1 /**
2   A simple but extendable Discord bot implementation.
3 */
4 
5 module dscord.bot.bot;
6 
7 import std.algorithm,
8        std.array,
9        std.experimental.logger,
10        std.regex,
11        std.functional,
12        std.string : strip, toStringz, fromStringz;
13 
14 import dscord.client,
15        dscord.bot.command,
16        dscord.bot.plugin,
17        dscord.types.all,
18        dscord.gateway.events,
19        dscord.util.errors;
20 
21 version (linux) {
22   import core.stdc.stdio;
23   import core.stdc.stdlib;
24   import core.sys.posix.dlfcn;
25 }
26 
27 /**
28   Feature flags that can be used to toggle behavior of the Bot interface.
29 */
30 enum BotFeatures {
31   /** This bot will parse/dispatch commands */
32   COMMANDS = 1 << 1,
33 }
34 
35 /**
36   Configuration that can be used to control the behavior of the Bot.
37 */
38 struct BotConfig {
39   /** API Authentication Token */
40   string  token;
41 
42   /** Bitwise flags from `BotFeatures` */
43   uint    features = BotFeatures.COMMANDS;
44 
45   /** Command prefix (can be empty for none) */
46   string  cmdPrefix = "!";
47 
48   /** Whether the bot requires mentioning to respond */
49   bool    cmdRequireMention = true;
50 
51   /** Function which should be used to determine a given users level */
52   int delegate(User)  lvlGetter;
53 
54   /** Returns true if user levels are enabled (e.g. lvlGetter is set) */
55   @property bool lvlEnabled() {
56     return this.lvlGetter != null;
57   }
58 }
59 
60 /**
61   The Bot class is an extensible, fully-featured base for building Bots with the
62   dscord library. It was meant to serve as a base class that can be extended in
63   seperate projects.
64 */
65 class Bot {
66   Client     client;
67   BotConfig  config;
68   Logger  log;
69 
70   Plugin[string]  plugins;
71 
72   this(this T)(BotConfig bc, LogLevel lvl=LogLevel.all) {
73     this.config = bc;
74     this.client = new Client(this.config.token, lvl);
75     this.log = this.client.log;
76 
77     if (this.feature(BotFeatures.COMMANDS)) {
78       this.client.events.listen!MessageCreate(&this.onMessageCreate);
79     }
80   }
81 
82   /**
83     Loads a plugin into the bot, optionally restoring previous plugin state.
84   */
85   void loadPlugin(Plugin p, PluginState state = null) {
86     p.load(this, state);
87     this.plugins[p.name] = p;
88 
89     // Bind listeners
90     foreach (ref listener; p.listeners) {
91       this.log.infof("Registering listener for event %s", listener.clsName);
92       listener.listener = this.client.events.listenRaw(listener.clsName, toDelegate(listener.func));
93     }
94   }
95 
96   // Dynamic library plugin loading (linux only currently)
97   version (linux) {
98     /**
99       Loads a plugin from a dynamic library, optionally restoring previous plugin
100       state.
101     */
102     Plugin dynamicLoadPlugin(string path, PluginState state) {
103       // Attempt to load the dynamic library from a given path
104       void* lh = dlopen(toStringz(path), RTLD_NOW);
105       if (!lh) {
106         throw new BaseError("Failed to dynamically load plugin: %s", fromStringz(dlerror()));
107       }
108 
109       // Try to grab the create function (which should return a new plugin instance)
110       Plugin function() fn = cast(Plugin function())dlsym(lh, "create");
111       char* error = dlerror();
112       if (error) {
113         throw new BaseError("Failed to dynamically load plugin create function: %s", fromStringz(error));
114       }
115 
116       // Finally create the plugin instance and register it.
117       Plugin p = fn();
118       this.loadPlugin(p, state);
119 
120       // Track the DLL handle so we can close it when unloading
121       p.dynamicLibrary = lh;
122       p.dynamicLibraryPath = path;
123       return p;
124     }
125 
126     /**
127       Reloads a plugin which was previously loaded as a dynamic library. This
128       function restores previous plugin state.
129     */
130     Plugin dynamicReloadPlugin(Plugin p) {
131       string path = p.dynamicLibraryPath;
132       PluginState state = p.state;
133       this.unloadPlugin(p);
134       return this.dynamicLoadPlugin(path, state);
135     }
136 
137     // not linux
138     } else {
139 
140     Plugin dynamicLoadPlugin(string path, PluginState state) {
141       throw new BaseError("Dynamic plugins are only supported on linux");
142     }
143 
144     Plugin dynamicReloadPlugin(Plugin p) {
145       throw new BaseError("Dynamic plugins are only supported on linux");
146     }
147   }
148 
149   /**
150     Unloads a plugin from the bot, unbinding all listeners and commands.
151   */
152   void unloadPlugin(Plugin p) {
153     p.unload(this);
154     this.plugins.remove(p.name);
155 
156     foreach (ref listener; p.listeners) {
157       listener.listener.unbind();
158     }
159 
160     // Loaded dynamically, close the DLL
161     version (linux) {
162       if (p.dynamicLibrary) {
163         void* lh = p.dynamicLibrary;
164         p.destroy();
165         dlclose(lh);
166       }
167     }
168   }
169 
170   /**
171     Unloads a plugin from the bot by name.
172   */
173   void unloadPlugin(string name) {
174     this.unloadPlugin(this.plugins[name]);
175   }
176 
177   /**
178     Returns true if the current bot instance/configuration supports all of the
179     passed BotFeature flags.
180   */
181   bool feature(BotFeatures[] features...) {
182     return (this.config.features & reduce!((a, b) => a & b)(features)) > 0;
183   }
184 
185   private void tryHandleCommand(CommandEvent event) {
186     // If we require a mention, make sure we got it
187     if (this.config.cmdRequireMention) {
188       if (!event.msg.mentions.length) {
189         return;
190       } else if (!event.msg.mentions.has(this.client.state.me.id)) {
191         return;
192       }
193     }
194 
195     // Strip all mentions and spaces from the message
196     string contents = strip(event.msg.withoutMentions);
197 
198     // If the message doesn't start with the command prefix, break
199     if (this.config.cmdPrefix.length) {
200       if (!contents.startsWith(this.config.cmdPrefix)) {
201         return;
202       }
203 
204       // Replace the command prefix from the string
205       contents = contents[this.config.cmdPrefix.length..contents.length];
206     }
207 
208     // Iterate over all plugins and check for command matches
209     Captures!string capture;
210     foreach (ref plugin; this.plugins.values) {
211       foreach (ref command; plugin.commands) {
212         if (!command.enabled) continue;
213 
214         auto c = command.match(contents);
215         if (c.length) {
216           event.cmd = command;
217           capture = c;
218           break;
219         }
220       }
221     }
222 
223     // If we didn't match any CommandObject, carry on our merry way
224     if (!capture) {
225       return;
226     }
227 
228     // Extract some stuff for the CommandEvent
229     event.contents = strip(capture.post());
230     event.args = event.contents.split(" ");
231 
232     if (event.args.length && event.args[0] == "") {
233       event.args = event.args[1..event.args.length];
234     }
235 
236     // Check permissions
237     if (this.config.lvlEnabled) {
238       if (this.config.lvlGetter(event.msg.author) < event.cmd.level) {
239         return;
240       }
241     }
242 
243     event.cmd.func(event);
244   }
245 
246   private void onMessageCreate(MessageCreate event) {
247     if (this.feature(BotFeatures.COMMANDS)) {
248       this.tryHandleCommand(new CommandEvent(event));
249     }
250   }
251 
252   /**
253     Starts the bot.
254   */
255   void run() {
256     client.gw.start();
257   }
258 };