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 }