Skip to content

Commit

Permalink
help: replace built-in help by sopel-help
Browse files Browse the repository at this point in the history
The help built-in plugin is obsolete, and replaced by
sopel-help which can be installed with pip install sopel-help (as a
standalone install) or when installing Sopel through pip.

A warning has been added to the sopel.modules.help.setup() in case
someone install Sopel without its dependencies.
All rules/commands have been removed.

Thank you for your service, help, you will be remembered.

Co-authored-by: dgw <dgw@technobabbl.es>
  • Loading branch information
Exirel and dgw committed Aug 21, 2022
1 parent 2e90817 commit f671428
Show file tree
Hide file tree
Showing 2 changed files with 8 additions and 339 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies = [
"sqlalchemy>=1.4,<1.5",
"importlib_metadata>=3.6",
"packaging",
"sopel-help>=0.4.0",
]

[project.urls]
Expand Down
346 changes: 7 additions & 339 deletions sopel/modules/help.py
Original file line number Diff line number Diff line change
@@ -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".')

0 comments on commit f671428

Please sign in to comment.