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 }