1 /++
2     The Bash plugin looks up [bash.org](http://bash.org) quotes and reports them
3     to the appropriate nickname or channel.
4 
5     See_Also:
6         https://github.com/zorael/kameloso/wiki/Current-plugins#bash,
7         [kameloso.plugins.common.core],
8         [kameloso.plugins.common.misc]
9 
10     Copyright: [JR](https://github.com/zorael)
11     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
12 
13     Authors:
14         [JR](https://github.com/zorael)
15  +/
16 module kameloso.plugins.bash;
17 
18 version(WithBashPlugin):
19 
20 private:
21 
22 import kameloso.plugins;
23 import kameloso.plugins.common.core;
24 import kameloso.plugins.common.awareness : MinimalAuthentication;
25 import kameloso.messaging;
26 import dialect.defs;
27 
28 mixin MinimalAuthentication;
29 mixin PluginRegistration!BashPlugin;
30 
31 
32 // BashSettings
33 /++
34     All Bash plugin settings gathered.
35  +/
36 @Settings struct BashSettings
37 {
38     /++
39         Whether or not the Bash plugin should react to events at all.
40      +/
41     @Enabler bool enabled = true;
42 }
43 
44 
45 // onCommandBash
46 /++
47     Fetch a random or specified `bash.org` quote.
48 
49     Defers to the [worker] subthread.
50  +/
51 @(IRCEventHandler()
52     .onEvent(IRCEvent.Type.CHAN)
53     .onEvent(IRCEvent.Type.QUERY)
54     .permissionsRequired(Permissions.anyone)
55     .channelPolicy(ChannelPolicy.home)
56     .addCommand(
57         IRCEventHandler.Command()
58             .word("bash")
59             .policy(PrefixPolicy.prefixed)
60             .description("Fetch a random or specified bash.org quote.")
61             .addSyntax("$command [optional bash quote number]")
62     )
63 )
64 void onCommandBash(BashPlugin plugin, const /*ref*/ IRCEvent event)
65 {
66     import kameloso.thread : ThreadMessage;
67     import std.concurrency : prioritySend, spawn;
68 
69     plugin.state.mainThread.prioritySend(ThreadMessage.shortenReceiveTimeout());
70 
71     // Defer all work to the worker thread
72     cast(void)spawn(&worker, cast(shared)plugin.state, event);
73 }
74 
75 
76 // worker
77 /++
78     Looks up a `bash.org` quote and reports it to the appropriate nickname or channel.
79 
80     Supposed to be run in its own, short-lived thread.
81 
82     Params:
83         sState = A `shared` [kameloso.plugins.common.core.IRCPluginState|IRCPluginState]
84             containing necessary information to pass messages to send messages
85             to the main thread, to send text to the server or display text on
86             the screen.
87         event = The [dialect.defs.IRCEvent|IRCEvent] in flight.
88  +/
89 void worker(
90     shared IRCPluginState sState,
91     const /*ref*/ IRCEvent event)
92 {
93     import kameloso.constants : KamelosoInfo, Timeout;
94     import lu.string : beginsWith;
95     import arsd.dom : Document, htmlEntitiesDecode;
96     import arsd.http2 : HttpClient, Uri;
97     import std.algorithm.iteration : splitter;
98     import std.array : replace;
99     import std.exception : assumeUnique;
100     import std.format : format;
101     import core.time : seconds;
102     static import kameloso.common;
103 
104     auto state = cast()sState;
105 
106     version(Posix)
107     {
108         import kameloso.thread : setThreadName;
109         setThreadName("bashquotes");
110     }
111 
112     void sendCouldNotFetchQuote(const string url, const string error)
113     {
114         enum pattern = "Bash plugin could not fetch <l>bash.org</> quote at <l>%s</>: <t>%s";
115         askToWarn(state, pattern.format(url, error));
116 
117         enum channelPattern = "Could not fetch <b>bash.org<b> quote: %s";
118         immutable channelMessage = channelPattern.format(error);
119         privmsg(state, event.channel, event.sender.nickname, channelMessage);
120     }
121 
122     void sendNoResponseseReceived()
123     {
124         enum message = "No reponse received from <b>bash.org<b>; is it down?";
125         privmsg(state, event.channel, event.sender.nickname, message);
126     }
127 
128     void reportLayoutError()
129     {
130         askToError(state, "Failed to parse <l>bash.org</> page; unexpected layout.");
131     }
132 
133     // Set the global settings so messaging functions don't segfault us
134     kameloso.common.settings = &state.settings;
135 
136     immutable quoteID = event.content.beginsWith('#') ?
137         event.content[1..$] :
138         event.content;
139     immutable url = quoteID.length ?
140         ("http://bash.org/?" ~ quoteID) :
141         "http://bash.org/?random";
142 
143     // No need to keep a static HttpClient since this will be in a new thread every time
144     auto client = new HttpClient;
145     client.useHttp11 = true;
146     client.keepAlive = false;
147     client.acceptGzip = false;
148     client.defaultTimeout = Timeout.httpGET.seconds;  // FIXME
149     client.userAgent = "kameloso/" ~ cast(string)KamelosoInfo.version_;
150     immutable caBundleFile = state.connSettings.caBundleFile;
151     if (caBundleFile.length) client.setClientCertificate(caBundleFile, caBundleFile);
152 
153     try
154     {
155         auto req = client.request(Uri(url));
156         const res = req.waitForCompletion();
157 
158         if (res.code == 2)
159         {
160             return sendCouldNotFetchQuote(url, res.codeText);
161         }
162 
163         if (!res.responseText.length)
164         {
165             return sendNoResponseseReceived();
166         }
167 
168         auto doc = new Document;
169         doc.parseGarbage("");  // Work around missing null check, causing segfaults on empty pages
170         doc.parseGarbage(res.responseText);
171 
172         auto numBlock = doc.getElementsByClassName("quote");
173 
174         if (!numBlock.length)
175         {
176             return sendCouldNotFetchQuote(url, "No such quote found.");
177         }
178 
179         auto p = numBlock[0].getElementsByTagName("p");
180         if (!p.length) return reportLayoutError();  // Page changed layout
181 
182         auto b = p[0].getElementsByTagName("b");
183         if (!b.length || (b[0].toString.length < 5)) return reportLayoutError();  // Page changed layout
184 
185         auto qt = doc.getElementsByClassName("qt");
186         if (!qt.length) return reportLayoutError();  // Page changed layout
187 
188         auto range = qt[0]
189             .toString
190             .replace(`<p class="qt">`, string.init)
191             .replace(`</p>`, string.init)
192             .replace(`<br />`, string.init)
193             .htmlEntitiesDecode
194             .splitter('\n');
195 
196         immutable message = "[<b>bash.org<b>] #" ~ b[0].toString[4..$-4];
197         privmsg(state, event.channel, event.sender.nickname, message);
198 
199         foreach (const line; range)
200         {
201             privmsg(state, event.channel, event.sender.nickname, line);
202         }
203     }
204     catch (Exception e)
205     {
206         /*return*/ sendCouldNotFetchQuote(url, e.msg);
207         version(PrintStacktraces) askToTrace(state, e.toString);
208     }
209 }
210 
211 
212 public:
213 
214 
215 // BashPlugin
216 /++
217     The Bash plugin looks up `bash.org` quotes and reports them to the
218     appropriate nickname or channel.
219  +/
220 final class BashPlugin : IRCPlugin
221 {
222     /++
223         All Bash plugin settings gathered.
224      +/
225     BashSettings bashSettings;
226 
227     mixin IRCPluginImpl;
228 }