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 }