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