1 /** 2 A simple but extendable Discord bot implementation. 3 */ 4 5 module dcord.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 dcord.bot, 15 dcord.types, 16 dcord.client, 17 dcord.gateway, 18 dcord.util.emitter, 19 dcord.util.errors; 20 21 /** 22 Feature flags that can be used to toggle behavior of the Bot interface. 23 */ 24 enum BotFeatures { 25 /** This bot will parse/dispatch commands */ 26 COMMANDS = 1 << 1, 27 } 28 29 /** 30 Configuration that can be used to control the behavior of the Bot. 31 */ 32 struct BotConfig { 33 /** API Authentication Token */ 34 string token; 35 36 /** Shard number of this instance */ 37 ushort shard = 0; 38 39 /** The total number of shards */ 40 ushort numShards = 1; 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 = false; 50 51 /** Whether the bot should use permission levels */ 52 bool levelsEnabled = false; 53 54 @property ShardInfo* shardInfo() { 55 return new ShardInfo(this.shard, this.numShards); 56 } 57 } 58 59 /** 60 The Bot class is an extensible, fully-featured base for building Bots with the 61 dcord library. It's meant to serve as a base class that can be extended in 62 seperate projects. 63 */ 64 class Bot { 65 Client client; 66 BotConfig config; 67 Logger log; 68 69 Plugin[string] plugins; 70 71 this(this T)(BotConfig bc, LogLevel lvl=LogLevel.all) { 72 this.config = bc; 73 this.client = new Client(this.config.token, lvl, this.config.shardInfo); 74 this.log = this.client.log; 75 76 if(this.feature(BotFeatures.COMMANDS)) this.client.events.listen!MessageCreate(&this.onMessageCreate, EmitterOrder.BEFORE); 77 78 } 79 80 /** 81 Loads a plugin into the bot, optionally restoring previous plugin state. 82 */ 83 void loadPlugin(Plugin p, PluginState state = null) { 84 p.load(this, state); 85 this.plugins[p.name] = p; 86 87 // Bind listeners 88 foreach (ref listener; p.listeners) { 89 this.log.infof("Registering listener for event %s", listener.clsName); 90 listener.listener = this.client.events.listenRaw(listener.clsName, toDelegate(listener.func), listener.order); 91 } 92 } 93 94 95 /** 96 Unloads a plugin from the bot, unbinding all listeners and commands. 97 */ 98 void unloadPlugin(Plugin p) { 99 p.unload(this); 100 this.plugins.remove(p.name); 101 102 foreach (ref listener; p.listeners) { 103 listener.listener.unbind(); 104 } 105 } 106 107 /** 108 Unloads a plugin from the bot by name. 109 */ 110 void unloadPlugin(string name) { 111 this.unloadPlugin(this.plugins[name]); 112 } 113 114 /** 115 Returns true if the current bot instance/configuration supports all of the 116 passed BotFeature flags. 117 */ 118 bool feature(BotFeatures[] features...) { 119 return (this.config.features & reduce!((a, b) => a & b)(features)) > 0; 120 } 121 122 private void tryHandleCommand(CommandEvent event) { 123 // If we require a mention, make sure we got it 124 if (this.config.cmdRequireMention) { 125 if (!event.msg.mentions.length) { 126 return; 127 } else if (!event.msg.mentions.has(this.client.state.me.id)) { 128 return; 129 } 130 } 131 132 // Strip all mentions and spaces from the message 133 string contents = strip(event.msg.withoutMentions); 134 135 // If the message doesn't start with the command prefix, break 136 if (this.config.cmdPrefix.length) { 137 if (!contents.startsWith(this.config.cmdPrefix)) { 138 return; 139 } 140 141 // Replace the command prefix from the string 142 contents = contents[this.config.cmdPrefix.length..contents.length]; 143 } 144 145 // Iterate over all plugins and check for command matches 146 Captures!string capture; 147 foreach (ref plugin; this.plugins.values) { 148 foreach (ref command; plugin.commands) { 149 if (!command.enabled) continue; 150 151 auto c = command.match(contents); 152 if (c.length) { 153 event.cmd = command; 154 capture = c; 155 break; 156 } 157 } 158 } 159 160 // If we didn't match any CommandObject, carry on our merry way 161 if (!capture) { 162 return; 163 } 164 165 // Extract some stuff for the CommandEvent 166 if (capture.back.length) { 167 event.contents = strip(capture.back); 168 } else { 169 event.contents = strip(capture.post); 170 } 171 172 event.args = event.contents.split(" "); 173 174 if (event.args.length && event.args[0] == "") { 175 event.args = event.args[1..$]; 176 } 177 178 // Check permissions (if enabled) 179 if (this.config.levelsEnabled) { 180 if (this.getLevel(event) < event.cmd.level) { 181 return; 182 } 183 } 184 185 // Set the command event so other people can introspect it 186 event.event.commandEvent = event; 187 event.cmd.call(event); 188 } 189 190 private void onMessageCreate(MessageCreate event) { 191 if (this.feature(BotFeatures.COMMANDS)) { 192 this.tryHandleCommand(new CommandEvent(event)); 193 } 194 } 195 196 /** 197 Starts the bot. 198 */ 199 void run() { 200 client.gw.start(); 201 } 202 203 /// Base implementation for getting a level from a user. Override this. 204 int getLevel(User user) { 205 return 0; 206 } 207 208 /// Base implementation for getting a level from a role. Override this. 209 int getLevel(Role role) { 210 return 0; 211 } 212 213 /// Override implementation for getting a level from a user (for command handling) 214 int getLevel(CommandEvent event) { 215 // If we where sent in a guild, check role permissions 216 int roleLevel = 0; 217 if (event.msg.guild) { 218 auto guild = event.msg.guild; 219 auto member = guild.getMember(event.msg.author); 220 221 if (member && member.roles) { 222 roleLevel = member.roles.map!((rid) => 223 this.getLevel(guild.roles.get(rid)) 224 ).reduce!max; 225 } 226 } 227 228 return max(roleLevel, this.getLevel(event.msg.author)); 229 } 230 231 }