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.bot,
15        dscord.types,
16        dscord.client,
17        dscord.gateway,
18        dscord.util.dynlib,
19        dscord.util.emitter,
20        dscord.util.errors;
21 
22 /**
23   Feature flags that can be used to toggle behavior of the Bot interface.
24 */
25 enum BotFeatures {
26   /** This bot will parse/dispatch commands */
27   COMMANDS = 1 << 1,
28 }
29 
30 /**
31   Configuration that can be used to control the behavior of the Bot.
32 */
33 struct BotConfig {
34   /** API Authentication Token */
35   string  token;
36 
37   /** This bot instances shard number */
38   ushort shard = 0;
39 
40   /** The total number of shards */
41   ushort numShards = 1;
42 
43   /** Bitwise flags from `BotFeatures` */
44   uint    features = BotFeatures.COMMANDS;
45 
46   /** Command prefix (can be empty for none) */
47   string  cmdPrefix = "!";
48 
49   /** Whether the bot requires mentioning to respond */
50   bool    cmdRequireMention = true;
51 
52   /** Whether the bot should use permission levels */
53   bool    levelsEnabled = false;
54 
55   @property ShardInfo* shardInfo() {
56     return new ShardInfo(this.shard, this.numShards);
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, this.config.shardInfo);
75     this.log = this.client.log;
76 
77     if (this.feature(BotFeatures.COMMANDS)) {
78       this.client.events.listen!MessageCreate(&this.onMessageCreate, EmitterOrder.BEFORE);
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), listener.order);
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       DynamicLibrary dl = loadDynamicLibrary(path);
104       Plugin function() fn = dl.loadFromDynamicLibrary!(Plugin function())("create");
105 
106       // Finally create the plugin instance and register it.
107       Plugin p = fn();
108       this.loadPlugin(p, state);
109 
110       // Track the DLL handle so we can close it when unloading
111       p.dynamicLibrary = dl;
112       p.dynamicLibraryPath = path;
113       return p;
114     }
115 
116     /**
117       Reloads a plugin which was previously loaded as a dynamic library. This
118       function restores previous plugin state.
119     */
120     Plugin dynamicReloadPlugin(Plugin p) {
121       string path = p.dynamicLibraryPath;
122       PluginState state = p.state;
123       this.unloadPlugin(p);
124       return this.dynamicLoadPlugin(path, state);
125     }
126 
127     // not linux
128     } else {
129 
130     Plugin dynamicLoadPlugin(string path, PluginState state) {
131       throw new BaseError("Dynamic plugins are only supported on linux");
132     }
133 
134     Plugin dynamicReloadPlugin(Plugin p) {
135       throw new BaseError("Dynamic plugins are only supported on linux");
136     }
137   }
138 
139   /**
140     Unloads a plugin from the bot, unbinding all listeners and commands.
141   */
142   void unloadPlugin(Plugin p) {
143     p.unload(this);
144     this.plugins.remove(p.name);
145 
146     foreach (ref listener; p.listeners) {
147       listener.listener.unbind();
148     }
149 
150     // Loaded dynamically, close the DLL
151     version (linux) {
152       if (p.dynamicLibrary) {
153         void* dl = p.dynamicLibrary;
154         p.destroy();
155         dl.unloadDynamicLibrary();
156       }
157     }
158   }
159 
160   /**
161     Unloads a plugin from the bot by name.
162   */
163   void unloadPlugin(string name) {
164     this.unloadPlugin(this.plugins[name]);
165   }
166 
167   /**
168     Returns true if the current bot instance/configuration supports all of the
169     passed BotFeature flags.
170   */
171   bool feature(BotFeatures[] features...) {
172     return (this.config.features & reduce!((a, b) => a & b)(features)) > 0;
173   }
174 
175   private void tryHandleCommand(CommandEvent event) {
176     // If we require a mention, make sure we got it
177     if (this.config.cmdRequireMention) {
178       if (!event.msg.mentions.length) {
179         return;
180       } else if (!event.msg.mentions.has(this.client.state.me.id)) {
181         return;
182       }
183     }
184 
185     // Strip all mentions and spaces from the message
186     string contents = strip(event.msg.withoutMentions);
187 
188     // If the message doesn't start with the command prefix, break
189     if (this.config.cmdPrefix.length) {
190       if (!contents.startsWith(this.config.cmdPrefix)) {
191         return;
192       }
193 
194       // Replace the command prefix from the string
195       contents = contents[this.config.cmdPrefix.length..contents.length];
196     }
197 
198     // Iterate over all plugins and check for command matches
199     Captures!string capture;
200     foreach (ref plugin; this.plugins.values) {
201       foreach (ref command; plugin.commands) {
202         if (!command.enabled) continue;
203 
204         auto c = command.match(contents);
205         if (c.length) {
206           event.cmd = command;
207           capture = c;
208           break;
209         }
210       }
211     }
212 
213     // If we didn't match any CommandObject, carry on our merry way
214     if (!capture) {
215       return;
216     }
217 
218     // Extract some stuff for the CommandEvent
219     if (capture.back.length) {
220       event.contents = strip(capture.back);
221     } else {
222       event.contents = strip(capture.post);
223     }
224 
225     event.args = event.contents.split(" ");
226 
227     if (event.args.length && event.args[0] == "") {
228       event.args = event.args[1..$];
229     }
230 
231     // Check permissions (if enabled)
232     if (this.config.levelsEnabled) {
233       if (this.getLevel(event) < event.cmd.level) {
234         return;
235       }
236     }
237 
238     // Set the command event so other people can introspect it
239     event.event.commandEvent = event;
240     event.cmd.call(event);
241   }
242 
243   private void onMessageCreate(MessageCreate event) {
244     if (this.feature(BotFeatures.COMMANDS)) {
245       this.tryHandleCommand(new CommandEvent(event));
246     }
247   }
248 
249   /**
250     Starts the bot.
251   */
252   void run() {
253     client.gw.start();
254   }
255 
256   /// Base implementation for getting a level from a user. Override this.
257   int getLevel(User user) {
258     return 0;
259   }
260 
261   /// Base implementation for getting a level from a role. Override this.
262   int getLevel(Role role) {
263     return 0;
264   }
265 
266   /// Override implementation for getting a level from a user (for command handling)
267   int getLevel(CommandEvent event) {
268     // If we where sent in a guild, check role permissions
269     int roleLevel = 0;
270     if (event.msg.guild) {
271       auto guild = event.msg.guild;
272       auto member = guild.getMember(event.msg.author);
273 
274       if (member && member.roles) {
275         roleLevel = member.roles.map!((rid) =>
276           this.getLevel(guild.roles.get(rid))
277         ).reduce!max;
278       }
279     }
280 
281     return max(roleLevel, this.getLevel(event.msg.author));
282   }
283 };