Repository: sopel-irc/sopel

Issue:
help: using a pastebin should be optional
Not all environments are public environments where it is appropriate to post commands  publicly. It would be extremely helpful if this behavior (print to channel or gist) was configurable. 


Discussion:
Also additional options, like outputting to a text or HTML file on the local filesystem instead, would be useful. Maybe I should just bite the bullet and implement all that.
As an aside, this issue might be obsolete. See #1254.
Switched to a different pastebin provider for the time being. This will stick around as a feature request.
I feel like this has been fixed by #1451 
@Exirel Nope. We still want to add locally-hosted HTML or something that doesn't touch a pastebin at all. Choosing a pastebin was a separate issue, #1416, as confusing as the similar issue titles might be.
Punting this. The implementation should add an option without changing the existing behavior if not configured, so no rush to get this into the next major version.
The more I think about that one, the more I'd like to create a separated package for the help plugin, and to have a dedicated `[help]` config section. I've plenty of ideas for that kind of plugins, and I think it would help (...) a lot to be able to update such plugin whenever a paste service become unavailable, or when they change their interface, and so on.

For instance, I'd love to allow third-party devs to implement their own help publishing backend, so whenever someone have a new use-case that is solely for them and their group, they could do it more easily.

Also, I'd use it as an excuse to push Sopel's internals to always be better - as one may say "eat your own food".
Aren't we already pulling a bunch more stuff out of core for 8.0? `help` can be part of that.
Yes, but I'd love to start with `sopel-help` for Sopel 7.1: it's easy to do, and since almost everyone use it from time to time, if anything goes wrong it won't be silent.

Also, "help" is another name for "documentation". 😛 
Feel free to play around in the new `sopel-help` repo, @Exirel. Whatever you come up with in there, the only real question is whether it takes effect in 7.1 or 8.0.
We'll consider this fixed by [sopel-irc/sopel-help](https://github.com/sopel-irc/sopel-help), which includes several pastebins, adds local HTML output (to be served by the user's chosen external HTTPd), _and_ resurrected direct-to-IRC output as well.

Since the new plugin only supports Python 3.5+, I'll mark this for Sopel 8.0 (which will drop support for older Python versions) and note it prominently in the release notes for that version, then close the issue.

Resolving patch:
diff --git a/sopel/modules/help.py b/sopel/modules/help.py
--- a/sopel/modules/help.py
+++ b/sopel/modules/help.py
@@ -1,350 +1,18 @@
-"""
-help.py - Sopel Help Plugin
-Copyright 2008, Sean B. Palmer, inamidst.com
-Copyright © 2013, Elad Alfassa, <elad@fedoraproject.org>
-Copyright © 2018, Adam Erdman, pandorah.org
-Copyright © 2019, Tomasz Kurcz, github.com/uint
-Copyright © 2019, dgw, technobabbl.es
-Licensed under the Eiffel Forum License 2.
+"""help.py - Obsolete Sopel Help Plugin
 
