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 }