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 }