-https://sopel.chat
+Install ``sopel-help`` with ``pip install sopel-help`` to get the official
+help plugin for Sopel.
 """
 from __future__ import annotations
 
-import collections
 import logging
-import re
-import socket
-import textwrap
-
-import requests
 
-from sopel import plugin, tools
-from sopel.config import types
 
-
-SETTING_CACHE_NAMESPACE = 'help-setting-cache'  # Set top-level memory key name
 LOGGER = logging.getLogger(__name__)
 
-# Settings that should require the help listing to be regenerated, or
-# re-POSTed to paste, if they are changed during runtime.
-# Keys are plugin names, and values are lists of setting names
-# specific to that plugin.
-TRACKED_SETTINGS = {
-    'help': [
-        'output',
-        'show_server_host',
-    ]
-}
-
-
-class PostingException(Exception):
-    """Custom exception type for errors posting help to the chosen pastebin."""
-    pass
-
-
-# Pastebin handlers
-
-
-def _requests_post_catch_errors(*args, **kwargs):
-    try:
-        response = requests.post(*args, **kwargs)
-        response.raise_for_status()
-    except (
-            requests.exceptions.Timeout,
-            requests.exceptions.TooManyRedirects,
-            requests.exceptions.RequestException,
-            requests.exceptions.HTTPError
-    ):
-        # We re-raise all expected exception types to a generic "posting error"
-        # that's easy for callers to expect, and then we pass the original
-        # exception through to provide some debugging info
-        LOGGER.exception('Error during POST request')
-        raise PostingException('Could not communicate with remote service')
-
-    # remaining handling (e.g. errors inside the response) is left to the caller
-    return response
-
-
-def post_to_clbin(msg):
-    try:
-        result = _requests_post_catch_errors('https://clbin.com/', data={'clbin': msg})
-    except PostingException:
-        raise
-
-    result = result.text
-    if re.match(r'https?://clbin\.com/', result):
-        # find/replace just in case the site tries to be sneaky and save on SSL overhead,
-        # though it will probably send us an HTTPS link without any tricks.
-        return result.replace('http://', 'https://', 1)
-    else:
-        LOGGER.error("Invalid result %s", result)
-        raise PostingException('clbin result did not contain expected URL base.')
-
-
-def post_to_0x0(msg):
-    try:
-        result = _requests_post_catch_errors('https://0x0.st', files={'file': msg})
-    except PostingException:
-        raise
-
-    result = result.text
-    if re.match(r'https?://0x0\.st/', result):
-        # find/replace just in case the site tries to be sneaky and save on SSL overhead,
-        # though it will probably send us an HTTPS link without any tricks.
-        return result.replace('http://', 'https://', 1)
-    else:
-        LOGGER.error('Invalid result %s', result)
-        raise PostingException('0x0.st result did not contain expected URL base.')
-
-
-def post_to_hastebin(msg):
-    try:
-        result = _requests_post_catch_errors('https://hastebin.com/documents', data=msg)
-    except PostingException:
-        raise
-
-    try:
-        result = result.json()
-    except ValueError:
-        LOGGER.error("Invalid Hastebin response %s", result)
-        raise PostingException('Could not parse response from Hastebin!')
-
-    if 'key' not in result:
-        LOGGER.error("Invalid result %s", result)
-        raise PostingException('Hastebin result did not contain expected URL base.')
-    return "https://hastebin.com/" + result['key']
-
-
-def post_to_termbin(msg):
-    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-    sock.settimeout(10)  # the bot may NOT wait forever for a response; that would be bad
-    try:
-        sock.connect(('termbin.com', 9999))
-        sock.sendall(msg)
-        sock.shutdown(socket.SHUT_WR)
-        response = ""
-        while 1:
-            data = sock.recv(1024)
-            if data == "":
-                break
-            response += data
-        sock.close()
-    except socket.error:
-        LOGGER.exception('Error during communication with termbin')
-        raise PostingException('Error uploading to termbin')
-
-    # find/replace just in case the site tries to be sneaky and save on SSL overhead,
-    # though it will probably send us an HTTPS link without any tricks.
-    return response.strip('\x00\n').replace('http://', 'https://', 1)
-
-
-def post_to_ubuntu(msg):
-    data = {
-        'poster': 'sopel',
-        'syntax': 'text',
-        'expiration': '',
-        'content': msg,
-    }
-    result = _requests_post_catch_errors(
-        'https://pastebin.ubuntu.com/', data=data)
-
-    if not re.match(r'https://pastebin\.ubuntu\.com/p/[^/]+/', result.url):
-        LOGGER.error("Invalid Ubuntu pastebin response url %s", result.url)
-        raise PostingException(
-            'Invalid response from Ubuntu pastebin: %s' % result.url)
-
-    return result.url
-
-
-PASTEBIN_PROVIDERS = {
-    'clbin': post_to_clbin,
-    '0x0': post_to_0x0,
-    'hastebin': post_to_hastebin,
-    'termbin': post_to_termbin,
-    'ubuntu': post_to_ubuntu,
-}
-REPLY_METHODS = [
-    'channel',
-    'query',
-    'notice',
-]
-
-
-class HelpSection(types.StaticSection):
-    """Configuration section for this plugin."""
-    output = types.ChoiceAttribute('output',
-                                   list(PASTEBIN_PROVIDERS),
-                                   default='clbin')
-    """The pastebin provider to use for help output."""
-    reply_method = types.ChoiceAttribute('reply_method',
-                                         REPLY_METHODS,
-                                         default='channel')
-    """Where/how to reply to help commands (public/private)."""
-    show_server_host = types.BooleanAttribute('show_server_host',
-                                              default=True)
-    """Show the IRC server's hostname/IP in the first line of the help listing?"""
-
-
-def configure(config):
-    """
-    | name | example | purpose |
-    | ---- | ------- | ------- |
-    | output | clbin | The pastebin provider to use for help output |
-    | reply\\_method | channel | How/where help output should be sent |
-    | show\\_server\\_host | True | Whether to show the IRC server's hostname/IP at the top of command listings |
-    """
-    config.define_section('help', HelpSection)
-    provider_list = ', '.join(PASTEBIN_PROVIDERS)
-    reply_method_list = ', '.join(REPLY_METHODS)
-    config.help.configure_setting(
-        'output',
-        'Pick a pastebin provider: {}: '.format(provider_list)
-    )
-    config.help.configure_setting(
-        'reply_method',
-        'How/where should help command replies be sent: {}? '.format(reply_method_list)
-    )
-    config.help.configure_setting(
-        'show_server_host',
-        'Should the help command show the IRC server\'s hostname/IP in the listing?'
-    )
-
 
 def setup(bot):
