|
1 | | -""" |
2 | | -help.py - Sopel Help Plugin |
3 | | -Copyright 2008, Sean B. Palmer, inamidst.com |
4 | | -Copyright © 2013, Elad Alfassa, <[email protected]> |
5 | | -Copyright © 2018, Adam Erdman, pandorah.org |
6 | | -Copyright © 2019, Tomasz Kurcz, github.com/uint |
7 | | -Copyright © 2019, dgw, technobabbl.es |
8 | | -Licensed under the Eiffel Forum License 2. |
| 1 | +"""help.py - Obsolete Sopel Help Plugin |
9 | 2 |
|
10 | | -https://sopel.chat |
| 3 | +Install ``sopel-help`` with ``pip install sopel-help`` to get the official |
| 4 | +help plugin for Sopel. |
11 | 5 | """ |
12 | 6 | from __future__ import annotations |
13 | 7 |
|
14 | | -import collections |
15 | 8 | import logging |
16 | | -import re |
17 | | -import socket |
18 | | -import textwrap |
19 | | - |
20 | | -import requests |
21 | 9 |
|
22 | | -from sopel import plugin, tools |
23 | | -from sopel.config import types |
24 | 10 |
|
25 | | - |
26 | | -SETTING_CACHE_NAMESPACE = 'help-setting-cache' # Set top-level memory key name |
27 | 11 | LOGGER = logging.getLogger(__name__) |
28 | 12 |
|
29 | | -# Settings that should require the help listing to be regenerated, or |
30 | | -# re-POSTed to paste, if they are changed during runtime. |
31 | | -# Keys are plugin names, and values are lists of setting names |
32 | | -# specific to that plugin. |
33 | | -TRACKED_SETTINGS = { |
34 | | - 'help': [ |
35 | | - 'output', |
36 | | - 'show_server_host', |
37 | | - ] |
38 | | -} |
39 | | - |
40 | | - |
41 | | -class PostingException(Exception): |
42 | | - """Custom exception type for errors posting help to the chosen pastebin.""" |
43 | | - pass |
44 | | - |
45 | | - |
46 | | -# Pastebin handlers |
47 | | - |
48 | | - |
49 | | -def _requests_post_catch_errors(*args, **kwargs): |
50 | | - try: |
51 | | - response = requests.post(*args, **kwargs) |
52 | | - response.raise_for_status() |
53 | | - except ( |
54 | | - requests.exceptions.Timeout, |
55 | | - requests.exceptions.TooManyRedirects, |
56 | | - requests.exceptions.RequestException, |
57 | | - requests.exceptions.HTTPError |
58 | | - ): |
59 | | - # We re-raise all expected exception types to a generic "posting error" |
60 | | - # that's easy for callers to expect, and then we pass the original |
61 | | - # exception through to provide some debugging info |
62 | | - LOGGER.exception('Error during POST request') |
63 | | - raise PostingException('Could not communicate with remote service') |
64 | | - |
65 | | - # remaining handling (e.g. errors inside the response) is left to the caller |
66 | | - return response |
67 | | - |
68 | | - |
69 | | -def post_to_clbin(msg): |
70 | | - try: |
71 | | - result = _requests_post_catch_errors('https://clbin.com/', data={'clbin': msg}) |
72 | | - except PostingException: |
73 | | - raise |
74 | | - |
75 | | - result = result.text |
76 | | - if re.match(r'https?://clbin\.com/', result): |
77 | | - # find/replace just in case the site tries to be sneaky and save on SSL overhead, |
78 | | - # though it will probably send us an HTTPS link without any tricks. |
79 | | - return result.replace('http://', 'https://', 1) |
80 | | - else: |
81 | | - LOGGER.error("Invalid result %s", result) |
82 | | - raise PostingException('clbin result did not contain expected URL base.') |
83 | | - |
84 | | - |
85 | | -def post_to_0x0(msg): |
86 | | - try: |
87 | | - result = _requests_post_catch_errors('https://0x0.st', files={'file': msg}) |
88 | | - except PostingException: |
89 | | - raise |
90 | | - |
91 | | - result = result.text |
92 | | - if re.match(r'https?://0x0\.st/', result): |
93 | | - # find/replace just in case the site tries to be sneaky and save on SSL overhead, |
94 | | - # though it will probably send us an HTTPS link without any tricks. |
95 | | - return result.replace('http://', 'https://', 1) |
96 | | - else: |
97 | | - LOGGER.error('Invalid result %s', result) |
98 | | - raise PostingException('0x0.st result did not contain expected URL base.') |
99 | | - |
100 | | - |
101 | | -def post_to_hastebin(msg): |
102 | | - try: |
103 | | - result = _requests_post_catch_errors('https://hastebin.com/documents', data=msg) |
104 | | - except PostingException: |
105 | | - raise |
106 | | - |
107 | | - try: |
108 | | - result = result.json() |
109 | | - except ValueError: |
110 | | - LOGGER.error("Invalid Hastebin response %s", result) |
111 | | - raise PostingException('Could not parse response from Hastebin!') |
112 | | - |
113 | | - if 'key' not in result: |
114 | | - LOGGER.error("Invalid result %s", result) |
115 | | - raise PostingException('Hastebin result did not contain expected URL base.') |
116 | | - return "https://hastebin.com/" + result['key'] |
117 | | - |
118 | | - |
119 | | -def post_to_termbin(msg): |
120 | | - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
121 | | - sock.settimeout(10) # the bot may NOT wait forever for a response; that would be bad |
122 | | - try: |
123 | | - sock.connect(('termbin.com', 9999)) |
124 | | - sock.sendall(msg) |
125 | | - sock.shutdown(socket.SHUT_WR) |
126 | | - response = "" |
127 | | - while 1: |
128 | | - data = sock.recv(1024) |
129 | | - if data == "": |
130 | | - break |
131 | | - response += data |
132 | | - sock.close() |
133 | | - except socket.error: |
134 | | - LOGGER.exception('Error during communication with termbin') |
135 | | - raise PostingException('Error uploading to termbin') |
136 | | - |
137 | | - # find/replace just in case the site tries to be sneaky and save on SSL overhead, |
138 | | - # though it will probably send us an HTTPS link without any tricks. |
139 | | - return response.strip('\x00\n').replace('http://', 'https://', 1) |
140 | | - |
141 | | - |
142 | | -def post_to_ubuntu(msg): |
143 | | - data = { |
144 | | - 'poster': 'sopel', |
145 | | - 'syntax': 'text', |
146 | | - 'expiration': '', |
147 | | - 'content': msg, |
148 | | - } |
149 | | - result = _requests_post_catch_errors( |
150 | | - 'https://pastebin.ubuntu.com/', data=data) |
151 | | - |
152 | | - if not re.match(r'https://pastebin\.ubuntu\.com/p/[^/]+/', result.url): |
153 | | - LOGGER.error("Invalid Ubuntu pastebin response url %s", result.url) |
154 | | - raise PostingException( |
155 | | - 'Invalid response from Ubuntu pastebin: %s' % result.url) |
156 | | - |
157 | | - return result.url |
158 | | - |
159 | | - |
160 | | -PASTEBIN_PROVIDERS = { |
161 | | - 'clbin': post_to_clbin, |
162 | | - '0x0': post_to_0x0, |
163 | | - 'hastebin': post_to_hastebin, |
164 | | - 'termbin': post_to_termbin, |
165 | | - 'ubuntu': post_to_ubuntu, |
166 | | -} |
167 | | -REPLY_METHODS = [ |
168 | | - 'channel', |
169 | | - 'query', |
170 | | - 'notice', |
171 | | -] |
172 | | - |
173 | | - |
174 | | -class HelpSection(types.StaticSection): |
175 | | - """Configuration section for this plugin.""" |
176 | | - output = types.ChoiceAttribute('output', |
177 | | - list(PASTEBIN_PROVIDERS), |
178 | | - default='clbin') |
179 | | - """The pastebin provider to use for help output.""" |
180 | | - reply_method = types.ChoiceAttribute('reply_method', |
181 | | - REPLY_METHODS, |
182 | | - default='channel') |
183 | | - """Where/how to reply to help commands (public/private).""" |
184 | | - show_server_host = types.BooleanAttribute('show_server_host', |
185 | | - default=True) |
186 | | - """Show the IRC server's hostname/IP in the first line of the help listing?""" |
187 | | - |
188 | | - |
189 | | -def configure(config): |
190 | | - """ |
191 | | - | name | example | purpose | |
192 | | - | ---- | ------- | ------- | |
193 | | - | output | clbin | The pastebin provider to use for help output | |
194 | | - | reply\\_method | channel | How/where help output should be sent | |
195 | | - | show\\_server\\_host | True | Whether to show the IRC server's hostname/IP at the top of command listings | |
196 | | - """ |
197 | | - config.define_section('help', HelpSection) |
198 | | - provider_list = ', '.join(PASTEBIN_PROVIDERS) |
199 | | - reply_method_list = ', '.join(REPLY_METHODS) |
200 | | - config.help.configure_setting( |
201 | | - 'output', |
202 | | - 'Pick a pastebin provider: {}: '.format(provider_list) |
203 | | - ) |
204 | | - config.help.configure_setting( |
205 | | - 'reply_method', |
206 | | - 'How/where should help command replies be sent: {}? '.format(reply_method_list) |
207 | | - ) |
208 | | - config.help.configure_setting( |
209 | | - 'show_server_host', |
210 | | - 'Should the help command show the IRC server\'s hostname/IP in the listing?' |
211 | | - ) |
212 | | - |
213 | 13 |
|
214 | 14 | def setup(bot): |
215 | | - bot.config.define_section('help', HelpSection) |
216 | | - |
217 | | - # Initialize memory |
218 | | - if SETTING_CACHE_NAMESPACE not in bot.memory: |
219 | | - bot.memory[SETTING_CACHE_NAMESPACE] = tools.SopelMemory() |
220 | | - |
221 | | - # Initialize settings cache |
222 | | - for section in TRACKED_SETTINGS: |
223 | | - if section not in bot.memory[SETTING_CACHE_NAMESPACE]: |
224 | | - bot.memory[SETTING_CACHE_NAMESPACE][section] = tools.SopelMemory() |
225 | | - |
226 | | - update_cache(bot) # Populate cache |
227 | | - |
228 | | - bot.config.define_section('help', HelpSection) |
229 | | - |
230 | | - |
231 | | -def update_cache(bot): |
232 | | - for section, setting_names_list in TRACKED_SETTINGS.items(): |
233 | | - for setting_name in setting_names_list: |
234 | | - bot.memory[SETTING_CACHE_NAMESPACE][section][setting_name] = getattr(getattr(bot.config, section), setting_name) |
235 | | - |
236 | | - |
237 | | -def is_cache_valid(bot): |
238 | | - for section, setting_names_list in TRACKED_SETTINGS.items(): |
239 | | - for setting_name in setting_names_list: |
240 | | - if bot.memory[SETTING_CACHE_NAMESPACE][section][setting_name] != getattr(getattr(bot.config, section), setting_name): |
241 | | - return False |
242 | | - return True |
243 | | - |
244 | | - |
245 | | -@plugin.rule('$nick' r'(?i)(help|doc) +([A-Za-z]+)(?:\?+)?$') |
246 | | -@plugin.example('.help tell') |
247 | | -@plugin.command('help', 'commands') |
248 | | -@plugin.priority('low') |
249 | | -def help(bot, trigger): |
250 | | - """Shows a command's documentation, and an example if available. With no arguments, lists all commands.""" |
251 | | - if bot.config.help.reply_method == 'query': |
252 | | - def respond(text): |
253 | | - bot.say(text, trigger.nick) |
254 | | - elif bot.config.help.reply_method == 'notice': |
255 | | - def respond(text): |
256 | | - bot.notice(text, trigger.nick) |
257 | | - else: |
258 | | - def respond(text): |
259 | | - bot.say(text, trigger.sender) |
260 | | - |
261 | | - if trigger.group(2): |
262 | | - name = trigger.group(2) |
263 | | - name = name.lower() |
264 | | - |
265 | | - # number of lines of help to show |
266 | | - threshold = 3 |
267 | | - |
268 | | - if name in bot.doc: |
269 | | - # count lines we're going to send |
270 | | - # lines in command docstring, plus one line for example(s) if present (they're sent all on one line) |
271 | | - if len(bot.doc[name][0]) + int(bool(bot.doc[name][1])) > threshold: |
272 | | - if trigger.nick != trigger.sender: # don't say that if asked in private |
273 | | - bot.reply('The documentation for this command is too long; ' |
274 | | - 'I\'m sending it to you in a private message.') |
275 | | - |
276 | | - def msgfun(message): |
277 | | - bot.say(message, trigger.nick) |
278 | | - else: |
279 | | - msgfun = respond |
280 | | - |
281 | | - for line in bot.doc[name][0]: |
282 | | - msgfun(line) |
283 | | - if bot.doc[name][1]: |
284 | | - # Build a nice, grammatically-correct list of examples |
285 | | - examples = ', '.join(bot.doc[name][1][:-2] + [' or '.join(bot.doc[name][1][-2:])]) |
286 | | - msgfun('e.g. ' + examples) |
287 | | - else: |
288 | | - # This'll probably catch most cases, without having to spend the time |
289 | | - # actually creating the list first. Maybe worth storing the link and a |
290 | | - # heuristic in the DB, too, so it persists across restarts. Would need a |
291 | | - # command to regenerate, too... |
292 | | - if ( |
293 | | - 'command-list' in bot.memory and |
294 | | - bot.memory['command-list'][0] == len(bot.command_groups) and |
295 | | - is_cache_valid(bot) |
296 | | - ): |
297 | | - url = bot.memory['command-list'][1] |
298 | | - else: |
299 | | - respond("Hang on, I'm creating a list.") |
300 | | - msgs = [] |
301 | | - |
302 | | - name_length = max(6, max(len(k) for k in bot.command_groups.keys())) |
303 | | - for category, cmds in collections.OrderedDict(sorted(bot.command_groups.items())).items(): |
304 | | - category = category.upper().ljust(name_length) |
305 | | - cmds = set(cmds) # remove duplicates |
306 | | - cmds = ' '.join(cmds) |
307 | | - msg = category + ' ' + cmds |
308 | | - indent = ' ' * (name_length + 2) |
309 | | - # Honestly not sure why this is a list here |
310 | | - msgs.append('\n'.join(textwrap.wrap(msg, subsequent_indent=indent))) |
311 | | - |
312 | | - url = create_list(bot, '\n\n'.join(msgs)) |
313 | | - if not url: |
314 | | - return |
315 | | - bot.memory['command-list'] = (len(bot.command_groups), url) |
316 | | - update_cache(bot) |
317 | | - respond("I've posted a list of my commands at {0} - You can see " |
318 | | - "more info about any of these commands by doing {1}help " |
319 | | - "<command> (e.g. {1}help time)" |
320 | | - .format(url, bot.config.core.help_prefix)) |
321 | | - |
322 | | - |
323 | | -def create_list(bot, msg): |
324 | | - """Creates & uploads the command list. |
325 | | -
|
326 | | - Returns the URL from the chosen pastebin provider. |
327 | | - """ |
328 | | - msg = 'Command listing for {}{}\n\n{}'.format( |
329 | | - bot.nick, |
330 | | - ('@' + bot.config.core.host) if bot.config.help.show_server_host else '', |
331 | | - msg) |
332 | | - |
333 | | - try: |
334 | | - result = PASTEBIN_PROVIDERS[bot.config.help.output](msg) |
335 | | - except PostingException: |
336 | | - bot.say("Sorry! Something went wrong.") |
337 | | - LOGGER.exception("Error posting commands") |
338 | | - return |
339 | | - return result |
340 | | - |
341 | | - |
342 | | -@plugin.rule('$nick' r'(?i)help(?:[?!]+)?$') |
343 | | -@plugin.priority('low') |
344 | | -def help2(bot, trigger): |
345 | | - response = ( |
346 | | - "Hi, I'm a bot. Say {1}commands to me in private for a list " |
347 | | - "of my commands, or see https://sopel.chat for more " |
348 | | - "general details. My owner is {0}." |
349 | | - .format(bot.config.core.owner, bot.config.core.help_prefix)) |
350 | | - bot.reply(response) |
| 15 | + LOGGER.warning( |
| 16 | + 'Sopel's built-in help plugin is obsolete. ' |
| 17 | + 'Install sopel-help as the official help plugin for Sopel.\n' |
| 18 | + 'You can install sopel-help with "pip install sopel-help".') |
0 commit comments