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 }