Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

help: replace with dependency on sopel-help #2332

Merged
merged 1 commit into from
Aug 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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".')