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 
181         foreach (name, level; levels) {
182           auto cmd = this.commands[name];
183           cmd.level = level.get!int;
184         }
185       }
186 
187       // Try grabbing an override for group
188       group = this.config.get!string("group", group);
189     }
190 
191     // If we have an override value for the commandgroup, set it now on all commands
192     if (group != "") {
193       foreach (command; this.commands.values) {
194         command.setGroup(group);
195       }
196     }
197   }
198 
199   /// Used to unload the Plugin. Saves config/storage if required.
200   void unload(Bot bot) {
201     if (this.options.useStorage) {
202       this.storage.save();
203     }
204 
205     if (this.options.useConfig) {
206       this.config.save();
207     }
208   }
209 
210   /// Returns path to this plugins storage directory.
211   @property string storageDirectoryPath() {
212     return "storage" ~ dirSeparator ~ this.name;
213   }
214 
215   /// Returns path to this plugins storage file.
216   @property string storagePath() {
217     return this.storageDirectoryPath ~ dirSeparator ~ "storage.json";
218   }
219 
220   /// Returns path to this plugins config file.
221   @property string configPath() {
222     return "config" ~ dirSeparator ~ this.name ~ ".json";
223   }
224 
225   /// Storage instance for this plugin.
226   @property Storage storage() {
227     return this.state.storage;
228   }
229 
230   /// Config instance for this plugin
231   @property Storage config() {
232     return this.state.config;
233   }
234 
235   /// PluginOptions for this plugin
236   @property PluginOptions options() {
237     return this.state.options;
238   }
239 
240   /// Client instance for the Bot running this plugin
241   @property Client client() {
242     return this.bot.client;
243   }
244 
245   /// User instance for the account this bot is running under
246   @property User me() {
247     return this.client.state.me;
248   }
249 
250   /// Returns the name of this plugin.
251   string name() {
252     return typeof(this).toString;
253   }
254 }