1 /++
2     The Automode plugin handles automatically setting the modes of users in a
3     channel. The common use-case is to have someone be automatically set to `+o`
4     (operator) when joining.
5 
6     See_Also:
7         https://github.com/zorael/kameloso/wiki/Current-plugins#automode,
8         [kameloso.plugins.common.core],
9         [kameloso.plugins.common.misc]
10 
11     Copyright: [JR](https://github.com/zorael)
12     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
13 
14     Authors:
15         [JR](https://github.com/zorael)
16  +/
17 module kameloso.plugins.automode;
18 
19 version(WithAutomodePlugin):
20 
21 private:
22 
23 import kameloso.plugins;
24 import kameloso.plugins.common.core;
25 import kameloso.plugins.common.awareness : ChannelAwareness, UserAwareness;
26 import kameloso.common : logger;
27 import kameloso.messaging;
28 import dialect.defs;
29 import std.typecons : Flag, No, Yes;
30 
31 
32 // AutomodeSettings
33 /++
34     All Automode settings gathered in a struct.
35  +/
36 @Settings struct AutomodeSettings
37 {
38     /// Toggles whether or not the plugin should react to events at all.
39     @Enabler bool enabled = true;
40 }
41 
42 
43 // saveAutomodes
44 /++
45     Saves automode definitions to disk.
46 
47     Use JSON to get a pretty-printed list, then write it to disk.
48 
49     Params:
50         plugin = The current [AutomodePlugin].
51  +/
52 void saveAutomodes(AutomodePlugin plugin)
53 {
54     import lu.json : JSONStorage;
55     import std.json : JSONValue;
56 
57     // Create a JSONStorage only to save it
58     JSONStorage automodes;
59     pruneChannels(plugin.automodes);
60     automodes.storage = JSONValue(plugin.automodes);
61     automodes.save(plugin.automodeFile);
62 }
63 
64 
65 // initResources
66 /++
67     Ensures that there is an automodes file, creating one if there isn't.
68  +/
69 void initResources(AutomodePlugin plugin)
70 {
71     import lu.json : JSONStorage;
72     import std.json : JSONException;
73 
74     JSONStorage json;
75 
76     try
77     {
78         json.load(plugin.automodeFile);
79     }
80     catch (JSONException e)
81     {
82         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
83 
84         version(PrintStacktraces) logger.trace(e);
85         throw new IRCPluginInitialisationException(
86             "Automodes file is malformed",
87             plugin.name,
88             plugin.automodeFile,
89             __FILE__,
90             __LINE__);
91     }
92 
93     // Let other Exceptions pass.
94 
95     // Adjust saved JSON layout to be more easily edited
96     json.save(plugin.automodeFile);
97 }
98 
99 
100 // onAccountInfo
101 /++
102     Potentially applies an automode, depending on the definitions and the user
103     triggering the function.
104 
105     Different [dialect.defs.IRCEvent.Type|IRCEvent.Type]s have to be handled differently,
106     as the triggering user may be either the sender or the target.
107 
108     Additionally none of these events carry a channel, so we'll have to make
109     manual checks to see if the user is in a home channel we're in. Otherwise
110     there's nothing for the bot to do.
111  +/
112 @(IRCEventHandler()
113     .onEvent(IRCEvent.Type.ACCOUNT)
114     .onEvent(IRCEvent.Type.RPL_WHOISACCOUNT)
115     .onEvent(IRCEvent.Type.RPL_WHOISREGNICK)
116     .onEvent(IRCEvent.Type.RPL_WHOISUSER)
117     .permissionsRequired(Permissions.ignore)
118 )
119 void onAccountInfo(AutomodePlugin plugin, const ref IRCEvent event)
120 {
121     // In case of self WHOIS results, don't automode ourselves
122     // target for WHOIS, sender for ACCOUNT
123     if ((event.target.nickname == plugin.state.client.nickname) ||
124         (event.sender.nickname == plugin.state.client.nickname)) return;
125 
126     string account;
127     string nickname;
128 
129     with (IRCEvent.Type)
130     switch (event.type)
131     {
132     case ACCOUNT:
133         if (!event.sender.account.length) return;
134         account = event.sender.account;
135         nickname = event.sender.nickname;
136         break;
137 
138     case RPL_WHOISUSER:
139         if (plugin.state.settings.preferHostmasks && event.target.account.length)
140         {
141             // Persistence will have set the account field, if there is any to set.
142             goto case RPL_WHOISACCOUNT;
143         }
144         return;
145 
146     case RPL_WHOISACCOUNT:
147     case RPL_WHOISREGNICK:
148         account = event.target.account;
149         nickname = event.target.nickname;
150         break;
151 
152     default:
153         assert(0, "Invalid `onEvent` type annotation on `" ~ __FUNCTION__ ~ '`');
154     }
155 
156     foreach (immutable homeChannel; plugin.state.bot.homeChannels)
157     {
158         if (const channel = homeChannel in plugin.state.channels)
159         {
160             if (nickname in channel.users)
161             {
162                 applyAutomodes(plugin, homeChannel, nickname, account);
163             }
164         }
165     }
166 }
167 
168 
169 // onJoin
170 /++
171     Applies automodes upon someone joining a home channel.
172 
173     [applyAutomodes] will cautiously probe whether there are any definitions to
174     apply, so there's little sense in doing it here as well. Just pass the
175     arguments and let it look things up.
176  +/
177 @(IRCEventHandler()
178     .onEvent(IRCEvent.Type.JOIN)
179     .permissionsRequired(Permissions.anyone)
180     .channelPolicy(ChannelPolicy.home)
181 )
182 void onJoin(AutomodePlugin plugin, const ref IRCEvent event)
183 {
184     if (event.sender.account.length)
185     {
186         applyAutomodes(plugin, event.channel, event.sender.nickname, event.sender.account);
187     }
188 }
189 
190 
191 // applyAutomodes
192 /++
193     Applies automodes for a specific user in a specific channel.
194 
195     Params:
196         plugin = The current [AutomodePlugin]
197         channelName = String channel to apply the modes in.
198         nickname = String nickname of the user to apply modes to.
199         account = String account of the user, to look up definitions for.
200  +/
201 void applyAutomodes(
202     AutomodePlugin plugin,
203     const string channelName,
204     const string nickname,
205     const string account)
206 in (channelName.length, "Tried to apply automodes to an empty channel string")
207 in (nickname.length, "Tried to apply automodes to an empty nickname")
208 in (account.length, "Tried to apply automodes to an empty account")
209 {
210     import std.string : representation;
211 
212     auto accountmodes = channelName in plugin.automodes;
213     if (!accountmodes) return;
214 
215     const wantedModes = account in *accountmodes;
216     if (!wantedModes || !wantedModes.length) return;
217 
218     auto channel = channelName in plugin.state.channels;
219     if (!channel) return;
220 
221     char[] missingModes;
222 
223     foreach (const mode; (*wantedModes).representation)
224     {
225         if (const usersWithThisMode = cast(char)mode in channel.mods)
226         {
227             if (!usersWithThisMode.length || (nickname !in *usersWithThisMode))
228             {
229                 // User doesn't have this mode
230                 missingModes ~= mode;
231             }
232         }
233         else
234         {
235             // No one has this mode, which by implication means the user doesn't either
236             missingModes ~= mode;
237         }
238     }
239 
240     if (!missingModes.length) return;
241 
242     if (plugin.state.client.nickname !in channel.ops)
243     {
244         enum pattern = "Could not apply <i>+%s</> <i>%s</> in <i>%s</> " ~
245             "because we are not an operator in the channel.";
246         return logger.logf(pattern, missingModes, nickname, channelName);
247     }
248 
249     mode(plugin.state, channel.name, "+" ~ missingModes, nickname);
250 }
251 
252 unittest
253 {
254     import lu.conv : Enum;
255     import std.concurrency;
256     import std.format : format;
257 
258     // Only tests the messenger mode call
259 
260     IRCPluginState state;
261     state.mainThread = thisTid;
262 
263     mode(state, "#channel", "+ov", "mydude");
264 
265     receive(
266         (Message m)
267         {
268             assert((m.event.type == IRCEvent.Type.MODE), Enum!(IRCEvent.Type).toString(m.event.type));
269             assert((m.event.channel == "#channel"), m.event.channel);
270             assert((m.event.aux[0] == "+ov"), m.event.aux[0]);
271             assert((m.event.content == "mydude"), m.event.content);
272             assert(m.properties == Message.Property.init);
273 
274             immutable line = "MODE %s %s %s".format(m.event.channel, m.event.aux[0], m.event.content);
275             assert((line == "MODE #channel +ov mydude"), line);
276         }
277     );
278 }
279 
280 
281 // onCommandAutomode
282 /++
283     Lists current automodes for a user in the current channel, clears them,
284     or adds new ones depending on the verb passed.
285  +/
286 @(IRCEventHandler()
287     .onEvent(IRCEvent.Type.CHAN)
288     .permissionsRequired(Permissions.operator)
289     .channelPolicy(ChannelPolicy.home)
290     .addCommand(
291         IRCEventHandler.Command()
292             .word("automode")
293             .policy(PrefixPolicy.prefixed)
294             .description("Adds, lists or removes automode definitions for the current channel.")
295             .addSyntax("$command add [account] [mode]")
296             .addSyntax("$command clear [account]")
297             .addSyntax("$command list")
298     )
299 )
300 void onCommandAutomode(AutomodePlugin plugin, const /*ref*/ IRCEvent event)
301 {
302     import dialect.common : isValidNickname;
303     import lu.string : SplitResults, beginsWith, nom, splitInto, stripped;
304     import std.algorithm.searching : count;
305     import std.format : format;
306 
307     void sendUsage()
308     {
309         enum pattern = "Usage: <b>%s%s<b> [add|clear|list] [nickname/account] [mode]";
310         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
311         chan(plugin.state, event.channel, message);
312     }
313 
314     void sendInvalidNickname()
315     {
316         enum message = "Invalid nickname.";
317         chan(plugin.state, event.channel, message);
318     }
319 
320     void sendCannotBeNegative()
321     {
322         enum message = "Automodes cannot be negative.";
323         chan(plugin.state, event.channel, message);
324     }
325 
326     void sendMustSupplyMode()
327     {
328         enum message = "You must supply a valid mode.";
329         chan(plugin.state, event.channel, message);
330     }
331 
332     void sendAutomodeModified(const string nickname, const string mode)
333     {
334         enum pattern = "Automode modified! <h>%s<h> in <b>%s<b>: +<b>%s<b>";
335         immutable message = pattern.format(nickname, event.channel, mode);
336         chan(plugin.state, event.channel, message);
337     }
338 
339     void sendAutomodeCleared(const string nickname)
340     {
341         enum pattern = "Automode for <h>%s<h> cleared.";
342         immutable message = pattern.format(nickname);
343         chan(plugin.state, event.channel, message);
344     }
345 
346     void sendAutomodeList(/*const*/ string[string] channelModes)
347     {
348         import std.conv : text;
349         immutable message = text("Current automodes: ", channelModes);
350         chan(plugin.state, event.channel, message);
351     }
352 
353     void sendNoAutomodes()
354     {
355         enum pattern = "No automodes defined for channel <b>%s<b>.";
356         immutable message = pattern.format(event.channel);
357         chan(plugin.state, event.channel, message);
358     }
359 
360     string line = event.content.stripped;  // mutable
361     immutable verb = line.nom!(Yes.inherit)(' ');
362 
363     switch (verb)
364     {
365     case "add":
366         // !automode add nickname mode
367         string nickname;  // mutable
368         string mode;  // mutable
369 
370         immutable result = line.splitInto(nickname, mode);
371         if (result != SplitResults.match) goto default;
372 
373         if (nickname.beginsWith('@')) nickname = nickname[1..$];
374 
375         if (!nickname.isValidNickname(plugin.state.server)) return sendInvalidNickname();
376 
377         if (mode.beginsWith('-')) return sendCannotBeNegative();
378 
379         while (mode.beginsWith('+'))
380         {
381             mode = mode[1..$];
382         }
383 
384         if (!mode.length) return sendMustSupplyMode();
385 
386         modifyAutomode(plugin, Yes.add, nickname, event.channel, mode);
387         return sendAutomodeModified(nickname, mode);
388 
389     case "clear":
390     case "del":
391         string nickname = line;  // mutable
392         if (nickname.beginsWith('@')) nickname = nickname[1..$];
393 
394         if (!nickname.length) goto default;
395 
396         if (!nickname.isValidNickname(plugin.state.server)) return sendInvalidNickname();
397 
398         modifyAutomode(plugin, No.add, nickname, event.channel);
399         return sendAutomodeCleared(nickname);
400 
401     case "list":
402         if (auto channelModes = event.channel in plugin.automodes)
403         {
404             // No const to get a better std.conv.text representation of it
405             return sendAutomodeList(*channelModes);
406         }
407         else
408         {
409             return sendNoAutomodes();
410         }
411 
412     default:
413         return sendUsage();
414     }
415 }
416 
417 
418 // modifyAutomode
419 /++
420     Modifies an automode entry by adding a new one or removing a (potentially)
421     existing one.
422 
423     Params:
424         plugin = The current [AutomodePlugin].
425         add = Whether to add or to remove the automode.
426         nickname = The nickname of the user to add the automode for.
427         channelName = The channel the automode should play out in.
428         mode = The mode string, when adding a new automode.
429  +/
430 void modifyAutomode(
431     AutomodePlugin plugin,
432     const Flag!"add" add,
433     const string nickname,
434     const string channelName,
435     const string mode = string.init)
436 in ((!add || mode.length), "Tried to add an empty automode")
437 {
438     import kameloso.plugins.common.mixins : WHOISFiberDelegate;
439 
440     void onSuccess(const string id)
441     {
442         if (add)
443         {
444             plugin.automodes[channelName][id] = mode;
445         }
446         else
447         {
448             auto channelmodes = channelName in plugin.automodes;
449             if (!channelmodes) return;
450 
451             if (id in *channelmodes)
452             {
453                 (*channelmodes).remove(id);
454             }
455         }
456 
457         saveAutomodes(plugin);
458     }
459 
460     void onFailure(const IRCUser failureUser)
461     {
462         logger.trace("(Assuming unauthenticated nickname or offline account was specified)");
463         return onSuccess(failureUser.nickname);
464     }
465 
466     if (const userOnRecord = nickname in plugin.state.users)
467     {
468         if (userOnRecord.account.length)
469         {
470             return onSuccess(userOnRecord.account);
471         }
472     }
473 
474     // WHOIS the supplied nickname and get its account, then add it.
475     // Assume the supplied nickname *is* the account if no match, error out if
476     // there is a match but the user isn't logged onto services.
477 
478     mixin WHOISFiberDelegate!(onSuccess, onFailure);
479 
480     enqueueAndWHOIS(nickname);
481 }
482 
483 
484 // onCommandOp
485 /++
486     Triggers a WHOIS of the user invoking it with bot commands.
487  +/
488 @(IRCEventHandler()
489     .onEvent(IRCEvent.Type.CHAN)
490     .permissionsRequired(Permissions.ignore)
491     .channelPolicy(ChannelPolicy.home)
492     .addCommand(
493         IRCEventHandler.Command()
494             .word("op")
495             .policy(PrefixPolicy.prefixed)
496             .description("Forces the bot to attempt to apply automodes.")
497     )
498 )
499 void onCommandOp(AutomodePlugin plugin, const ref IRCEvent event)
500 {
501     if (event.sender.account.length)
502     {
503         applyAutomodes(plugin, event.channel, event.sender.nickname, event.sender.account);
504     }
505     else
506     {
507         import kameloso.messaging : whois;
508         enum properties = Message.Property.forced;
509         whois(plugin.state, event.sender.nickname, properties);
510     }
511 }
512 
513 
514 // onWelcome
515 /++
516     Populate automodes array after we have successfully logged onto the server.
517  +/
518 @(IRCEventHandler()
519     .onEvent(IRCEvent.Type.RPL_WELCOME)
520 )
521 void onWelcome(AutomodePlugin plugin)
522 {
523     plugin.reload();
524 }
525 
526 
527 // reload
528 /++
529     Reloads automode definitions from disk.
530  +/
531 void reload(AutomodePlugin plugin)
532 {
533     import lu.json : JSONStorage, populateFromJSON;
534 
535     JSONStorage automodesJSON;
536     automodesJSON.load(plugin.automodeFile);
537     plugin.automodes.clear();
538     plugin.automodes.populateFromJSON(automodesJSON, Yes.lowercaseKeys);
539     plugin.automodes = plugin.automodes.rehash();
540 }
541 
542 
543 // onMode
544 /++
545     Applies automodes in a channel upon being given operator privileges.
546  +/
547 @(IRCEventHandler()
548     .onEvent(IRCEvent.Type.MODE)
549     .channelPolicy(ChannelPolicy.home)
550 )
551 void onMode(AutomodePlugin plugin, const ref IRCEvent event)
552 {
553     import std.algorithm.searching : canFind;
554 
555     if ((event.sender.nickname == plugin.state.client.nickname) ||
556         (event.target.nickname != plugin.state.client.nickname))
557     {
558         // Sender is us or target is not us (e.g. it cannot possibly be us becoming +o)
559         return;
560     }
561 
562     if (plugin.state.client.nickname !in plugin.state.channels[event.channel].ops) return;
563 
564     auto accountmodes = event.channel in plugin.automodes;
565     if (!accountmodes) return;
566 
567     foreach (immutable account; accountmodes.byKey)
568     {
569         import std.algorithm.iteration : filter;
570 
571         auto usersWithThatAccount = plugin.state.users
572             .byValue
573             .filter!(user => user.account == account);
574 
575         if (usersWithThatAccount.empty) continue;
576 
577         foreach (const user; usersWithThatAccount)
578         {
579             // There can technically be more than one
580             applyAutomodes(plugin, event.channel, user.nickname, user.account);
581         }
582     }
583 }
584 
585 
586 // pruneChannels
587 /++
588     Prunes empty channels in the automodes definitions associative array.
589 
590     Params:
591         automodes = Associative array of automodes to prune.
592  +/
593 void pruneChannels(ref string[string][string] automodes)
594 {
595     import lu.objmanip : pruneAA;
596     pruneAA(automodes);
597 }
598 
599 
600 mixin UserAwareness;
601 mixin ChannelAwareness;
602 mixin PluginRegistration!AutomodePlugin;
603 
604 public:
605 
606 
607 // AutomodePlugin
608 /++
609     The Automode plugin automatically changes modes of users in channels as per
610     saved definitions.
611 
612     Definitions are saved in a JSON file.
613  +/
614 final class AutomodePlugin : IRCPlugin
615 {
616 private:
617     /// All Automode options gathered.
618     AutomodeSettings automodeSettings;
619 
620     /// Associative array of automodes.
621     string[string][string] automodes;
622 
623     /// The file to read and save automode definitions from/to.
624     @Resource string automodeFile = "automodes.json";
625 
626 
627     // isEnabled
628     /++
629         Override
630         [kameloso.plugins.common.core.IRCPlugin.isEnabled|IRCPlugin.isEnabled]
631         (effectively overriding [kameloso.plugins.common.core.IRCPluginImpl.isEnabled|IRCPluginImpl.isEnabled])
632         and inject a server check, so this service does nothing on Twitch servers,
633         in addition to doing nothing when [AutomodeSettings.enabled] is false.
634 
635         Returns:
636             `true` if this plugin should react to events; `false` if not.
637      +/
638     version(TwitchSupport)
639     override public bool isEnabled() const @property pure nothrow @nogc
640     {
641         return (state.server.daemon != IRCServer.Daemon.twitch) && automodeSettings.enabled;
642     }
643 
644     mixin IRCPluginImpl;
645 }