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, 77 EmitterOrder.BEFORE); 78 79 } 80 81 /** 82 Loads a plugin into the bot, optionally restoring previous plugin state. 83 */ 84 void loadPlugin(Plugin p, PluginState state = null) { 85 p.load(this, state); 86 this.plugins[p.name] = p; 87 88 // Bind listeners 89 foreach (ref listener; p.listeners) { 90 this.log.infof("Registering listener for event %s", listener.clsName); 91 listener.listener = this.client.events.listenRaw(listener.clsName, toDelegate(listener.func), listener.order); 92 } 93 } 94 95 96 /** 97 Unloads a plugin from the bot, unbinding all listeners and commands. 98 */ 99 void unloadPlugin(Plugin p) { 100 p.unload(this); 101 this.plugins.remove(p.name); 102 103 foreach(ref listener; p.listeners) listener.listener.unbind(); 104 } 105 106 /** 107 Unloads a plugin from the bot by name. 108 */ 109 void unloadPlugin(string name) { 110 this.unloadPlugin(this.plugins[name]); 111 } 112 113 /** 114 Returns true if the current bot instance/configuration supports all of the 115 passed BotFeature flags. 116 */ 117 bool feature(BotFeatures[] features...) { 118 return (this.config.features & reduce!((a, b) => a & b)(features)) > 0; 119 } 120 121 private void tryHandleCommand(CommandEvent event) { 122 // If we require a mention, make sure we got it 123 if (this.config.cmdRequireMention) { 124 if (!event.msg.mentions.length) { 125 return; 126 } else if (!event.msg.mentions.has(this.client.state.me.id)) { 127 return; 128 } 129 } 130 131 // Strip all mentions and spaces from the message 132 string contents = strip(event.msg.withoutMentions); 133 134 // If the message doesn't start with the command prefix, break 135 if (this.config.cmdPrefix.length) { 136 if (!contents.startsWith(this.config.cmdPrefix)) { 137 return; 138 } 139 140 // Replace the command prefix from the string 141 contents = contents[this.config.cmdPrefix.length..contents.length]; 142 } 143 144 // Iterate over all plugins and check for command matches 145 Captures!string capture; 146 foreach (ref plugin; this.plugins.values) { 147 foreach (ref command; plugin.commands) { 148 if (!command.enabled) continue; 149 150 auto c = command.match(contents); 151 if (c.length) { 152 event.cmd = command; 153 capture = c; 154 break; 155 } 156 } 157 } 158 159 // If we didn't match any CommandObject, carry on our merry way 160 if (!capture) { 161 return; 162 } 163 164 // Extract some stuff for the CommandEvent 165 if (capture.back.length) { 166 event.contents = strip(capture.back); 167 } else { 168 event.contents = strip(capture.post); 169 } 170 171 event.args = event.contents.split(" "); 172 173 if (event.args.length && event.args[0] == "") { 174 event.args = event.args[1..$]; 175 } 176 177 // Check permissions (if enabled) 178 if (this.config.levelsEnabled) { 179 if (this.getLevel(event) < event.cmd.level) { 180 return; 181 } 182 } 183 184 // Set the command event so other people can introspect it 185 event.event.commandEvent = event; 186 event.cmd.call(event); 187 } 188 189 private void onMessageCreate(MessageCreate event) { 190 if(this.feature(BotFeatures.COMMANDS)) this.tryHandleCommand(new CommandEvent(event)); 191 } 192 193 /** 194 Starts the bot. 195 Params: 196 game = an optional Game object. 197 */ 198 void run(Game game=null) { 199 if(game is null) client.gw.start(); 200 else client.gw.start(game); 201 } 202 203 204 /// Base implementation for getting a level from a user. Override this. 205 int getLevel(User user) { // stfu 206 return 0; 207 } 208 209 /// Base implementation for getting a level from a role. Override this. 210 int getLevel(Role role) { // stfu 211 return 0; 212 } 213 214 /// Override implementation for getting a level from a user (for command handling) 215 int getLevel(CommandEvent event) { 216 // If we where sent in a guild, check role permissions 217 int roleLevel = 0; 218 if (event.msg.guild) { 219 auto guild = event.msg.guild; 220 auto member = guild.getMember(event.msg.author); 221 222 if (member && member.roles) { 223 roleLevel = member.roles.map!(rid => this.getLevel(guild.roles.get(rid))).reduce!max; 224 } 225 } 226 227 return max(roleLevel, this.getLevel(event.msg.author)); 228 } 229 230 void updateStatus(Game game=null) { 231 client.gw.updateStatus(game); 232 } 233 }