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 }