1 /++ 2 The Channel Queries service queries channels for information about them (in 3 terms of topic and modes) as well as their lists of participants. It does this 4 shortly after having joined a channel, as a service to all other plugins, 5 so they don't each have to independently do it themselves. 6 7 It is qualified as a service, so while it is not technically mandatory, it 8 is highly recommended if you plan on mixing in 9 [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness] into 10 your plugins. 11 12 See_Also: 13 [kameloso.plugins.common.core], 14 [kameloso.plugins.common.misc] 15 16 Copyright: [JR](https://github.com/zorael) 17 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 18 19 Authors: 20 [JR](https://github.com/zorael) 21 +/ 22 module kameloso.plugins.services.chanqueries; 23 24 version(WithChanQueriesService): 25 26 private: 27 28 import kameloso.plugins; 29 import kameloso.plugins.common.core; 30 import kameloso.plugins.common.delayawait; 31 import kameloso.plugins.common.awareness : ChannelAwareness, UserAwareness; 32 import dialect.defs; 33 import std.typecons : Flag, No, Yes; 34 35 36 version(OmniscientQueries) 37 { 38 /++ 39 The [kameloso.plugins.common.core.ChannelPolicy|ChannelPolicy] to mix in 40 awareness with depending on whether version `OmniscientQueries` is set or not. 41 +/ 42 enum omniscientChannelPolicy = ChannelPolicy.any; 43 } 44 else 45 { 46 /// Ditto 47 enum omniscientChannelPolicy = ChannelPolicy.home; 48 } 49 50 51 // ChannelState 52 /++ 53 Different states which tracked channels can be in. 54 55 This is to keep track of which channels have been queried, which are 56 currently queued for being queried, etc. It is checked by bitmask, so a 57 channel can have several channel states. 58 +/ 59 enum ChannelState : ubyte 60 { 61 unset = 1 << 0, /// Initial value, invalid state. 62 topicKnown = 1 << 1, /// Topic has been sent once, it is known. 63 queued = 1 << 2, /// Channel queued to be queried. 64 queried = 1 << 3, /// Channel has been queried. 65 } 66 67 68 // startChannelQueries 69 /++ 70 Queries channels for information about them and their users. 71 72 Checks an internal list of channels once every [dialect.defs.IRCEvent.Type.PING|PING], 73 and if one we inhabit hasn't been queried, queries it. 74 +/ 75 @(IRCEventHandler() 76 .onEvent(IRCEvent.Type.PING) 77 .fiber(true) 78 ) 79 void startChannelQueries(ChanQueriesService service) 80 { 81 import kameloso.thread : CarryingFiber, ThreadMessage, boxed; 82 import kameloso.messaging : Message, mode, raw; 83 import std.concurrency : send; 84 import std.datetime.systime : Clock; 85 import std.string : representation; 86 import core.thread : Fiber; 87 import core.time : seconds; 88 89 if (service.querying) return; // Try again next PING 90 91 string[] querylist; 92 foreach (immutable channelName, ref state; service.channelStates) 93 { 94 if (state & (ChannelState.queried | ChannelState.queued)) 95 { 96 // Either already queried or queued to be 97 continue; 98 } 99 100 state |= ChannelState.queued; 101 querylist ~= channelName; 102 } 103 104 // Continue anyway if eagerLookups 105 if (!querylist.length && !service.state.settings.eagerLookups) return; 106 107 auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis); 108 109 service.querying = true; // "Lock" 110 111 scope(exit) 112 { 113 service.queriedAtLeastOnce = true; 114 service.querying = false; // "Unlock" 115 } 116 117 static immutable secondsBetween = ChanQueriesService.secondsBetween.seconds; 118 119 chanloop: 120 foreach (immutable i, immutable channelName; querylist) 121 { 122 if (channelName !in service.channelStates) continue; 123 124 if (i > 0) 125 { 126 // Delay between runs after first since aMode probes don't delay at end 127 delay(service, secondsBetween, Yes.yield); 128 } 129 130 version(WithPrinterPlugin) 131 { 132 immutable squelchMessage = "squelch " ~ channelName; 133 } 134 135 /// Common code to send a query, await the results and unlist the fiber. 136 void queryAwaitAndUnlist(Types)(const string command, const Types types) 137 { 138 import std.conv : text; 139 140 await(service, types, No.yield); 141 scope(exit) unawait(service, types); 142 143 version(WithPrinterPlugin) 144 { 145 service.state.mainThread.send( 146 ThreadMessage.busMessage("printer", boxed(squelchMessage))); 147 } 148 149 enum properties = (Message.Property.quiet | Message.Property.background); 150 immutable message = text(command, ' ', channelName); 151 raw(service.state, message, properties); 152 153 do Fiber.yield(); // Awaiting specified types 154 while (thisFiber.payload.channel != channelName); 155 156 delay(service, secondsBetween, Yes.yield); 157 } 158 159 /// Event types that signal the end of a query response. 160 static immutable topicTypes = 161 [ 162 IRCEvent.Type.RPL_TOPIC, 163 IRCEvent.Type.RPL_NOTOPIC, 164 ]; 165 166 queryAwaitAndUnlist("TOPIC", topicTypes); 167 if (channelName !in service.channelStates) continue chanloop; 168 queryAwaitAndUnlist("WHO", IRCEvent.Type.RPL_ENDOFWHO); 169 if (channelName !in service.channelStates) continue chanloop; 170 queryAwaitAndUnlist("MODE", IRCEvent.Type.RPL_CHANNELMODEIS); 171 if (channelName !in service.channelStates) continue chanloop; 172 173 // MODE generic 174 175 foreach (immutable n, immutable modechar; service.state.server.aModes.representation) 176 { 177 import std.conv : text; 178 179 if (n > 0) 180 { 181 // Cannot await by event type; there are too many types. 182 delay(service, secondsBetween, Yes.yield); 183 if (channelName !in service.channelStates) continue chanloop; 184 } 185 186 version(WithPrinterPlugin) 187 { 188 // It's very common to get ERR_CHANOPRIVSNEEDED when querying 189 // channels for specific modes. 190 // [chanoprivsneeded] [#d] sinisalo.freenode.net: "You're not a channel operator" (#482) 191 // Ask the Printer to squelch those messages too. 192 service.state.mainThread.send( 193 ThreadMessage.busMessage("printer", boxed(squelchMessage))); 194 } 195 196 enum properties = (Message.Property.quiet | Message.Property.background); 197 immutable modeline = text('+', cast(char)modechar); 198 mode( 199 service.state, 200 channelName, 201 modeline, 202 string.init, 203 properties); 204 } 205 206 if (channelName !in service.channelStates) continue chanloop; 207 208 // Overwrite state with [ChannelState.queried]; 209 // [ChannelState.topicKnown] etc are no longer relevant. 210 service.channelStates[channelName] = ChannelState.queried; 211 } 212 213 // Stop here if we can't or are not interested in going further 214 if (!service.serverSupportsWHOIS || !service.state.settings.eagerLookups) return; 215 216 immutable now = Clock.currTime.toUnixTime; 217 bool[string] uniqueUsers; 218 219 foreach (immutable channelName, const channel; service.state.channels) 220 { 221 foreach (immutable nickname; channel.users.byKey) 222 { 223 import kameloso.constants : Timeout; 224 225 if (nickname == service.state.client.nickname) continue; 226 227 const user = nickname in service.state.users; 228 if (!user || !user.account.length || ((now - user.updated) > Timeout.whoisRetry)) 229 { 230 // No user, or no account and sufficient amount of time passed since last WHOIS 231 uniqueUsers[nickname] = true; 232 } 233 } 234 } 235 236 if (!uniqueUsers.length) return; // Early exit 237 238 uniqueUsers = uniqueUsers.rehash(); 239 240 /// Event types that signal the end of a WHOIS response. 241 static immutable whoisTypes = 242 [ 243 IRCEvent.Type.RPL_ENDOFWHOIS, 244 IRCEvent.Type.ERR_UNKNOWNCOMMAND, 245 ]; 246 247 await(service, whoisTypes, No.yield); 248 249 scope(exit) 250 { 251 unawait(service, whoisTypes); 252 253 version(WithPrinterPlugin) 254 { 255 service.state.mainThread.send( 256 ThreadMessage.busMessage("printer", boxed("unsquelch"))); 257 } 258 } 259 260 long lastQueryResults; 261 262 whoisloop: 263 foreach (immutable nickname; uniqueUsers.byKey) 264 { 265 import kameloso.common : logger; 266 import kameloso.messaging : whois; 267 import core.time : seconds; 268 269 if ((nickname !in service.state.users) || 270 (service.state.users[nickname].account.length)) 271 { 272 // User disappeared, or something else WHOISed it already. 273 continue; 274 } 275 276 // Delay between runs after first since aMode probes don't delay at end 277 delay(service, secondsBetween, Yes.yield); 278 279 while ((Clock.currTime.toUnixTime - lastQueryResults) < service.secondsBetween-1) 280 { 281 static immutable oneSecond = 1.seconds; 282 delay(service, oneSecond, Yes.yield); 283 } 284 285 version(WithPrinterPlugin) 286 { 287 service.state.mainThread.send( 288 ThreadMessage.busMessage("printer", boxed("squelch " ~ nickname))); 289 } 290 291 enum properties = (Message.Property.quiet | Message.Property.background); 292 whois(service.state, nickname, properties); 293 Fiber.yield(); // Await whois types registered above 294 295 enum maxConsecutiveUnknownCommands = 3; 296 uint consecutiveUnknownCommands; 297 298 while (true) 299 { 300 with (IRCEvent.Type) 301 switch (thisFiber.payload.type) 302 { 303 case RPL_ENDOFWHOIS: 304 consecutiveUnknownCommands = 0; 305 306 if (thisFiber.payload.target.nickname == nickname) 307 { 308 // Saw the expected response 309 lastQueryResults = Clock.currTime.toUnixTime; 310 continue whoisloop; 311 } 312 else 313 { 314 // Something else caused a WHOIS; yield until the right one comes along 315 Fiber.yield(); 316 continue; 317 } 318 319 case ERR_UNKNOWNCOMMAND: 320 if (!thisFiber.payload.aux[0].length) 321 { 322 // A different flavour of ERR_UNKNOWNCOMMAND doesn't include the command 323 // We can't say for sure it's erroring on "WHOIS" specifically 324 // If consecutive three errors, assume it's not supported 325 326 if (++consecutiveUnknownCommands >= maxConsecutiveUnknownCommands) 327 { 328 // Cannot WHOIS on this server (assume) 329 logger.error("Error: This server does not seem " ~ 330 "to support user accounts?"); 331 enum message = "Consider enabling <l>Core</>.<l>preferHostmasks</>."; 332 logger.error(message); 333 service.serverSupportsWHOIS = false; 334 return; 335 } 336 } 337 else if (thisFiber.payload.aux[0] == "WHOIS") 338 { 339 // Cannot WHOIS on this server 340 // Connect will display an error, so don't do it here again 341 service.serverSupportsWHOIS = false; 342 return; 343 } 344 else 345 { 346 // Something else issued an unknown command; yield and try again 347 consecutiveUnknownCommands = 0; 348 Fiber.yield(); 349 continue; 350 } 351 break; 352 353 default: 354 import lu.conv : Enum; 355 assert(0, "Unexpected event type triggered query Fiber: " ~ 356 "`IRCEvent.Type." ~ Enum!(IRCEvent.Type).toString(thisFiber.payload.type) ~ '`'); 357 } 358 } 359 360 assert(0, "Escaped `while (true)` loop in query Fiber delegate"); 361 } 362 } 363 364 365 // onSelfjoin 366 /++ 367 Adds a channel we join to the internal [ChanQueriesService.channels] list of 368 channel states. 369 +/ 370 @(IRCEventHandler() 371 .onEvent(IRCEvent.Type.SELFJOIN) 372 .channelPolicy(omniscientChannelPolicy) 373 ) 374 void onSelfjoin(ChanQueriesService service, const ref IRCEvent event) 375 { 376 service.channelStates[event.channel] = ChannelState.unset; 377 } 378 379 380 // onSelfpart 381 /++ 382 Removes a channel we part from the internal [ChanQueriesService.channels] 383 list of channel states. 384 +/ 385 @(IRCEventHandler() 386 .onEvent(IRCEvent.Type.SELFPART) 387 .onEvent(IRCEvent.Type.SELFKICK) 388 .channelPolicy(omniscientChannelPolicy) 389 ) 390 void onSelfpart(ChanQueriesService service, const ref IRCEvent event) 391 { 392 service.channelStates.remove(event.channel); 393 } 394 395 396 // onTopic 397 /++ 398 Registers that we have seen the topic of a channel. 399 400 We do this so we know not to query it later. Mostly cosmetic. 401 +/ 402 @(IRCEventHandler() 403 .onEvent(IRCEvent.Type.RPL_TOPIC) 404 .channelPolicy(omniscientChannelPolicy) 405 ) 406 void onTopic(ChanQueriesService service, const ref IRCEvent event) 407 { 408 service.channelStates[event.channel] |= ChannelState.topicKnown; 409 } 410 411 412 // onEndOfNames 413 /++ 414 After listing names (upon joining a channel), initiate a channel query run 415 unless one is already running. Additionally don't do it before it has been 416 done at least once, after login. 417 +/ 418 @(IRCEventHandler() 419 .onEvent(IRCEvent.Type.RPL_ENDOFNAMES) 420 .channelPolicy(omniscientChannelPolicy) 421 .fiber(true) 422 ) 423 void onEndOfNames(ChanQueriesService service) 424 { 425 if (!service.querying && service.queriedAtLeastOnce) 426 { 427 startChannelQueries(service); 428 } 429 } 430 431 432 // onMyInfo 433 /++ 434 After successful connection, start a delayed channel query on all channels. 435 +/ 436 @(IRCEventHandler() 437 .onEvent(IRCEvent.Type.RPL_MYINFO) 438 .fiber(true) 439 ) 440 void onMyInfo(ChanQueriesService service) 441 { 442 delay(service, service.timeBeforeInitialQueries, Yes.yield); 443 startChannelQueries(service); 444 } 445 446 447 // onNoSuchChannel 448 /++ 449 If we get an error that a channel doesn't exist, remove it from 450 [ChanQueriesService.channelStates|channelStates]. This stops it from being 451 queried in [startChannelQueries]. 452 +/ 453 @(IRCEventHandler() 454 .onEvent(IRCEvent.Type.ERR_NOSUCHCHANNEL) 455 ) 456 void onNoSuchChannel(ChanQueriesService service, const ref IRCEvent event) 457 { 458 service.channelStates.remove(event.channel); 459 } 460 461 462 version(OmniscientQueries) 463 { 464 enum channelPolicy = ChannelPolicy.any; 465 } 466 else 467 { 468 enum channelPolicy = ChannelPolicy.home; 469 } 470 471 472 mixin UserAwareness!channelPolicy; 473 mixin ChannelAwareness!channelPolicy; 474 mixin PluginRegistration!(ChanQueriesService, -10.priority); 475 476 public: 477 478 479 // ChanQueriesService 480 /++ 481 The Channel Queries service queries channels for information about them (in 482 terms of topic and modes) as well as its list of participants. 483 +/ 484 final class ChanQueriesService : IRCPlugin 485 { 486 private: 487 import core.time : seconds; 488 489 /++ 490 Extra seconds delay between channel mode/user queries. Not delaying may 491 cause kicks and disconnects if results are returned quickly. 492 +/ 493 enum secondsBetween = 3; 494 495 /// Seconds after welcome event before the first round of channel-querying will start. 496 static immutable timeBeforeInitialQueries = 60.seconds; 497 498 /++ 499 Short associative array of the channels the bot is in and which state(s) 500 they are in. 501 +/ 502 ubyte[string] channelStates; 503 504 /// Whether or not a channel query Fiber is running. 505 bool querying; 506 507 /// Whether or not at least one channel query has been made. 508 bool queriedAtLeastOnce; 509 510 /// Whether or not the server is known to support WHOIS queries. (Default to true.) 511 bool serverSupportsWHOIS = true; 512 513 514 // isEnabled 515 /++ 516 Override 517 [kameloso.plugins.common.core.IRCPlugin.isEnabled|IRCPlugin.isEnabled] 518 (effectively overriding [kameloso.plugins.common.core.IRCPluginImpl.isEnabled|IRCPluginImpl.isEnabled]) 519 and inject a server check, so this service does nothing on Twitch servers. 520 521 Returns: 522 `true` if this service should react to events; `false` if not. 523 +/ 524 version(TwitchSupport) 525 override public bool isEnabled() const @property pure nothrow @nogc 526 { 527 return (state.server.daemon != IRCServer.Daemon.twitch); 528 } 529 530 mixin IRCPluginImpl; 531 }