1 /** 2 Base class for creating plugins the Bot can load/unload. 3 */ 4 5 module dscord.bot.plugin; 6 7 import std.path, 8 std.file, 9 std.variant; 10 11 import std.experimental.logger, 12 vibe.core.core : runTask; 13 14 import dscord.bot, 15 dscord.types, 16 dscord.client, 17 dscord.util.dynlib, 18 dscord.util.storage; 19 20 /** 21 Sentinel for @Synced attributes 22 TODO: this is messy. Better way to achieve a sentinel? 23 */ 24 struct SyncedAttribute { 25 string syncedAttributeSentinel; 26 }; 27 28 /** 29 UDA which tells StateSyncable that a member attribute should be synced into 30 the state on plugin load/unload. 31 */ 32 SyncedAttribute Synced() { 33 return SyncedAttribute(); 34 } 35 36 /** 37 PluginState is a class the encapsulates all run-time state required for a 38 plugin to exist. It's purpose is to allow for hot-reloading and replacing 39 of plugin code, without destroy/rebuilding run-time data. 40 */ 41 class PluginState { 42 /** Plugin JSON Storage file (for data) */ 43 Storage storage; 44 45 /** Plugin JSON Config file */ 46 Storage config; 47 48 /** PluginOptions struct */ 49 PluginOptions options; 50 51 /** Custom state data stored by the plugin */ 52 Variant[string] custom; 53 54 this(Plugin plugin, PluginOptions opts) { 55 this.options = opts ? opts : new PluginOptions; 56 57 if (this.options.useStorage) { 58 this.storage = new Storage(plugin.storagePath); 59 } 60 61 if (this.options.useConfig) { 62 this.config = new Storage(plugin.configPath); 63 } 64 } 65 } 66 67 /** 68 The StateSyncable template is an implementation which handles the syncing of 69 member attributes into are PluginState.custom store during plugin load/unload. 70 This allows plugin developers to simply attach the @Synced UDA to any attributes 71 they wish to be stored, and then call stateLoad and stateUnload in the plugin 72 load/unload functions. 73 */ 74 mixin template StateSyncable() { 75 /// Loads all custom attribute state from a PluginState. 76 void stateLoad(T)(PluginState state) { 77 foreach (mem; __traits(allMembers, T)) { 78 foreach (attr; __traits(getAttributes, __traits(getMember, T, mem))) { 79 static if(__traits(hasMember, attr, "syncedAttributeSentinel")) { 80 if (mem in state.custom && state.custom[mem].hasValue()) { 81 mixin("(cast(T)this)." ~ mem ~ " = " ~ "state.custom[\"" ~ mem ~ "\"].get!(typeof(__traits(getMember, T, mem)));"); 82 } 83 } 84 } 85 } 86 } 87 88 /// Unloads all custom attributes into a PluginState. 89 void stateUnload(T)(PluginState state) { 90 foreach (mem; __traits(allMembers, T)) { 91 foreach (attr; __traits(getAttributes, __traits(getMember, T, mem))) { 92 static if(__traits(hasMember, attr, "syncedAttributeSentinel")) { 93 mixin("state.custom[\"" ~ mem ~ "\"] = " ~ "Variant((cast(T)this)." ~ mem ~ ");"); 94 } 95 } 96 } 97 } 98 } 99 100 /** 101 PluginOptions is a class that can be used to configure the base functionality 102 and utilties in use by a plugin. 103 */ 104 class PluginOptions { 105 /** Does this plugin load/require a configuration file? */ 106 bool useConfig = false; 107 108 /** Does this plugin load/require a JSON storage file? */ 109 bool useStorage = false; 110 111 /** Does this plugin auto-load level/command overrides from its config? */ 112 bool useOverrides = false; 113 114 /** Default command group to use */ 115 string commandGroup = ""; 116 } 117 118 /** 119 A Plugin represents a modular, extendable class that encapsulates certain 120 Bot functionality into a logical slice. Plugins usually have a set of commands 121 and listeners attached to them, and are built to be dynamically loaded/reloaded 122 into a Bot. 123 */ 124 class Plugin { 125 /// Bot instance for this plugin. Should always be set 126 Bot bot; 127 128 /// Current runtime state for this plugin 129 PluginState state; 130 131 mixin Listenable; 132 mixin Commandable; 133 mixin StateSyncable; 134 135 /** 136 The path to the dynamic library this plugin was loaded from. If set, this 137 signals this Plugin was loaded from a dynamic library, and can be reloaded 138 from the given path. 139 */ 140 string dynamicLibraryPath; 141 142 /// Pointer to the dynamic library, used for cleaning up on shutdown. 143 DynamicLibrary dynamicLibrary; 144 145 /// Constructor for initial load. Usually called from the inherited constructor. 146 this(this T)(PluginOptions opts = null) { 147 this.state = new PluginState(this, opts); 148 149 this.loadCommands!T(); 150 this.loadListeners!T(); 151 } 152 153 /// Plugin log instance. 154 @property Logger log() { 155 return this.bot.log; 156 } 157 158 /// Used to load the Plugin, initially loading state if requred. 159 void load(Bot bot, PluginState state = null) { 160 this.bot = bot; 161 162 // Make sure our storage directory exists 163 if (this.options.useStorage && !exists(this.storageDirectoryPath)) { 164 mkdirRecurse(this.storageDirectoryPath); 165 } 166 167 // If we got state, assume this was a plugin reload and replace 168 if (state) { 169 this.state = state; 170 } else { 171 // If plugin uses storage, load the storage from disk 172 if (this.options.useStorage) { 173 this.storage.load(); 174 this.storage.save(); 175 } 176 177 // If plugin uses config, load the config from disk 178 if (this.options.useConfig) { 179 this.config.load(); 180 this.config.save(); 181 } 182 } 183 184 string group = this.options.commandGroup; 185 186 if (this.options.useOverrides && this.config) { 187 if (this.config.has("levels")) { 188 auto levels = this.config.get!(VibeJSON[string])("levels"); 189 190 foreach (name, level; levels) { 191 auto cmd = this.commands[name]; 192 cmd.level = level.get!int; 193 } 194 } 195 196 // Try grabbing an override for group 197 group = this.config.get!string("group", group); 198 } 199 200 // If we have an override value for the commandgroup, set it now on all commands 201 if (group != "") { 202 foreach (command; this.commands.values) { 203 command.setGroup(group); 204 } 205 } 206 } 207 208 /// Used to unload the Plugin. Saves config/storage if required. 209 void unload(Bot bot) { 210 if (this.options.useStorage) { 211 this.storage.save(); 212 } 213 214 if (this.options.useConfig) { 215 this.config.save(); 216 } 217 } 218 219 /// Returns path to this plugins storage directory. 220 @property string storageDirectoryPath() { 221 return "storage" ~ dirSeparator ~ this.name; 222 } 223 224 /// Returns path to this plugins storage file. 225 @property string storagePath() { 226 return this.storageDirectoryPath ~ dirSeparator ~ "storage.json"; 227 } 228 229 /// Returns path to this plugins config file. 230 @property string configPath() { 231 return "config" ~ dirSeparator ~ this.name ~ ".json"; 232 } 233 234 /// Storage instance for this plugin. 235 @property Storage storage() { 236 return this.state.storage; 237 } 238 239 /// Config instance for this plugin 240 @property Storage config() { 241 return this.state.config; 242 } 243 244 /// PluginOptions for this plugin 245 @property PluginOptions options() { 246 return this.state.options; 247 } 248 249 /// Client instance for the Bot running this plugin 250 @property Client client() { 251 return this.bot.client; 252 } 253 254 /// User instance for the account this bot is running under 255 @property User me() { 256 return this.client.state.me; 257 } 258 259 /// Returns the name of this plugin. 260 string name() { 261 return typeof(this).toString; 262 } 263 }