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