-    bot.config.define_section('help', HelpSection)
-
-    # Initialize memory
-    if SETTING_CACHE_NAMESPACE not in bot.memory:
-        bot.memory[SETTING_CACHE_NAMESPACE] = tools.SopelMemory()
-
-    # Initialize settings cache
-    for section in TRACKED_SETTINGS:
-        if section not in bot.memory[SETTING_CACHE_NAMESPACE]:
-            bot.memory[SETTING_CACHE_NAMESPACE][section] = tools.SopelMemory()
-
-    update_cache(bot)  # Populate cache
-
-    bot.config.define_section('help', HelpSection)
-
-
-def update_cache(bot):
-    for section, setting_names_list in TRACKED_SETTINGS.items():
-        for setting_name in setting_names_list:
-            bot.memory[SETTING_CACHE_NAMESPACE][section][setting_name] = getattr(getattr(bot.config, section), setting_name)
-
-
-def is_cache_valid(bot):
-    for section, setting_names_list in TRACKED_SETTINGS.items():
-        for setting_name in setting_names_list:
-            if bot.memory[SETTING_CACHE_NAMESPACE][section][setting_name] != getattr(getattr(bot.config, section), setting_name):
-                return False
-    return True
-
-
-@plugin.rule('$nick' r'(?i)(help|doc) +([A-Za-z]+)(?:\?+)?$')
-@plugin.example('.help tell')
-@plugin.command('help', 'commands')
-@plugin.priority('low')
-def help(bot, trigger):
-    """Shows a command's documentation, and an example if available. With no arguments, lists all commands."""
-    if bot.config.help.reply_method == 'query':
-        def respond(text):
-            bot.say(text, trigger.nick)
-    elif bot.config.help.reply_method == 'notice':
-        def respond(text):
-            bot.notice(text, trigger.nick)
-    else:
-        def respond(text):
-            bot.say(text, trigger.sender)
-
-    if trigger.group(2):
-        name = trigger.group(2)
-        name = name.lower()
-
-        # number of lines of help to show
-        threshold = 3
-
-        if name in bot.doc:
-            # count lines we're going to send
-            # lines in command docstring, plus one line for example(s) if present (they're sent all on one line)
-            if len(bot.doc[name][0]) + int(bool(bot.doc[name][1])) > threshold:
-                if trigger.nick != trigger.sender:  # don't say that if asked in private
-                    bot.reply('The documentation for this command is too long; '
-                              'I\'m sending it to you in a private message.')
-
-                def msgfun(message):
-                    bot.say(message, trigger.nick)
-            else:
-                msgfun = respond
-
-            for line in bot.doc[name][0]:
-                msgfun(line)
-            if bot.doc[name][1]:
-                # Build a nice, grammatically-correct list of examples
-                examples = ', '.join(bot.doc[name][1][:-2] + [' or '.join(bot.doc[name][1][-2:])])
-                msgfun('e.g. ' + examples)
-    else:
-        # This'll probably catch most cases, without having to spend the time
-        # actually creating the list first. Maybe worth storing the link and a
-        # heuristic in the DB, too, so it persists across restarts. Would need a
-        # command to regenerate, too...
-        if (
-            'command-list' in bot.memory and
-            bot.memory['command-list'][0] == len(bot.command_groups) and
-            is_cache_valid(bot)
-        ):
-            url = bot.memory['command-list'][1]
-        else:
-            respond("Hang on, I'm creating a list.")
-            msgs = []
-
-            name_length = max(6, max(len(k) for k in bot.command_groups.keys()))
-            for category, cmds in collections.OrderedDict(sorted(bot.command_groups.items())).items():
-                category = category.upper().ljust(name_length)
-                cmds = set(cmds)  # remove duplicates
-                cmds = '  '.join(cmds)
-                msg = category + '  ' + cmds
-                indent = ' ' * (name_length + 2)
-                # Honestly not sure why this is a list here
-                msgs.append('\n'.join(textwrap.wrap(msg, subsequent_indent=indent)))
-
-            url = create_list(bot, '\n\n'.join(msgs))
-            if not url:
-                return
-            bot.memory['command-list'] = (len(bot.command_groups), url)
-            update_cache(bot)
-        respond("I've posted a list of my commands at {0} - You can see "
-                "more info about any of these commands by doing {1}help "
-                "<command> (e.g. {1}help time)"
-                .format(url, bot.config.core.help_prefix))
-
-
-def create_list(bot, msg):
-    """Creates & uploads the command list.
-
-    Returns the URL from the chosen pastebin provider.
-    """
-    msg = 'Command listing for {}{}\n\n{}'.format(
-        bot.nick,
-        ('@' + bot.config.core.host) if bot.config.help.show_server_host else '',
-        msg)
-
-    try:
-        result = PASTEBIN_PROVIDERS[bot.config.help.output](msg)
-    except PostingException:
-        bot.say("Sorry! Something went wrong.")
-        LOGGER.exception("Error posting commands")
-        return
-    return result
-
-
-@plugin.rule('$nick' r'(?i)help(?:[?!]+)?$')
-@plugin.priority('low')
-def help2(bot, trigger):
-    response = (
-        "Hi, I'm a bot. Say {1}commands to me in private for a list "
-        "of my commands, or see https://sopel.chat for more "
-        "general details. My owner is {0}."
-        .format(bot.config.core.owner, bot.config.core.help_prefix))
-    bot.reply(response)
+    LOGGER.warning(
+        'Sopel\'s built-in help plugin is obsolete. '
+        'Install sopel-help as the official help plugin for Sopel.\n'
+        'You can install sopel-help with "pip install sopel-help".')


Test patch: