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 };