text: # coding=utf-8
  """
  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.
  
  https://sopel.chat
  """
  from __future__ import absolute_import, division, print_function, unicode_literals
  
  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(l):
                      bot.say(l, 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)
  