From 7059d64231df9514b608f7cd2fcaa11b96adafeb Mon Sep 17 00:00:00 2001 From: iou1name Date: Wed, 22 Nov 2017 19:26:40 -0500 Subject: [PATCH] first commit --- .gitignore | 11 + README.md | 4 + __init__.py | 101 ++++++ bot.py | 635 +++++++++++++++++++++++++++++++++++ config/__init__.py | 263 +++++++++++++++ config/core_section.py | 200 +++++++++++ config/types.py | 351 ++++++++++++++++++++ coretasks.py | 719 ++++++++++++++++++++++++++++++++++++++++ db.py | 247 ++++++++++++++ formatting.py | 107 ++++++ irc.py | 411 +++++++++++++++++++++++ loader.py | 227 +++++++++++++ logger.py | 55 +++ module.py | 460 +++++++++++++++++++++++++ modules/8ball.py | 21 ++ modules/admin.py | 229 +++++++++++++ modules/adminchannel.py | 276 +++++++++++++++ modules/announce.py | 22 ++ modules/ascii.py | 192 +++++++++++ modules/away.py | 56 ++++ modules/banhe.py | 49 +++ modules/bq.py | 20 ++ modules/bugzilla.py | 97 ++++++ modules/calc.py | 66 ++++ modules/clock.py | 276 +++++++++++++++ modules/countdown.py | 39 +++ modules/currency.py | 51 +++ modules/dice.py | 259 +++++++++++++++ modules/echo.py | 13 + modules/etymology.py | 97 ++++++ modules/find.py | 139 ++++++++ modules/hangman.py | 92 +++++ modules/help.py | 51 +++ modules/ip.py | 136 ++++++++ modules/ipython.py | 78 +++++ modules/isup.py | 46 +++ modules/light.py | 41 +++ modules/lmgtfy.py | 18 + modules/meetbot.py | 432 ++++++++++++++++++++++++ modules/movie.py | 161 +++++++++ modules/ping.py | 28 ++ modules/pingall.py | 17 + modules/rand.py | 48 +++ modules/reload.py | 110 ++++++ modules/remind.py | 229 +++++++++++++ modules/resistor.py | 46 +++ modules/safety.py | 198 +++++++++++ modules/scramble.py | 79 +++++ modules/search.py | 128 +++++++ modules/seen.py | 50 +++ modules/spellcheck.py | 53 +++ modules/tell.py | 174 ++++++++++ modules/tld.py | 69 ++++ modules/translate.py | 208 ++++++++++++ modules/unicode_info.py | 51 +++ modules/units.py | 186 +++++++++++ modules/uptime.py | 26 ++ modules/url.py | 68 ++++ modules/version.py | 81 +++++ modules/weather.py | 181 ++++++++++ modules/wikipedia.py | 134 ++++++++ modules/wiktionary.py | 101 ++++++ modules/willilike.py | 19 ++ modules/wolfram.py | 78 +++++ modules/xkcd.py | 124 +++++++ run_script.py | 205 ++++++++++++ sopel | 11 + test/test_config.py | 36 ++ test/test_db.py | 257 ++++++++++++++ test/test_formatting.py | 26 ++ test/test_irc.py | 147 ++++++++ test/test_module.py | 210 ++++++++++++ test/test_trigger.py | 245 ++++++++++++++ test_tools.py | 183 ++++++++++ tools/__init__.py | 352 ++++++++++++++++++++ tools/_events.py | 203 ++++++++++++ tools/calculation.py | 195 +++++++++++ tools/jobs.py | 233 +++++++++++++ tools/target.py | 90 +++++ tools/time.py | 190 +++++++++++ tools/web.py | 21 ++ trigger.py | 187 +++++++++++ 82 files changed, 12025 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 __init__.py create mode 100755 bot.py create mode 100755 config/__init__.py create mode 100755 config/core_section.py create mode 100755 config/types.py create mode 100755 coretasks.py create mode 100755 db.py create mode 100755 formatting.py create mode 100755 irc.py create mode 100755 loader.py create mode 100644 logger.py create mode 100755 module.py create mode 100755 modules/8ball.py create mode 100755 modules/admin.py create mode 100755 modules/adminchannel.py create mode 100755 modules/announce.py create mode 100755 modules/ascii.py create mode 100755 modules/away.py create mode 100755 modules/banhe.py create mode 100755 modules/bq.py create mode 100755 modules/bugzilla.py create mode 100755 modules/calc.py create mode 100755 modules/clock.py create mode 100755 modules/countdown.py create mode 100755 modules/currency.py create mode 100755 modules/dice.py create mode 100755 modules/echo.py create mode 100755 modules/etymology.py create mode 100755 modules/find.py create mode 100755 modules/hangman.py create mode 100755 modules/help.py create mode 100755 modules/ip.py create mode 100755 modules/ipython.py create mode 100755 modules/isup.py create mode 100755 modules/light.py create mode 100755 modules/lmgtfy.py create mode 100755 modules/meetbot.py create mode 100755 modules/movie.py create mode 100755 modules/ping.py create mode 100755 modules/pingall.py create mode 100755 modules/rand.py create mode 100755 modules/reload.py create mode 100755 modules/remind.py create mode 100755 modules/resistor.py create mode 100755 modules/safety.py create mode 100755 modules/scramble.py create mode 100755 modules/search.py create mode 100755 modules/seen.py create mode 100755 modules/spellcheck.py create mode 100755 modules/tell.py create mode 100755 modules/tld.py create mode 100755 modules/translate.py create mode 100755 modules/unicode_info.py create mode 100755 modules/units.py create mode 100755 modules/uptime.py create mode 100755 modules/url.py create mode 100755 modules/version.py create mode 100755 modules/weather.py create mode 100755 modules/wikipedia.py create mode 100755 modules/wiktionary.py create mode 100755 modules/willilike.py create mode 100755 modules/wolfram.py create mode 100755 modules/xkcd.py create mode 100755 run_script.py create mode 100755 sopel create mode 100644 test/test_config.py create mode 100644 test/test_db.py create mode 100644 test/test_formatting.py create mode 100644 test/test_irc.py create mode 100644 test/test_module.py create mode 100644 test/test_trigger.py create mode 100755 test_tools.py create mode 100755 tools/__init__.py create mode 100755 tools/_events.py create mode 100755 tools/calculation.py create mode 100755 tools/jobs.py create mode 100755 tools/target.py create mode 100755 tools/time.py create mode 100755 tools/web.py create mode 100755 trigger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eea2537 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*/__pycache__/ +logs/ +modules_old/ +default.cfg +*.db +*.pid +*.dat +*.txt +tourettes.py + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdb7584 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +NIGGER DICKS +NIGGER DICKS +NIGGER DICKS +NIGGER DICKS diff --git a/__init__.py b/__init__.py new file mode 100755 index 0000000..53e803c --- /dev/null +++ b/__init__.py @@ -0,0 +1,101 @@ +# coding=utf-8 +# ASCII ONLY IN THIS FILE THOUGH!!!!!!! +# Python does some stupid bullshit of respecting LC_ALL over the encoding on the +# file, so in order to undo Python's ridiculous fucking idiocy, we have to have +# our own check. + +# Copyright 2008, Sean B. Palmer, inamidst.com +# Copyright 2012, Elsie Powell, http://embolalia.com +# Copyright 2012, Elad Alfassa +# +# Licensed under the Eiffel Forum License 2. +import locale +import sys +loc = locale.getlocale() +if sys.version_info.major > 2: + if not loc[1] or 'UTF-8' not in loc[1]: + print('WARNING!!! You are running with a non-UTF8 locale environment ' + 'variables (e.g. LC_ALL is set to "C"), which makes Python 3 do ' + 'stupid things. If you get strange errors, please set it to ' + 'something like "en_US.UTF-8".', file=sys.stderr) + + +from collections import namedtuple +import os +import re +import time +import traceback +import signal + +__version__ = '6.5.0' + + +def _version_info(version=__version__): + regex = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?.*') + version_groups = regex.match(__version__).groups() + major, minor, micro = (int(piece) for piece in version_groups[0:3]) + level = version_groups[3] + serial = int(version_groups[4] or 0) + if level == 'a': + level = 'alpha' + elif level == 'b': + level = 'beta' + elif level == 'rc': + level = 'candidate' + elif not level and version_groups[4] is None: + level = 'final' + else: + level = 'alpha' + version_type = namedtuple('version_info', + 'major, minor, micro, releaselevel, serial') + return version_type(major, minor, micro, level, serial) +version_info = _version_info() + + +def run(config, pid_file, daemon=False): + import bot + import logger + from tools import stderr + delay = 20 + # Inject ca_certs from config to web for SSL validation of web requests + if not config.core.ca_certs: + stderr('Could not open CA certificates file. SSL will not ' + 'work properly.') + + def signal_handler(sig, frame): + if sig == signal.SIGUSR1 or sig == signal.SIGTERM: + stderr('Got quit signal, shutting down.') + p.quit('Closing') + while True: + try: + p = bot.Sopel(config, daemon=daemon) + if hasattr(signal, 'SIGUSR1'): + signal.signal(signal.SIGUSR1, signal_handler) + if hasattr(signal, 'SIGTERM'): + signal.signal(signal.SIGTERM, signal_handler) + logger.setup_logging(p) + p.run(config.core.host, int(config.core.port)) + except KeyboardInterrupt: + break + except Exception: + trace = traceback.format_exc() + try: + stderr(trace) + except: + pass + logfile = open(os.path.join(config.core.logdir, 'exceptions.log'), 'a') + logfile.write('Critical exception in core') + logfile.write(trace) + logfile.write('----------------------------------------\n\n') + logfile.close() + os.unlink(pid_file) + os._exit(1) + + if not isinstance(delay, int): + break + if p.hasquit: + break + stderr('Warning: Disconnected. Reconnecting in %s seconds...' % delay) + time.sleep(delay) + os.unlink(pid_file) + os._exit(0) diff --git a/bot.py b/bot.py new file mode 100755 index 0000000..534265f --- /dev/null +++ b/bot.py @@ -0,0 +1,635 @@ +# coding=utf-8 +# Copyright 2008, Sean B. Palmer, inamidst.com +# Copyright © 2012, Elad Alfassa +# Copyright 2012-2015, Elsie Powell, http://embolalia.com +# +# Licensed under the Eiffel Forum License 2. + +from __future__ import unicode_literals, absolute_import, print_function, division + +import collections +import os +import re +import sys +import threading +import time + +import tools +import irc +from db import SopelDB +from tools import stderr, Identifier +import tools.jobs +from trigger import Trigger +from module import NOLIMIT +from logger import get_logger +import loader + + +LOGGER = get_logger(__name__) + +if sys.version_info.major >= 3: + unicode = str + basestring = str + py3 = True +else: + py3 = False + + +class _CapReq(object): + def __init__(self, prefix, module, failure=None, arg=None, success=None): + def nop(bot, cap): + pass + # TODO at some point, reorder those args to be sane + self.prefix = prefix + self.module = module + self.arg = arg + self.failure = failure or nop + self.success = success or nop + + +class Sopel(irc.Bot): + def __init__(self, config, daemon=False): + irc.Bot.__init__(self, config) + self._daemon = daemon # Used for iPython. TODO something saner here + # `re.compile('.*') is re.compile('.*')` because of caching, so we need + # to associate a list with each regex, since they are unexpectedly + # indistinct. + self._callables = { + 'high': collections.defaultdict(list), + 'medium': collections.defaultdict(list), + 'low': collections.defaultdict(list) + } + self.config = config + """The :class:`sopel.config.Config` for the current Sopel instance.""" + self.doc = {} + """ + A dictionary of command names to their docstring and example, if + declared. The first item in a callable's commands list is used as the + key in version *3.2* onward. Prior to *3.2*, the name of the function + as declared in the source code was used. + """ + self._command_groups = collections.defaultdict(list) + """A mapping of module names to a list of commands in it.""" + self.stats = {} # deprecated, remove in 7.0 + self._times = {} + """ + A dictionary mapping lower-case'd nicks to dictionaries which map + funtion names to the time which they were last used by that nick. + """ + + self.server_capabilities = {} + """A dict mapping supported IRCv3 capabilities to their options. + + For example, if the server specifies the capability ``sasl=EXTERNAL``, + it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified + without any options will have ``None`` as the value. + + For servers that do not support IRCv3, this will be an empty set.""" + self.enabled_capabilities = set() + """A set containing the IRCv3 capabilities that the bot has enabled.""" + self._cap_reqs = dict() + """A dictionary of capability names to a list of requests""" + + self.privileges = dict() + """A dictionary of channels to their users and privilege levels + + The value associated with each channel is a dictionary of + :class:`sopel.tools.Identifier`\s to + a bitwise integer value, determined by combining the appropriate + constants from :mod:`sopel.module`. + + .. deprecated:: 6.2.0 + Use :attr:`channels` instead. + """ + + self.channels = tools.SopelMemory() # name to chan obj + """A map of the channels that Sopel is in. + + The keys are Identifiers of the channel names, and map to + :class:`sopel.tools.target.Channel` objects which contain the users in + the channel and their permissions. + """ + self.users = tools.SopelMemory() # name to user obj + """A map of the users that Sopel is aware of. + + The keys are Identifiers of the nicknames, and map to + :class:`sopel.tools.target.User` instances. In order for Sopel to be + aware of a user, it must be in at least one channel which they are also + in. + """ + + self.db = SopelDB(config) + """The bot's database, as a :class:`sopel.db.SopelDB` instance.""" + + self.memory = tools.SopelMemory() + """ + A thread-safe dict for storage of runtime data to be shared between + modules. See :class:`sopel.tools.Sopel.SopelMemory` + """ + + self.scheduler = tools.jobs.JobScheduler(self) + self.scheduler.start() + + # Set up block lists + # Default to empty + if not self.config.core.nick_blocks: + self.config.core.nick_blocks = [] + if not self.config.core.host_blocks: + self.config.core.host_blocks = [] + self.setup() + + # Backwards-compatibility aliases to attributes made private in 6.2. Remove + # these in 7.0 + times = property(lambda self: getattr(self, '_times')) + command_groups = property(lambda self: getattr(self, '_command_groups')) + + def write(self, args, text=None): # Shim this in here for autodocs + """Send a command to the server. + + ``args`` is an iterable of strings, which are joined by spaces. + ``text`` is treated as though it were the final item in ``args``, but + is preceeded by a ``:``. This is a special case which means that + ``text``, unlike the items in ``args`` may contain spaces (though this + constraint is not checked by ``write``). + + In other words, both ``sopel.write(('PRIVMSG',), 'Hello, world!')`` + and ``sopel.write(('PRIVMSG', ':Hello, world!'))`` will send + ``PRIVMSG :Hello, world!`` to the server. + + Newlines and carriage returns ('\\n' and '\\r') are removed before + sending. Additionally, if the message (after joining) is longer than + than 510 characters, any remaining characters will not be sent. + """ + irc.Bot.write(self, args, text=text) + + def setup(self): + stderr("\nWelcome to Sopel. Loading modules...\n\n") + + modules = loader.enumerate_modules(self.config) + + error_count = 0 + success_count = 0 + for name in modules: + path, type_ = modules[name] + + try: + module, _ = loader.load_module(name, path, type_) + except Exception as e: + error_count = error_count + 1 + filename, lineno = tools.get_raising_file_and_line() + rel_path = os.path.relpath(filename, os.path.dirname(__file__)) + raising_stmt = "%s:%d" % (rel_path, lineno) + stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) + else: + try: + if hasattr(module, 'setup'): + module.setup(self) + relevant_parts = loader.clean_module( + module, self.config) + except Exception as e: + error_count = error_count + 1 + filename, lineno = tools.get_raising_file_and_line() + rel_path = os.path.relpath( + filename, os.path.dirname(__file__) + ) + raising_stmt = "%s:%d" % (rel_path, lineno) + stderr("Error in %s setup procedure: %s (%s)" + % (name, e, raising_stmt)) + else: + self.register(*relevant_parts) + success_count += 1 + + if len(modules) > 1: # coretasks is counted + stderr('\n\nRegistered %d modules,' % (success_count - 1)) + stderr('%d modules failed to load\n\n' % error_count) + else: + stderr("Warning: Couldn't load any modules") + + def unregister(self, obj): + if not callable(obj): + return + if hasattr(obj, 'rule'): # commands and intents have it added + for rule in obj.rule: + callb_list = self._callables[obj.priority][rule] + if obj in callb_list: + callb_list.remove(obj) + if hasattr(obj, 'interval'): + # TODO this should somehow find the right job to remove, rather than + # clearing the entire queue. Issue #831 + self.scheduler.clear_jobs() + if (getattr(obj, '__name__', None) == 'shutdown' + and obj in self.shutdown_methods): + self.shutdown_methods.remove(obj) + + def register(self, callables, jobs, shutdowns, urls): + self.shutdown_methods = shutdowns + for callbl in callables: + for rule in callbl.rule: + self._callables[callbl.priority][rule].append(callbl) + if hasattr(callbl, 'commands'): + module_name = callbl.__module__.rsplit('.', 1)[-1] + # TODO doc and make decorator for this. Not sure if this is how + # it should work yet, so not making it public for 6.0. + category = getattr(callbl, 'category', module_name) + self._command_groups[category].append(callbl.commands[0]) + for command, docs in callbl._docs.items(): + self.doc[command] = docs + for func in jobs: + for interval in func.interval: + job = tools.jobs.Job(interval, func) + self.scheduler.add_job(job) + + if not self.memory.contains('url_callbacks'): + self.memory['url_callbacks'] = tools.SopelMemory() + for func in urls: + self.memory['url_callbacks'][func.url_regex] = func + + def part(self, channel, msg=None): + """Part a channel.""" + self.write(['PART', channel], msg) + + def join(self, channel, password=None): + """Join a channel + + If `channel` contains a space, and no `password` is given, the space is + assumed to split the argument into the channel to join and its + password. `channel` should not contain a space if `password` is given. + + """ + if password is None: + self.write(('JOIN', channel)) + else: + self.write(['JOIN', channel, password]) + + def msg(self, recipient, text, max_messages=1): + # Deprecated, but way too much of a pain to remove. + self.say(text, recipient, max_messages) + + def say(self, text, recipient, max_messages=1): + """Send ``text`` as a PRIVMSG to ``recipient``. + + In the context of a triggered callable, the ``recipient`` defaults to + the channel (or nickname, if a private message) from which the message + was received. + + By default, this will attempt to send the entire ``text`` in one + message. If the text is too long for the server, it may be truncated. + If ``max_messages`` is given, the ``text`` will be split into at most + that many messages, each no more than 400 bytes. The split is made at + the last space character before the 400th byte, or at the 400th byte if + no such space exists. If the ``text`` is too long to fit into the + specified number of messages using the above splitting, the final + message will contain the entire remainder, which may be truncated by + the server. + """ + # We're arbitrarily saying that the max is 400 bytes of text when + # messages will be split. Otherwise, we'd have to acocunt for the bot's + # hostmask, which is hard. + max_text_length = 400 + max_messages=1000 + # Encode to bytes, for propper length calculation + if isinstance(text, unicode): + encoded_text = text.encode('utf-8') + else: + encoded_text = text + excess = '' + if b'\n' in encoded_text: + excess = encoded_text.split(b"\n", 1)[1] + encoded_text = encoded_text.split(b"\n", 1)[0] + + if max_messages > 1 and len(encoded_text) > max_text_length: + last_space = encoded_text.rfind(' '.encode('utf-8'), 0, max_text_length) + if last_space == -1: + excess = encoded_text[max_text_length:] + encoded_text = encoded_text[:max_text_length] + else: + excess = encoded_text[last_space + 1:] + encoded_text = encoded_text[:last_space] + # We'll then send the excess at the end + # Back to unicode again, so we don't screw things up later. + text = encoded_text.decode('utf-8') + try: + self.sending.acquire() + + # No messages within the last 3 seconds? Go ahead! + # Otherwise, wait so it's been at least 0.8 seconds + penalty + + recipient_id = Identifier(recipient) + + if recipient_id not in self.stack: + self.stack[recipient_id] = [] + + self.write(('PRIVMSG', recipient), text) + self.stack[recipient_id].append((time.time(), self.safe(text))) + self.stack[recipient_id] = self.stack[recipient_id][-10:] + finally: + self.sending.release() + # Now that we've sent the first part, we need to send the rest. Doing + # this recursively seems easier to me than iteratively + if excess: + self.msg(recipient, excess, max_messages - 1) + + def notice(self, text, dest): + """Send an IRC NOTICE to a user or a channel. + + Within the context of a triggered callable, ``dest`` will default to + the channel (or nickname, if a private message), in which the trigger + happened. + """ + self.write(('NOTICE', dest), text) + + def action(self, text, dest): + """Send ``text`` as a CTCP ACTION PRIVMSG to ``dest``. + + The same loop detection and length restrictions apply as with + :func:`say`, though automatic message splitting is not available. + + Within the context of a triggered callable, ``dest`` will default to + the channel (or nickname, if a private message), in which the trigger + happened. + """ + self.say('\001ACTION {}\001'.format(text), dest) + + def reply(self, text, dest, reply_to, notice=False): + """Prepend ``reply_to`` to ``text``, and send as a PRIVMSG to ``dest``. + + If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG. + + The same loop detection and length restrictions apply as with + :func:`say`, though automatic message splitting is not available. + + Within the context of a triggered callable, ``reply_to`` will default to + the nickname of the user who triggered the call, and ``dest`` to the + channel (or nickname, if a private message), in which the trigger + happened. + """ + text = '%s: %s' % (reply_to, text) + if notice: + self.notice(text, dest) + else: + self.say(text, dest) + + class SopelWrapper(object): + def __init__(self, sopel, trigger): + # The custom __setattr__ for this class sets the attribute on the + # original bot object. We don't want that for these, so we set them + # with the normal __setattr__. + object.__setattr__(self, '_bot', sopel) + object.__setattr__(self, '_trigger', trigger) + + def __dir__(self): + classattrs = [attr for attr in self.__class__.__dict__ + if not attr.startswith('__')] + return list(self.__dict__) + classattrs + dir(self._bot) + + def __getattr__(self, attr): + return getattr(self._bot, attr) + + def __setattr__(self, attr, value): + return setattr(self._bot, attr, value) + + def say(self, message, destination=None, max_messages=1): + if destination is None: + destination = self._trigger.sender + self._bot.say(message, destination, max_messages) + + def action(self, message, destination=None): + if destination is None: + destination = self._trigger.sender + self._bot.action(message, destination) + + def notice(self, message, destination=None): + if destination is None: + destination = self._trigger.sender + self._bot.notice(message, destination) + + def reply(self, message, destination=None, reply_to=None, notice=False): + if destination is None: + destination = self._trigger.sender + if reply_to is None: + reply_to = self._trigger.nick + self._bot.reply(message, destination, reply_to, notice) + + def call(self, func, sopel, trigger): + nick = trigger.nick + current_time = time.time() + if nick not in self._times: + self._times[nick] = dict() + if self.nick not in self._times: + self._times[self.nick] = dict() + if not trigger.is_privmsg and trigger.sender not in self._times: + self._times[trigger.sender] = dict() + + if not trigger.admin and not func.unblockable: + if func in self._times[nick]: + usertimediff = current_time - self._times[nick][func] + if func.rate > 0 and usertimediff < func.rate: + #self._times[nick][func] = current_time + LOGGER.info( + "%s prevented from using %s in %s due to user limit: %d < %d", + trigger.nick, func.__name__, trigger.sender, usertimediff, + func.rate + ) + return + if func in self._times[self.nick]: + globaltimediff = current_time - self._times[self.nick][func] + if func.global_rate > 0 and globaltimediff < func.global_rate: + #self._times[self.nick][func] = current_time + LOGGER.info( + "%s prevented from using %s in %s due to global limit: %d < %d", + trigger.nick, func.__name__, trigger.sender, globaltimediff, + func.global_rate + ) + return + + if not trigger.is_privmsg and func in self._times[trigger.sender]: + chantimediff = current_time - self._times[trigger.sender][func] + if func.channel_rate > 0 and chantimediff < func.channel_rate: + #self._times[trigger.sender][func] = current_time + LOGGER.info( + "%s prevented from using %s in %s due to channel limit: %d < %d", + trigger.nick, func.__name__, trigger.sender, chantimediff, + func.channel_rate + ) + return + + try: + exit_code = func(sopel, trigger) + except Exception: + exit_code = None + self.error(trigger) + + if exit_code != NOLIMIT: + self._times[nick][func] = current_time + self._times[self.nick][func] = current_time + if not trigger.is_privmsg: + self._times[trigger.sender][func] = current_time + + def dispatch(self, pretrigger): + args = pretrigger.args + event, args, text = pretrigger.event, args, args[-1] if args else '' + + if self.config.core.nick_blocks or self.config.core.host_blocks: + nick_blocked = self._nick_blocked(pretrigger.nick) + host_blocked = self._host_blocked(pretrigger.host) + else: + nick_blocked = host_blocked = None + + list_of_blocked_functions = [] + for priority in ('high', 'medium', 'low'): + items = self._callables[priority].items() + + for regexp, funcs in items: + match = regexp.match(text) + if not match: + continue + user_obj = self.users.get(pretrigger.nick) + account = user_obj.account if user_obj else None + trigger = Trigger(self.config, pretrigger, match, account) + wrapper = self.SopelWrapper(self, trigger) + + for func in funcs: + if (not trigger.admin and + not func.unblockable and + (nick_blocked or host_blocked)): + function_name = "%s.%s" % ( + func.__module__, func.__name__ + ) + list_of_blocked_functions.append(function_name) + continue + + if event not in func.event: + continue + if (hasattr(func, 'intents') and + trigger.tags.get('intent') not in func.intents): + continue + if func.thread: + targs = (func, wrapper, trigger) + t = threading.Thread(target=self.call, args=targs) + t.start() + else: + self.call(func, wrapper, trigger) + + if list_of_blocked_functions: + if nick_blocked and host_blocked: + block_type = 'both' + elif nick_blocked: + block_type = 'nick' + else: + block_type = 'host' + LOGGER.info( + "[%s]%s prevented from using %s.", + block_type, + trigger.nick, + ', '.join(list_of_blocked_functions) + ) + + def _host_blocked(self, host): + bad_masks = self.config.core.host_blocks + for bad_mask in bad_masks: + bad_mask = bad_mask.strip() + if not bad_mask: + continue + if (re.match(bad_mask + '$', host, re.IGNORECASE) or + bad_mask == host): + return True + return False + + def _nick_blocked(self, nick): + bad_nicks = self.config.core.nick_blocks + for bad_nick in bad_nicks: + bad_nick = bad_nick.strip() + if not bad_nick: + continue + if (re.match(bad_nick + '$', nick, re.IGNORECASE) or + Identifier(bad_nick) == nick): + return True + return False + + def _shutdown(self): + stderr( + 'Calling shutdown for %d modules.' % (len(self.shutdown_methods),) + ) + + for shutdown_method in self.shutdown_methods: + try: + stderr( + "calling %s.%s" % ( + shutdown_method.__module__, shutdown_method.__name__, + ) + ) + shutdown_method(self) + except Exception as e: + stderr( + "Error calling shutdown method for module %s:%s" % ( + shutdown_method.__module__, e + ) + ) + + def cap_req(self, module_name, capability, arg=None, failure_callback=None, + success_callback=None): + """Tell Sopel to request a capability when it starts. + + By prefixing the capability with `-`, it will be ensured that the + capability is not enabled. Simmilarly, by prefixing the capability with + `=`, it will be ensured that the capability is enabled. Requiring and + disabling is "first come, first served"; if one module requires a + capability, and another prohibits it, this function will raise an + exception in whichever module loads second. An exception will also be + raised if the module is being loaded after the bot has already started, + and the request would change the set of enabled capabilities. + + If the capability is not prefixed, and no other module prohibits it, it + will be requested. Otherwise, it will not be requested. Since + capability requests that are not mandatory may be rejected by the + server, as well as by other modules, a module which makes such a + request should account for that possibility. + + The actual capability request to the server is handled after the + completion of this function. In the event that the server denies a + request, the `failure_callback` function will be called, if provided. + The arguments will be a `Sopel` object, and the capability which was + rejected. This can be used to disable callables which rely on the + capability. It will be be called either if the server NAKs the request, + or if the server enabled it and later DELs it. + + The `success_callback` function will be called upon acknowledgement of + the capability from the server, whether during the initial capability + negotiation, or later. + + If ``arg`` is given, and does not exactly match what the server + provides or what other modules have requested for that capability, it is + considered a conflict. + """ + # TODO raise better exceptions + cap = capability[1:] + prefix = capability[0] + + entry = self._cap_reqs.get(cap, []) + if any((ent.arg != arg for ent in entry)): + raise Exception('Capability conflict') + + if prefix == '-': + if self.connection_registered and cap in self.enabled_capabilities: + raise Exception('Can not change capabilities after server ' + 'connection has been completed.') + if any((ent.prefix != '-' for ent in entry)): + raise Exception('Capability conflict') + entry.append(_CapReq(prefix, module_name, failure_callback, arg, + success_callback)) + self._cap_reqs[cap] = entry + else: + if prefix != '=': + cap = capability + prefix = '' + if self.connection_registered and (cap not in + self.enabled_capabilities): + raise Exception('Can not change capabilities after server ' + 'connection has been completed.') + # Non-mandatory will callback at the same time as if the server + # rejected it. + if any((ent.prefix == '-' for ent in entry)) and prefix == '=': + raise Exception('Capability conflict') + entry.append(_CapReq(prefix, module_name, failure_callback, arg, + success_callback)) + self._cap_reqs[cap] = entry diff --git a/config/__init__.py b/config/__init__.py new file mode 100755 index 0000000..f97d867 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,263 @@ +# coding=utf-8 +""" +The config object provides a simplified to access Sopel's configuration file. +The sections of the file are attributes of the object, and the keys in the +section are attributes of that. So, for example, the ``eggs`` attribute in the +``[spam]`` section can be accessed from ``config.spam.eggs``. + +Section definitions (see "Section configuration sections" below) can be added +to the config object with ``define_section``. When this is done, only the +defined keys will be available. A section can not be given more than one +definition. The ``[core]`` section is defined with ``CoreSection`` when the +object is initialized. + +.. versionadded:: 6.0.0 +""" +# Copyright 2012-2015, Elsie Powell, embolalia.com +# Copyright © 2012, Elad Alfassa +# Licensed under the Eiffel Forum License 2. + +from __future__ import unicode_literals, absolute_import, print_function, division + +from tools import iteritems, stderr +import tools +from tools import get_input +import loader +import os +import sys +if sys.version_info.major < 3: + import ConfigParser +else: + basestring = str + import configparser as ConfigParser +import config.core_section +from config.types import StaticSection + + +class ConfigurationError(Exception): + """ Exception type for configuration errors """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return 'ConfigurationError: %s' % self.value + + +class Config(object): + def __init__(self, filename, validate=True): + """The bot's configuration. + + The given filename will be associated with the configuration, and is + the file which will be written if write() is called. If load is not + given or True, the configuration object will load the attributes from + the file at filename. + + A few default values will be set here if they are not defined in the + config file, or a config file is not loaded. They are documented below. + """ + self.filename = filename + """The config object's associated file, as noted above.""" + self.parser = ConfigParser.RawConfigParser(allow_no_value=True) + self.parser.read(self.filename) + self.define_section('core', config.core_section.CoreSection, + validate=validate) + self.get = self.parser.get + + @property + def homedir(self): + """An alias to config.core.homedir""" + # Technically it's the other way around, so we can bootstrap filename + # attributes in the core section, but whatever. + configured = None + if self.parser.has_option('core', 'homedir'): + configured = self.parser.get('core', 'homedir') + if configured: + return configured + else: + return os.path.dirname(self.filename) + + def save(self): + """Save all changes to the config file.""" + cfgfile = open(self.filename, 'w') + self.parser.write(cfgfile) + cfgfile.flush() + cfgfile.close() + + def add_section(self, name): + """Add a section to the config file. + + Returns ``False`` if already exists. + """ + try: + return self.parser.add_section(name) + except ConfigParser.DuplicateSectionError: + return False + + def define_section(self, name, cls_, validate=True): + """Define the available settings in a section. + + ``cls_`` must be a subclass of ``StaticSection``. If the section has + already been defined with a different class, ValueError is raised. + + If ``validate`` is True, the section's values will be validated, and an + exception raised if they are invalid. This is desirable in a module's + setup function, for example, but might not be in the configure function. + """ + if not issubclass(cls_, StaticSection): + raise ValueError("Class must be a subclass of StaticSection.") + current = getattr(self, name, None) + current_name = str(current.__class__) + new_name = str(cls_) + if (current is not None and not isinstance(current, self.ConfigSection) + and not current_name == new_name): + raise ValueError( + "Can not re-define class for section from {} to {}.".format( + current_name, new_name) + ) + setattr(self, name, cls_(self, name, validate=validate)) + + class ConfigSection(object): + + """Represents a section of the config file. + + Contains all keys in thesection as attributes. + + """ + + def __init__(self, name, items, parent): + object.__setattr__(self, '_name', name) + object.__setattr__(self, '_parent', parent) + for item in items: + value = item[1].strip() + if not value.lower() == 'none': + if value.lower() == 'false': + value = False + object.__setattr__(self, item[0], value) + + def __getattr__(self, name): + return None + + def __setattr__(self, name, value): + object.__setattr__(self, name, value) + if type(value) is list: + value = ','.join(value) + self._parent.parser.set(self._name, name, value) + + def get_list(self, name): + value = getattr(self, name) + if not value: + return [] + if isinstance(value, basestring): + value = value.split(',') + # Keep the split value, so we don't have to keep doing this + setattr(self, name, value) + return value + + def __getattr__(self, name): + if name in self.parser.sections(): + items = self.parser.items(name) + section = self.ConfigSection(name, items, self) # Return a section + setattr(self, name, section) + return section + else: + raise AttributeError("%r object has no attribute %r" + % (type(self).__name__, name)) + + def option(self, question, default=False): + """Ask "y/n" and return the corresponding boolean answer. + + Show user in terminal a "y/n" prompt, and return true or false based on + the response. If default is passed as true, the default will be shown + as ``[y]``, else it will be ``[n]``. ``question`` should be phrased as + a question, but without a question mark at the end. + + """ + d = 'n' + if default: + d = 'y' + ans = get_input(question + ' (y/n)? [' + d + '] ') + if not ans: + ans = d + return ans.lower() == 'y' + + def _modules(self): + home = os.getcwd() + modules_dir = os.path.join(home, 'modules') + filenames = sopel.loader.enumerate_modules(self) + os.sys.path.insert(0, modules_dir) + for name, mod_spec in iteritems(filenames): + path, type_ = mod_spec + try: + module, _ = sopel.loader.load_module(name, path, type_) + except Exception as e: + filename, lineno = sopel.tools.get_raising_file_and_line() + rel_path = os.path.relpath(filename, os.path.dirname(__file__)) + raising_stmt = "%s:%d" % (rel_path, lineno) + stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) + else: + if hasattr(module, 'configure'): + prompt = name + ' module' + if module.__doc__: + doc = module.__doc__.split('\n', 1)[0] + if doc: + prompt = doc + prompt = 'Configure {} (y/n)? [n]'.format(prompt) + do_configure = get_input(prompt) + do_configure = do_configure and do_configure.lower() == 'y' + if do_configure: + module.configure(self) + self.save() + + +def _wizard(section, config=None): + dotdir = os.path.expanduser('~/.sopel') + configpath = os.path.join(dotdir, (config or 'default') + '.cfg') + if section == 'all': + _create_config(configpath) + elif section == 'mod': + _check_dir(False) + if not os.path.isfile(configpath): + print("No config file found." + + " Please make one before configuring these options.") + sys.exit(1) + config = Config(configpath, validate=False) + config._modules() + + +def _check_dir(create=True): + dotdir = os.path.join(os.path.expanduser('~'), '.sopel') + if not os.path.isdir(dotdir): + if create: + print('Creating a config directory at ~/.sopel...') + try: + os.makedirs(dotdir) + except Exception as e: + print('There was a problem creating %s:' % dotdir, file=sys.stderr) + print('%s, %s' % (e.__class__, str(e)), file=sys.stderr) + print('Please fix this and then run Sopel again.', file=sys.stderr) + sys.exit(1) + else: + print("No config file found. Please make one before configuring these options.") + sys.exit(1) + + +def _create_config(configpath): + _check_dir() + print("Please answer the following questions" + + " to create your configuration file:\n") + try: + config = Config(configpath, validate=False) + sopel.config.core_section.configure(config) + if config.option( + 'Would you like to see if there are any modules' + ' that need configuring' + ): + config._modules() + config.save() + except Exception: + print("Encountered an error while writing the config file." + + " This shouldn't happen. Check permissions.") + raise + sys.exit(1) + print("Config file written sucessfully!") diff --git a/config/core_section.py b/config/core_section.py new file mode 100755 index 0000000..4f1b676 --- /dev/null +++ b/config/core_section.py @@ -0,0 +1,200 @@ +# coding=utf-8 + +from __future__ import unicode_literals, absolute_import, print_function, division + +import os.path + +from config.types import ( + StaticSection, ValidatedAttribute, ListAttribute, ChoiceAttribute, + FilenameAttribute, NO_DEFAULT +) +from tools import Identifier + + +def _find_certs(): + certs = '/etc/pki/tls/cert.pem' + if not os.path.isfile(certs): + certs = '/etc/ssl/certs/ca-certificates.crt' + if not os.path.isfile(certs): + return None + return certs + + +def configure(config): + config.core.configure_setting('nick', 'Enter the nickname for your bot.') + config.core.configure_setting('host', 'Enter the server to connect to.') + config.core.configure_setting('use_ssl', 'Should the bot connect with SSL?') + if config.core.use_ssl: + default_port = 6697 + else: + default_port = 6667 + config.core.configure_setting('port', 'Enter the port to connect on.', + default=default_port) + config.core.configure_setting( + 'owner', "Enter your own IRC name (or that of the bot's owner)") + config.core.configure_setting( + 'channels', + 'Enter the channels to connect to at startup, separated by commas.' + ) + + +class CoreSection(StaticSection): + """The config section used for configuring the bot itself.""" + admins = ListAttribute('admins') + """The list of people (other than the owner) who can administer the bot""" + + admin_accounts = ListAttribute('admin_accounts') + """The list of accounts (other than the owner's) who can administer the bot. + + This should not be set for networks that do not support IRCv3 account + capabilities.""" + + auth_method = ChoiceAttribute('auth_method', choices=[ + 'nickserv', 'authserv', 'Q', 'sasl', 'server']) + """The method to use to authenticate with the server. + + Can be ``nickserv``, ``authserv``, ``Q``, ``sasl``, or ``server``.""" + + auth_password = ValidatedAttribute('auth_password') + """The password to use to authenticate with the server.""" + + auth_target = ValidatedAttribute('auth_target') + """The user to use for nickserv authentication, or the SASL mechanism. + + May not apply, depending on ``auth_method``. Defaults to NickServ for + nickserv auth, and PLAIN for SASL auth.""" + + auth_username = ValidatedAttribute('auth_username') + """The username/account to use to authenticate with the server. + + May not apply, depending on ``auth_method``.""" + + bind_host = ValidatedAttribute('bind_host') + """Bind the connection to a specific IP""" + + ca_certs = FilenameAttribute('ca_certs', default=_find_certs()) + """The path of the CA certs pem file""" + + channels = ListAttribute('channels') + """List of channels for the bot to join when it connects""" + + db_filename = ValidatedAttribute('db_filename') + """The filename for Sopel's database.""" + + default_time_format = ValidatedAttribute('default_time_format', + default='%Y-%m-%d - %T%Z') + """The default format to use for time in messages.""" + + default_timezone = ValidatedAttribute('default_timezone') + """The default timezone to use for time in messages.""" + + enable = ListAttribute('enable') + """A whitelist of the only modules you want to enable.""" + + exclude = ListAttribute('exclude') + """A list of modules which should not be loaded.""" + + extra = ListAttribute('extra') + """A list of other directories you'd like to include modules from.""" + + help_prefix = ValidatedAttribute('help_prefix', default='.') + """The prefix to use in help""" + + @property + def homedir(self): + """The directory in which various files are stored at runtime. + + By default, this is the same directory as the config. It can not be + changed at runtime. + """ + return self._parent.homedir + + host = ValidatedAttribute('host', default='irc.dftba.net') + """The server to connect to.""" + + host_blocks = ListAttribute('host_blocks') + """A list of hostmasks which Sopel should ignore. + + Regular expression syntax is used""" + + log_raw = ValidatedAttribute('log_raw', bool, default=True) + """Whether a log of raw lines as sent and recieved should be kept.""" + + logdir = FilenameAttribute('logdir', directory=True, default='logs') + """Directory in which to place logs.""" + + logging_channel = ValidatedAttribute('logging_channel', Identifier) + """The channel to send logging messages to.""" + + logging_level = ChoiceAttribute('logging_level', + ['CRITICAL', 'ERROR', 'WARNING', 'INFO', + 'DEBUG'], + 'WARNING') + """The lowest severity of logs to display.""" + + modes = ValidatedAttribute('modes', default='B') + """User modes to be set on connection.""" + + name = ValidatedAttribute('name', default='Sopel: http://sopel.chat') + """The "real name" of your bot for WHOIS responses.""" + + nick = ValidatedAttribute('nick', Identifier, default=Identifier('Sopel')) + """The nickname for the bot""" + + nick_blocks = ListAttribute('nick_blocks') + """A list of nicks which Sopel should ignore. + + Regular expression syntax is used.""" + + not_configured = ValidatedAttribute('not_configured', bool, default=False) + """For package maintainers. Not used in normal configurations. + + This allows software packages to install a default config file, with this + set to true, so that the bot will not run until it has been properly + configured.""" + + owner = ValidatedAttribute('owner', default=NO_DEFAULT) + """The IRC name of the owner of the bot.""" + + owner_account = ValidatedAttribute('owner_account') + """The services account name of the owner of the bot. + + This should only be set on networks which support IRCv3 account + capabilities. + """ + + pid_dir = FilenameAttribute('pid_dir', directory=True, default='.') + """The directory in which to put the file Sopel uses to track its process ID. + + You probably do not need to change this unless you're managing Sopel with + systemd or similar.""" + + port = ValidatedAttribute('port', int, default=6667) + """The port to connect on.""" + + prefix = ValidatedAttribute('prefix', default='\.') + """The prefix to add to the beginning of commands. + + It is a regular expression (so the default, ``\.``, means commands start + with a period), though using capturing groups will create problems.""" + + reply_errors = ValidatedAttribute('reply_errors', bool, default=True) + """Whether to message the sender of a message that triggered an error with the exception.""" + + throttle_join = ValidatedAttribute('throttle_join', int) + """Slow down the initial join of channels to prevent getting kicked. + + Sopel will only join this many channels at a time, sleeping for a second + between each batch. This is unnecessary on most networks.""" + + timeout = ValidatedAttribute('timeout', int, default=120) + """The amount of time acceptable between pings before timing out.""" + + use_ssl = ValidatedAttribute('use_ssl', bool, default=False) + """Whether to use a SSL secured connection.""" + + user = ValidatedAttribute('user', default='sopel') + """The "user" for your bot (the part before the @ in the hostname).""" + + verify_ssl = ValidatedAttribute('verify_ssl', bool, default=True) + """Whether to require a trusted SSL certificate for SSL connections.""" diff --git a/config/types.py b/config/types.py new file mode 100755 index 0000000..ca24a59 --- /dev/null +++ b/config/types.py @@ -0,0 +1,351 @@ +# coding=utf-8 +"""Types for creating section definitions. + +A section definition consists of a subclass of ``StaticSection``, on which any +number of subclasses of ``BaseValidated`` (a few common ones of which are +available in this module) are assigned as attributes. These descriptors define +how to read values from, and write values to, the config file. + +As an example, if one wanted to define the ``[spam]`` section as having an +``eggs`` option, which contains a list of values, they could do this: + + >>> class SpamSection(StaticSection): + ... eggs = ListAttribute('eggs') + ... + >>> SpamSection(config, 'spam') + >>> print(config.spam.eggs) + [] + >>> config.spam.eggs = ['goose', 'turkey', 'duck', 'chicken', 'quail'] + >>> print(config.spam.eggs) + ['goose', 'turkey', 'duck', 'chicken', 'quail'] + >>> config.spam.eggs = 'herring' + Traceback (most recent call last): + ... + ValueError: ListAttribute value must be a list. +""" + +from __future__ import unicode_literals, absolute_import, print_function, division +import os.path +import sys +from tools import get_input + +try: + import configparser +except ImportError: + import ConfigParser as configparser + +if sys.version_info.major >= 3: + unicode = str + basestring = (str, bytes) + + +class NO_DEFAULT(object): + """A special value to indicate that there should be no default.""" + + +class StaticSection(object): + """A configuration section with parsed and validated settings. + + This class is intended to be subclassed with added ``ValidatedAttribute``\s. + """ + def __init__(self, config, section_name, validate=True): + if not config.parser.has_section(section_name): + config.parser.add_section(section_name) + self._parent = config + self._parser = config.parser + self._section_name = section_name + for value in dir(self): + try: + getattr(self, value) + except ValueError as e: + raise ValueError( + 'Invalid value for {}.{}: {}'.format(section_name, value, + str(e)) + ) + except AttributeError: + if validate: + raise ValueError( + 'Missing required value for {}.{}'.format(section_name, + value) + ) + + def configure_setting(self, name, prompt, default=NO_DEFAULT): + """Return a validated value for this attribute from the terminal. + + ``prompt`` will be the docstring of the attribute if not given. + + If ``default`` is passed, it will be used if no value is given by the + user. If it is not passed, the current value of the setting, or the + default value if it's unset, will be used. Note that if ``default`` is + passed, the current value of the setting will be ignored, even if it is + not the attribute's default. + """ + clazz = getattr(self.__class__, name) + if default is NO_DEFAULT: + try: + default = getattr(self, name) + except AttributeError: + pass + except ValueError: + print('The configured value for this option was invalid.') + if clazz.default is not NO_DEFAULT: + default = clazz.default + while True: + try: + value = clazz.configure(prompt, default, self._parent, self._section_name) + except ValueError as exc: + print(exc) + else: + break + setattr(self, name, value) + + +class BaseValidated(object): + """The base type for a descriptor in a ``StaticSection``.""" + def __init__(self, name, default=None): + """ + ``name`` is the name of the setting in the section. + ``default`` is the value to be returned if the setting is not set. If + not given, AttributeError will be raised instead. + """ + self.name = name + self.default = default + + def configure(self, prompt, default, parent, section_name): + """With the prompt and default, parse and return a value from terminal. + """ + if default is not NO_DEFAULT and default is not None: + prompt = '{} [{}]'.format(prompt, default) + value = get_input(prompt + ' ') + if not value and default is NO_DEFAULT: + raise ValueError("You must provide a value for this option.") + value = value or default + return self.parse(value) + + def serialize(self, value): + """Take some object, and return the string to be saved to the file. + + Must be implemented in subclasses. + """ + raise NotImplemented("Serialize method must be implemented in subclass") + + def parse(self, value): + """Take a string from the file, and return the appropriate object. + + Must be implemented in subclasses.""" + raise NotImplemented("Parse method must be implemented in subclass") + + def __get__(self, instance, owner=None): + if instance is None: + # If instance is None, we're getting from a section class, not an + # instance of a session class. It makes the wizard code simpler + # (and is really just more intuitive) to return the descriptor + # instance here. + return self + + if instance._parser.has_option(instance._section_name, self.name): + value = instance._parser.get(instance._section_name, self.name) + else: + if self.default is not NO_DEFAULT: + return self.default + raise AttributeError( + "Missing required value for {}.{}".format( + instance._section_name, self.name + ) + ) + return self.parse(value) + + def __set__(self, instance, value): + if value is None: + instance._parser.remove_option(instance._section_name, self.name) + return + value = self.serialize(value) + instance._parser.set(instance._section_name, self.name, value) + + def __delete__(self, instance): + instance._parser.remove_option(instance._section_name, self.name) + + +def _parse_boolean(value): + if value is True or value == 1: + return value + if isinstance(value, basestring): + return value.lower() in ['1', 'yes', 'y', 'true', 'on'] + return bool(value) + + +def _serialize_boolean(value): + return 'true' if _parse_boolean(value) else 'false' + + +class ValidatedAttribute(BaseValidated): + def __init__(self, name, parse=None, serialize=None, default=None): + """A descriptor for settings in a ``StaticSection`` + + ``parse`` is the function to be used to read the string and create the + appropriate object. If not given, return the string as-is. + ``serialize`` takes an object, and returns the value to be written to + the file. If not given, defaults to ``unicode``. + """ + self.name = name + if parse == bool: + parse = _parse_boolean + if not serialize or serialize == bool: + serialize = _serialize_boolean + self.parse = parse or self.parse + self.serialize = serialize or self.serialize + self.default = default + + def serialize(self, value): + return unicode(value) + + def parse(self, value): + return value + + def configure(self, prompt, default, parent, section_name): + if self.parse == _parse_boolean: + prompt += ' (y/n)' + default = 'y' if default else 'n' + return super(ValidatedAttribute, self).configure(prompt, default, parent, section_name) + + +class ListAttribute(BaseValidated): + """A config attribute containing a list of string values. + + Values are saved to the file as a comma-separated list. It does not + currently support commas within items in the list. By default, the spaces + before and after each item are stripped; you can override this by passing + ``strip=False``.""" + def __init__(self, name, strip=True, default=None): + default = default or [] + super(ListAttribute, self).__init__(name, default=default) + self.strip = strip + + def parse(self, value): + value = value.split(',') + if self.strip: + return [v.strip() for v in value] + else: + return value + + def serialize(self, value): + if not isinstance(value, (list, set)): + raise ValueError('ListAttribute value must be a list.') + return ','.join(value) + + def configure(self, prompt, default, parent, section_name): + each_prompt = '?' + if isinstance(prompt, tuple): + each_prompt = prompt[1] + prompt = prompt[0] + + if default is not NO_DEFAULT: + default = ','.join(default) + prompt = '{} [{}]'.format(prompt, default) + else: + default = '' + print(prompt) + values = [] + value = get_input(each_prompt + ' ') or default + while value: + values.append(value) + value = get_input(each_prompt + ' ') + return self.parse(','.join(values)) + + +class ChoiceAttribute(BaseValidated): + """A config attribute which must be one of a set group of options. + + Currently, the choices can only be strings.""" + def __init__(self, name, choices, default=None): + super(ChoiceAttribute, self).__init__(name, default=default) + self.choices = choices + + def parse(self, value): + if value in self.choices: + return value + else: + raise ValueError('Value must be in {}'.format(self.choices)) + + def serialize(self, value): + if value in self.choices: + return value + else: + raise ValueError('Value must be in {}'.format(self.choices)) + + +class FilenameAttribute(BaseValidated): + """A config attribute which must be a file or directory.""" + def __init__(self, name, relative=True, directory=False, default=None): + """ + ``relative`` is whether the path should be relative to the location + of the config file (absolute paths will still be absolute). If + ``directory`` is True, the path must indicate a directory, rather than + a file. + """ + super(FilenameAttribute, self).__init__(name, default=default) + self.relative = relative + self.directory = directory + + def __get__(self, instance, owner=None): + if instance is None: + return self + if instance._parser.has_option(instance._section_name, self.name): + value = instance._parser.get(instance._section_name, self.name) + else: + if self.default is not NO_DEFAULT: + value = self.default + else: + raise AttributeError( + "Missing required value for {}.{}".format( + instance._section_name, self.name + ) + ) + main_config = instance._parent + this_section = getattr(main_config, instance._section_name) + return self.parse(main_config, this_section, value) + + def __set__(self, instance, value): + main_config = instance._parent + this_section = getattr(main_config, instance._section_name) + value = self.serialize(main_config, this_section, value) + instance._parser.set(instance._section_name, self.name, value) + + def configure(self, prompt, default, parent, section_name): + """With the prompt and default, parse and return a value from terminal. + """ + if default is not NO_DEFAULT and default is not None: + prompt = '{} [{}]'.format(prompt, default) + value = get_input(prompt + ' ') + if not value and default is NO_DEFAULT: + raise ValueError("You must provide a value for this option.") + value = value or default + return self.parse(parent, section_name, value) + + def parse(self, main_config, this_section, value): + if value is None: + return + + value = os.path.expanduser(value) + + if not os.path.isabs(value): + if not self.relative: + raise ValueError("Value must be an absolute path.") + value = os.path.join(main_config.homedir, value) + + if self.directory and not os.path.isdir(value): + try: + os.makedirs(value) + except OSError: + raise ValueError( + "Value must be an existing or creatable directory.") + if not self.directory and not os.path.isfile(value): + try: + open(value, 'w').close() + except OSError: + raise ValueError("Value must be an existant or creatable file.") + return value + + def serialize(self, main_config, this_section, value): + self.parse(main_config, this_section, value) + return value # So that it's still relative diff --git a/coretasks.py b/coretasks.py new file mode 100755 index 0000000..e7b2f42 --- /dev/null +++ b/coretasks.py @@ -0,0 +1,719 @@ +# coding=utf-8 +"""Tasks that allow the bot to run, but aren't user-facing functionality + +This is written as a module to make it easier to extend to support more +responses to standard IRC codes without having to shove them all into the +dispatch function in bot.py and making it easier to maintain. +""" +# Copyright 2008-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich +# (yanovich.net) +# Copyright © 2012, Elad Alfassa +# Copyright 2012-2015, Elsie Powell embolalia.com +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division + + +from random import randint +import re +import sys +import time +import module +from bot import _CapReq +from tools import Identifier, iteritems, events +from tools.target import User, Channel +import base64 +from logger import get_logger + +if sys.version_info.major >= 3: + unicode = str + +LOGGER = get_logger(__name__) + +batched_caps = {} +who_reqs = {} # Keeps track of reqs coming from this module, rather than others + + +def auth_after_register(bot): + """Do NickServ/AuthServ auth""" + if bot.config.core.auth_method == 'nickserv': + nickserv_name = bot.config.core.auth_target or 'NickServ' + bot.msg( + nickserv_name, + 'IDENTIFY %s' % bot.config.core.auth_password + ) + + elif bot.config.core.auth_method == 'authserv': + account = bot.config.core.auth_username + password = bot.config.core.auth_password + bot.write(( + 'AUTHSERV auth', + account + ' ' + password + )) + + elif bot.config.core.auth_method == 'Q': + account = bot.config.core.auth_username + password = bot.config.core.auth_password + bot.write(( + 'AUTH', + account + ' ' + password + )) + + +@module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT) +@module.rule('.*') +@module.thread(False) +@module.unblockable +def startup(bot, trigger): + """Do tasks related to connecting to the network. + + 001 RPL_WELCOME is from RFC2812 and is the first message that is sent after + the connection has been registered on the network. + + 251 RPL_LUSERCLIENT is a mandatory message that is sent after client + connects to the server in rfc1459. RFC2812 does not require it and all + networks might not send it. We support both. + + """ + if bot.connection_registered: + return + + bot.connection_registered = True + + auth_after_register(bot) + + modes = bot.config.core.modes + bot.write(('MODE ', '%s +%s' % (bot.nick, modes))) + + bot.memory['retry_join'] = dict() + + if bot.config.core.throttle_join: + throttle_rate = int(bot.config.core.throttle_join) + channels_joined = 0 + for channel in bot.config.core.channels: + channels_joined += 1 + if not channels_joined % throttle_rate: + time.sleep(1) + bot.join(channel) + else: + for channel in bot.config.core.channels: + bot.join(channel) + + if (not bot.config.core.owner_account and + 'account-tag' in bot.enabled_capabilities and + '@' not in bot.config.core.owner): + msg = ( + "This network supports using network services to identify you as " + "my owner, rather than just matching your nickname. This is much " + "more secure. If you'd like to do this, make sure you're logged in " + "and reply with \"{}useserviceauth\"" + ).format(bot.config.core.help_prefix) + bot.msg(bot.config.core.owner, msg) + + +@module.require_privmsg() +@module.require_owner() +@module.commands('useserviceauth') +def enable_service_auth(bot, trigger): + if bot.config.core.owner_account: + return + if 'account-tag' not in bot.enabled_capabilities: + bot.say('This server does not fully support services auth, so this ' + 'command is not available.') + return + if not trigger.account: + bot.say('You must be logged in to network services before using this ' + 'command.') + return + bot.config.core.owner_account = trigger.account + bot.config.save() + bot.say('Success! I will now use network services to identify you as my ' + 'owner.') + + +@module.event(events.ERR_NOCHANMODES) +@module.rule('.*') +@module.priority('high') +def retry_join(bot, trigger): + """Give NickServer enough time to identify on a +R channel. + + Give NickServ enough time to identify, and retry rejoining an + identified-only (+R) channel. Maximum of ten rejoin attempts. + + """ + channel = trigger.args[1] + if channel in bot.memory['retry_join'].keys(): + bot.memory['retry_join'][channel] += 1 + if bot.memory['retry_join'][channel] > 10: + LOGGER.warning('Failed to join %s after 10 attempts.', channel) + return + else: + bot.memory['retry_join'][channel] = 0 + bot.join(channel) + return + + time.sleep(6) + bot.join(channel) + + +@module.rule('(.*)') +@module.event(events.RPL_NAMREPLY) +@module.priority('high') +@module.thread(False) +@module.unblockable +def handle_names(bot, trigger): + """Handle NAMES response, happens when joining to channels.""" + names = trigger.split() + + #TODO specific to one channel type. See issue 281. + channels = re.search('(#\S*)', trigger.raw) + if not channels: + return + channel = Identifier(channels.group(1)) + if channel not in bot.privileges: + bot.privileges[channel] = dict() + + # This could probably be made flexible in the future, but I don't think + # it'd be worth it. + mapping = {'+': module.VOICE, + '%': module.HALFOP, + '@': module.OP, + '&': module.ADMIN, + '~': module.OWNER} + + for name in names: + priv = 0 + for prefix, value in iteritems(mapping): + if prefix in name: + priv = priv | value + nick = Identifier(name.lstrip(''.join(mapping.keys()))) + bot.privileges[channel][nick] = priv + + +@module.rule('(.*)') +@module.event('MODE') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_modes(bot, trigger): + """Track usermode changes and keep our lists of ops up to date.""" + # Mode message format: *( ( "-" / "+" ) * * ) + channel = Identifier(trigger.args[0]) + line = trigger.args[1:] + + # If the first character of where the mode is being set isn't a # + # then it's a user mode, not a channel mode, so we'll ignore it. + if channel.is_nick(): + return + + mapping = {'v': module.VOICE, + 'h': module.HALFOP, + 'o': module.OP, + 'a': module.ADMIN, + 'q': module.OWNER} + + modes = [] + for arg in line: + if len(arg) == 0: + continue + if arg[0] in '+-': + # There was a comment claiming IRC allows e.g. MODE +aB-c foo, but + # I don't see it in any RFCs. Leaving in the extra parsing for now. + sign = '' + modes = [] + for char in arg: + if char == '+' or char == '-': + sign = char + else: + modes.append(sign + char) + else: + arg = Identifier(arg) + for mode in modes: + priv = bot.privileges[channel].get(arg, 0) + value = mapping.get(mode[1]) + if value is not None: + if mode[0] == '+': + priv = priv | value + else: + priv = priv & ~value + bot.privileges[channel][arg] = priv + + +@module.rule('.*') +@module.event('NICK') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_nicks(bot, trigger): + """Track nickname changes and maintain our chanops list accordingly.""" + old = trigger.nick + new = Identifier(trigger) + + # Give debug mssage, and PM the owner, if the bot's own nick changes. + if old == bot.nick and new != bot.nick: + privmsg = ("Hi, I'm your bot, %s." + "Something has made my nick change. " + "This can cause some problems for me, " + "and make me do weird things. " + "You'll probably want to restart me, " + "and figure out what made that happen " + "so you can stop it happening again. " + "(Usually, it means you tried to give me a nick " + "that's protected by NickServ.)") % bot.nick + debug_msg = ("Nick changed by server. " + "This can cause unexpected behavior. Please restart the bot.") + LOGGER.critical(debug_msg) + bot.msg(bot.config.core.owner, privmsg) + return + + for channel in bot.privileges: + channel = Identifier(channel) + if old in bot.privileges[channel]: + value = bot.privileges[channel].pop(old) + bot.privileges[channel][new] = value + + for channel in bot.channels.values(): + channel.rename_user(old, new) + if old in bot.users: + bot.users[new] = bot.users.pop(old) + + +@module.rule('(.*)') +@module.event('PART') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_part(bot, trigger): + nick = trigger.nick + channel = trigger.sender + _remove_from_channel(bot, nick, channel) + + +@module.rule('.*') +@module.event('KICK') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_kick(bot, trigger): + nick = Identifier(trigger.args[1]) + channel = trigger.sender + _remove_from_channel(bot, nick, channel) + + +def _remove_from_channel(bot, nick, channel): + if nick == bot.nick: + bot.privileges.pop(channel, None) + bot.channels.pop(channel, None) + + lost_users = [] + for nick_, user in bot.users.items(): + user.channels.pop(channel, None) + if not user.channels: + lost_users.append(nick_) + for nick_ in lost_users: + bot.users.pop(nick_, None) + else: + bot.privileges[channel].pop(nick, None) + + user = bot.users.get(nick) + if user and channel in user.channels: + bot.channels[channel].clear_user(nick) + if not user.channels: + bot.users.pop(nick, None) + + +def _whox_enabled(bot): + # Either privilege tracking or away notification. For simplicity, both + # account notify and extended join must be there for account tracking. + return (('account-notify' in bot.enabled_capabilities and + 'extended-join' in bot.enabled_capabilities) or + 'away-notify' in bot.enabled_capabilities) + + +def _send_who(bot, channel): + if _whox_enabled(bot): + # WHOX syntax, see http://faerion.sourceforge.net/doc/irc/whox.var + # Needed for accounts in who replies. The random integer is a param + # to identify the reply as one from this command, because if someone + # else sent it, we have no fucking way to know what the format is. + rand = str(randint(0, 999)) + while rand in who_reqs: + rand = str(randint(0, 999)) + who_reqs[rand] = channel + bot.write(['WHO', channel, 'a%nuachtf,' + rand]) + else: + # We might be on an old network, but we still care about keeping our + # user list updated + bot.write(['WHO', channel]) + + +@module.rule('.*') +@module.event('JOIN') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_join(bot, trigger): + if trigger.nick == bot.nick and trigger.sender not in bot.channels: + bot.write(('TOPIC', trigger.sender)) + + bot.privileges[trigger.sender] = dict() + bot.channels[trigger.sender] = Channel(trigger.sender) + _send_who(bot, trigger.sender) + + bot.privileges[trigger.sender][trigger.nick] = 0 + + user = bot.users.get(trigger.nick) + if user is None: + user = User(trigger.nick, trigger.user, trigger.host) + bot.users[trigger.nick] = user + bot.channels[trigger.sender].add_user(user) + + if len(trigger.args) > 1 and trigger.args[1] != '*' and ( + 'account-notify' in bot.enabled_capabilities and + 'extended-join' in bot.enabled_capabilities): + user.account = trigger.args[1] + + +@module.rule('.*') +@module.event('QUIT') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_quit(bot, trigger): + for chanprivs in bot.privileges.values(): + chanprivs.pop(trigger.nick, None) + for channel in bot.channels.values(): + channel.clear_user(trigger.nick) + bot.users.pop(trigger.nick, None) + + +@module.rule('.*') +@module.event('CAP') +@module.thread(False) +@module.priority('high') +@module.unblockable +def recieve_cap_list(bot, trigger): + cap = trigger.strip('-=~') + # Server is listing capabilites + if trigger.args[1] == 'LS': + recieve_cap_ls_reply(bot, trigger) + # Server denied CAP REQ + elif trigger.args[1] == 'NAK': + entry = bot._cap_reqs.get(cap, None) + # If it was requested with bot.cap_req + if entry: + for req in entry: + # And that request was mandatory/prohibit, and a callback was + # provided + if req.prefix and req.failure: + # Call it. + req.failure(bot, req.prefix + cap) + # Server is removing a capability + elif trigger.args[1] == 'DEL': + entry = bot._cap_reqs.get(cap, None) + # If it was requested with bot.cap_req + if entry: + for req in entry: + # And that request wasn't prohibit, and a callback was + # provided + if req.prefix != '-' and req.failure: + # Call it. + req.failure(bot, req.prefix + cap) + # Server is adding new capability + elif trigger.args[1] == 'NEW': + entry = bot._cap_reqs.get(cap, None) + # If it was requested with bot.cap_req + if entry: + for req in entry: + # And that request wasn't prohibit + if req.prefix != '-': + # Request it + bot.write(('CAP', 'REQ', req.prefix + cap)) + # Server is acknowledging a capability + elif trigger.args[1] == 'ACK': + caps = trigger.args[2].split() + for cap in caps: + cap.strip('-~= ') + bot.enabled_capabilities.add(cap) + entry = bot._cap_reqs.get(cap, []) + for req in entry: + if req.success: + req.success(bot, req.prefix + trigger) + if cap == 'sasl': # TODO why is this not done with bot.cap_req? + recieve_cap_ack_sasl(bot) + + +def recieve_cap_ls_reply(bot, trigger): + if bot.server_capabilities: + # We've already seen the results, so someone sent CAP LS from a module. + # We're too late to do SASL, and we don't want to send CAP END before + # the module has done what it needs to, so just return + return + + for cap in trigger.split(): + c = cap.split('=') + if len(c) == 2: + batched_caps[c[0]] = c[1] + else: + batched_caps[c[0]] = None + + # Not the last in a multi-line reply. First two args are * and LS. + if trigger.args[2] == '*': + return + + bot.server_capabilities = batched_caps + + # If some other module requests it, we don't need to add another request. + # If some other module prohibits it, we shouldn't request it. + core_caps = ['multi-prefix', 'away-notify', 'cap-notify', 'server-time'] + for cap in core_caps: + if cap not in bot._cap_reqs: + bot._cap_reqs[cap] = [_CapReq('', 'coretasks')] + + def acct_warn(bot, cap): + LOGGER.info('Server does not support %s, or it conflicts with a custom ' + 'module. User account validation unavailable or limited.', + cap[1:]) + if bot.config.core.owner_account or bot.config.core.admin_accounts: + LOGGER.warning( + 'Owner or admin accounts are configured, but %s is not ' + 'supported by the server. This may cause unexpected behavior.', + cap[1:]) + auth_caps = ['account-notify', 'extended-join', 'account-tag'] + for cap in auth_caps: + if cap not in bot._cap_reqs: + bot._cap_reqs[cap] = [_CapReq('=', 'coretasks', acct_warn)] + + for cap, reqs in iteritems(bot._cap_reqs): + # At this point, we know mandatory and prohibited don't co-exist, but + # we need to call back for optionals if they're also prohibited + prefix = '' + for entry in reqs: + if prefix == '-' and entry.prefix != '-': + entry.failure(bot, entry.prefix + cap) + continue + if entry.prefix: + prefix = entry.prefix + + # It's not required, or it's supported, so we can request it + if prefix != '=' or cap in bot.server_capabilities: + # REQs fail as a whole, so we send them one capability at a time + bot.write(('CAP', 'REQ', entry.prefix + cap)) + # If it's required but not in server caps, we need to call all the + # callbacks + else: + for entry in reqs: + if entry.failure and entry.prefix == '=': + entry.failure(bot, entry.prefix + cap) + + # If we want to do SASL, we have to wait before we can send CAP END. So if + # we are, wait on 903 (SASL successful) to send it. + if bot.config.core.auth_method == 'sasl': + bot.write(('CAP', 'REQ', 'sasl')) + else: + bot.write(('CAP', 'END')) + + +def recieve_cap_ack_sasl(bot): + # Presumably we're only here if we said we actually *want* sasl, but still + # check anyway. + password = bot.config.core.auth_password + if not password: + return + mech = bot.config.core.auth_target or 'PLAIN' + bot.write(('AUTHENTICATE', mech)) + + +@module.event('AUTHENTICATE') +@module.rule('.*') +def auth_proceed(bot, trigger): + if trigger.args[0] != '+': + # How did we get here? I am not good with computer. + return + # Is this right? + sasl_username = bot.config.core.auth_username or bot.nick + sasl_password = bot.config.core.auth_password + sasl_token = '\0'.join((sasl_username, sasl_username, sasl_password)) + # Spec says we do a base 64 encode on the SASL stuff + bot.write(('AUTHENTICATE', base64.b64encode(sasl_token.encode('utf-8')))) + + +@module.event(events.RPL_SASLSUCCESS) +@module.rule('.*') +def sasl_success(bot, trigger): + bot.write(('CAP', 'END')) + + +#Live blocklist editing + + +@module.commands('blocks') +@module.priority('low') +@module.thread(False) +@module.unblockable +def blocks(bot, trigger): + """Manage Sopel's blocking features. + + https://github.com/sopel-irc/sopel/wiki/Making-Sopel-ignore-people + + """ + if not trigger.admin: + return + + STRINGS = { + "success_del": "Successfully deleted block: %s", + "success_add": "Successfully added block: %s", + "no_nick": "No matching nick block found for: %s", + "no_host": "No matching hostmask block found for: %s", + "invalid": "Invalid format for %s a block. Try: .blocks add (nick|hostmask) sopel", + "invalid_display": "Invalid input for displaying blocks.", + "nonelisted": "No %s listed in the blocklist.", + 'huh': "I could not figure out what you wanted to do.", + } + + masks = set(s for s in bot.config.core.host_blocks if s != '') + nicks = set(Identifier(nick) + for nick in bot.config.core.nick_blocks + if nick != '') + text = trigger.group().split() + + if len(text) == 3 and text[1] == "list": + if text[2] == "hostmask": + if len(masks) > 0: + blocked = ', '.join(unicode(mask) for mask in masks) + bot.say("Blocked hostmasks: {}".format(blocked)) + else: + bot.reply(STRINGS['nonelisted'] % ('hostmasks')) + elif text[2] == "nick": + if len(nicks) > 0: + blocked = ', '.join(unicode(nick) for nick in nicks) + bot.say("Blocked nicks: {}".format(blocked)) + else: + bot.reply(STRINGS['nonelisted'] % ('nicks')) + else: + bot.reply(STRINGS['invalid_display']) + + elif len(text) == 4 and text[1] == "add": + if text[2] == "nick": + nicks.add(text[3]) + bot.config.core.nick_blocks = nicks + bot.config.save() + elif text[2] == "hostmask": + masks.add(text[3].lower()) + bot.config.core.host_blocks = list(masks) + else: + bot.reply(STRINGS['invalid'] % ("adding")) + return + + bot.reply(STRINGS['success_add'] % (text[3])) + + elif len(text) == 4 and text[1] == "del": + if text[2] == "nick": + if Identifier(text[3]) not in nicks: + bot.reply(STRINGS['no_nick'] % (text[3])) + return + nicks.remove(Identifier(text[3])) + bot.config.core.nick_blocks = [unicode(n) for n in nicks] + bot.config.save() + bot.reply(STRINGS['success_del'] % (text[3])) + elif text[2] == "hostmask": + mask = text[3].lower() + if mask not in masks: + bot.reply(STRINGS['no_host'] % (text[3])) + return + masks.remove(mask) + bot.config.core.host_blocks = [unicode(m) for m in masks] + bot.config.save() + bot.reply(STRINGS['success_del'] % (text[3])) + else: + bot.reply(STRINGS['invalid'] % ("deleting")) + return + else: + bot.reply(STRINGS['huh']) + + +@module.event('ACCOUNT') +@module.rule('.*') +def account_notify(bot, trigger): + if trigger.nick not in bot.users: + bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) + account = trigger.args[0] + if account == '*': + account = None + bot.users[trigger.nick].account = account + + +@module.event(events.RPL_WHOSPCRPL) +@module.rule('.*') +@module.priority('high') +@module.unblockable +def recv_whox(bot, trigger): + if len(trigger.args) < 2 or trigger.args[1] not in who_reqs: + # Ignored, some module probably called WHO + return + if len(trigger.args) != 8: + return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.') + _, _, channel, user, host, nick, status, account = trigger.args + away = 'G' in status + _record_who(bot, channel, user, host, nick, account, away) + + +def _record_who(bot, channel, user, host, nick, account=None, away=None): + nick = Identifier(nick) + channel = Identifier(channel) + if nick not in bot.users: + bot.users[nick] = User(nick, user, host) + user = bot.users[nick] + if account == '0': + user.account = None + else: + user.account = account + user.away = away + if channel not in bot.channels: + bot.channels[channel] = Channel(channel) + bot.channels[channel].add_user(user) + + +@module.event(events.RPL_WHOREPLY) +@module.rule('.*') +@module.priority('high') +@module.unblockable +def recv_who(bot, trigger): + channel, user, host, _, nick, = trigger.args[1:6] + _record_who(bot, channel, user, host, nick) + + +@module.event(events.RPL_ENDOFWHO) +@module.rule('.*') +@module.priority('high') +@module.unblockable +def end_who(bot, trigger): + if _whox_enabled(bot): + who_reqs.pop(trigger.args[1], None) + + +@module.rule('.*') +@module.event('AWAY') +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_notify(bot, trigger): + if trigger.nick not in bot.users: + bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) + user = bot.users[trigger.nick] + user.away = bool(trigger.args) + + +@module.rule('.*') +@module.event('TOPIC') +@module.event(events.RPL_TOPIC) +@module.priority('high') +@module.thread(False) +@module.unblockable +def track_topic(bot, trigger): + if trigger.event != 'TOPIC': + channel = trigger.args[1] + else: + channel = trigger.args[0] + if channel not in bot.channels: + return + bot.channels[channel].topic = trigger.args[-1] diff --git a/db.py b/db.py new file mode 100755 index 0000000..a7c0afb --- /dev/null +++ b/db.py @@ -0,0 +1,247 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import json +import os.path +import sys +import sqlite3 + +from tools import Identifier + +if sys.version_info.major >= 3: + unicode = str + basestring = str + + +def _deserialize(value): + if value is None: + return None + # sqlite likes to return ints for strings that look like ints, even though + # the column type is string. That's how you do dynamic typing wrong. + value = unicode(value) + # Just in case someone's mucking with the DB in a way we can't account for, + # ignore json parsing errors + try: + value = json.loads(value) + except: + pass + return value + + +class SopelDB(object): + """*Availability: 5.0+* + + This defines an interface for basic, common operations on a sqlite + database. It simplifies those common operations, and allows direct access + to the database, wherever the user has configured it to be. + + When configured with a relative filename, it is assumed to be in the same + directory as the config.""" + + def __init__(self, config): + path = config.core.db_filename + config_dir, config_file = os.path.split(config.filename) + config_name, _ = os.path.splitext(config_file) + if path is None: + path = os.path.join(config_dir, config_name + '.db') + path = os.path.expanduser(path) + if not os.path.isabs(path): + path = os.path.normpath(os.path.join(config_dir, path)) + self.filename = path + self._create() + + def connect(self): + """Return a raw database connection object.""" + return sqlite3.connect(self.filename, timeout=10) + + def execute(self, *args, **kwargs): + """Execute an arbitrary SQL query against the database. + + Returns a cursor object, on which things like `.fetchall()` can be + called per PEP 249.""" + with self.connect() as conn: + cur = conn.cursor() + return cur.execute(*args, **kwargs) + + def _create(self): + """Create the basic database structure.""" + # Do nothing if the db already exists. + try: + self.execute('SELECT * FROM nick_ids;') + self.execute('SELECT * FROM nicknames;') + self.execute('SELECT * FROM nick_values;') + self.execute('SELECT * FROM channel_values;') + except: + pass + else: + return + + self.execute( + 'CREATE TABLE nick_ids (nick_id INTEGER PRIMARY KEY AUTOINCREMENT)' + ) + self.execute( + 'CREATE TABLE nicknames ' + '(nick_id INTEGER REFERENCES nick_ids, ' + 'slug STRING PRIMARY KEY, canonical string)' + ) + self.execute( + 'CREATE TABLE nick_values ' + '(nick_id INTEGER REFERENCES nick_ids(nick_id), ' + 'key STRING, value STRING, ' + 'PRIMARY KEY (nick_id, key))' + ) + self.execute( + 'CREATE TABLE channel_values ' + '(channel STRING, key STRING, value STRING, ' + 'PRIMARY KEY (channel, key))' + ) + + def get_uri(self): + """Returns a URL for the database, usable to connect with SQLAlchemy. + """ + return 'sqlite://{}'.format(self.filename) + + # NICK FUNCTIONS + + def get_nick_id(self, nick, create=True): + """Return the internal identifier for a given nick. + + This identifier is unique to a user, and shared across all of that + user's aliases. If create is True, a new ID will be created if one does + not already exist""" + slug = nick.lower() + nick_id = self.execute('SELECT nick_id from nicknames where slug = ?', + [slug]).fetchone() + if nick_id is None: + if not create: + raise ValueError('No ID exists for the given nick') + with self.connect() as conn: + cur = conn.cursor() + cur.execute('INSERT INTO nick_ids VALUES (NULL)') + nick_id = cur.execute('SELECT last_insert_rowid()').fetchone()[0] + cur.execute( + 'INSERT INTO nicknames (nick_id, slug, canonical) VALUES ' + '(?, ?, ?)', + [nick_id, slug, nick] + ) + nick_id = self.execute('SELECT nick_id from nicknames where slug = ?', + [slug]).fetchone() + return nick_id[0] + + def alias_nick(self, nick, alias): + """Create an alias for a nick. + + Raises ValueError if the alias already exists. If nick does not already + exist, it will be added along with the alias.""" + nick = Identifier(nick) + alias = Identifier(alias) + nick_id = self.get_nick_id(nick) + sql = 'INSERT INTO nicknames (nick_id, slug, canonical) VALUES (?, ?, ?)' + values = [nick_id, alias.lower(), alias] + try: + self.execute(sql, values) + except sqlite3.IntegrityError: + raise ValueError('Alias already exists.') + + def set_nick_value(self, nick, key, value): + """Sets the value for a given key to be associated with the nick.""" + nick = Identifier(nick) + value = json.dumps(value, ensure_ascii=False) + nick_id = self.get_nick_id(nick) + self.execute('INSERT OR REPLACE INTO nick_values VALUES (?, ?, ?)', + [nick_id, key, value]) + + def get_nick_value(self, nick, key): + """Retrieves the value for a given key associated with a nick.""" + nick = Identifier(nick) + result = self.execute( + 'SELECT value FROM nicknames JOIN nick_values ' + 'ON nicknames.nick_id = nick_values.nick_id ' + 'WHERE slug = ? AND key = ?', + [nick.lower(), key] + ).fetchone() + if result is not None: + result = result[0] + return _deserialize(result) + + def unalias_nick(self, alias): + """Removes an alias. + + Raises ValueError if there is not at least one other nick in the group. + To delete an entire group, use `delete_group`. + """ + alias = Identifier(alias) + nick_id = self.get_nick_id(alias, False) + count = self.execute('SELECT COUNT(*) FROM nicknames WHERE nick_id = ?', + [nick_id]).fetchone()[0] + if count <= 1: + raise ValueError('Given alias is the only entry in its group.') + self.execute('DELETE FROM nicknames WHERE slug = ?', [alias.lower()]) + + def delete_nick_group(self, nick): + """Removes a nickname, and all associated aliases and settings. + """ + nick = Identifier(nick) + nick_id = self.get_nick_id(nick, False) + self.execute('DELETE FROM nicknames WHERE nick_id = ?', [nick_id]) + self.execute('DELETE FROM nick_values WHERE nick_id = ?', [nick_id]) + + def merge_nick_groups(self, first_nick, second_nick): + """Merges the nick groups for the specified nicks. + + Takes two nicks, which may or may not be registered. Unregistered + nicks will be registered. Keys which are set for only one of the given + nicks will be preserved. Where multiple nicks have values for a given + key, the value set for the first nick will be used. + + Note that merging of data only applies to the native key-value store. + If modules define their own tables which rely on the nick table, they + will need to have their merging done separately.""" + first_id = self.get_nick_id(Identifier(first_nick)) + second_id = self.get_nick_id(Identifier(second_nick)) + self.execute( + 'UPDATE OR IGNORE nick_values SET nick_id = ? WHERE nick_id = ?', + [first_id, second_id]) + self.execute('DELETE FROM nick_values WHERE nick_id = ?', [second_id]) + self.execute('UPDATE nicknames SET nick_id = ? WHERE nick_id = ?', + [first_id, second_id]) + + # CHANNEL FUNCTIONS + + def set_channel_value(self, channel, key, value): + channel = Identifier(channel).lower() + value = json.dumps(value, ensure_ascii=False) + self.execute('INSERT OR REPLACE INTO channel_values VALUES (?, ?, ?)', + [channel, key, value]) + + def get_channel_value(self, channel, key): + """Retrieves the value for a given key associated with a channel.""" + channel = Identifier(channel).lower() + result = self.execute( + 'SELECT value FROM channel_values WHERE channel = ? AND key = ?', + [channel, key] + ).fetchone() + if result is not None: + result = result[0] + return _deserialize(result) + + # NICK AND CHANNEL FUNCTIONS + + def get_nick_or_channel_value(self, name, key): + """Gets the value `key` associated to the nick or channel `name`. + """ + name = Identifier(name) + if name.is_nick(): + return self.get_nick_value(name, key) + else: + return self.get_channel_value(name, key) + + def get_preferred_value(self, names, key): + """Gets the value for the first name which has it set. + + `names` is a list of channel and/or user names. Returns None if none of + the names have the key set.""" + for name in names: + value = self.get_nick_or_channel_value(name, key) + if value is not None: + return value diff --git a/formatting.py b/formatting.py new file mode 100755 index 0000000..b1733ea --- /dev/null +++ b/formatting.py @@ -0,0 +1,107 @@ +# coding=utf-8 +"""The formatting module includes functions to apply IRC formatting to text. + +*Availability: 4.5+* +""" +# Copyright 2014, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division +import sys +if sys.version_info.major >= 3: + unicode = str + +# Color names are as specified at http://www.mirc.com/colors.html + +CONTROL_NORMAL = '\x0f' +"""The control code to reset formatting""" +CONTROL_COLOR = '\x03' +"""The control code to start or end color formatting""" +CONTROL_UNDERLINE = '\x1f' +"""The control code to start or end underlining""" +CONTROL_BOLD = '\x02' +"""The control code to start or end bold formatting""" + + +# TODO when we can move to 3.3+ completely, make this an Enum. +class colors: + WHITE = '00' + BLACK = '01' + BLUE = '02' + NAVY = BLUE + GREEN = '03' + RED = '04' + BROWN = '05' + MAROON = BROWN + PURPLE = '06' + ORANGE = '07' + OLIVE = ORANGE + YELLOW = '08' + LIGHT_GREEN = '09' + LIME = LIGHT_GREEN + TEAL = '10' + LIGHT_CYAN = '11' + CYAN = LIGHT_CYAN + LIGHT_BLUE = '12' + ROYAL = LIGHT_BLUE + PINK = '13' + LIGHT_PURPLE = PINK + FUCHSIA = PINK + GREY = '14' + LIGHT_GREY = '15' + SILVER = LIGHT_GREY + + #Create aliases. + GRAY = GREY + LIGHT_GRAY = LIGHT_GREY + + +def _get_color(color): + if color is None: + return None + + # You can pass an int or string of the code + try: + color = int(color) + except ValueError: + pass + if isinstance(color, int): + if color > 99: + raise ValueError('Can not specify a color above 99.') + return unicode(color).rjust(2, '0') + + # You can also pass the name of the color + color_name = color.upper() + color_dict = colors.__dict__ + try: + return color_dict[color_name] + except KeyError: + raise ValueError('Unknown color name {}'.format(color)) + + +def color(text, fg=None, bg=None): + """Return the text, with the given colors applied in IRC formatting. + + The color can be a string of the color name, or an integer between 0 and + 99. The known color names can be found in the `colors` class of this + module.""" + if not fg and not bg: + return text + + fg = _get_color(fg) + bg = _get_color(bg) + + if not bg: + text = ''.join([CONTROL_COLOR, fg, text, CONTROL_COLOR]) + else: + text = ''.join([CONTROL_COLOR, fg, ',', bg, text, CONTROL_COLOR]) + return text + + +def bold(text): + """Return the text, with bold IRC formatting.""" + return ''.join([CONTROL_BOLD, text, CONTROL_BOLD]) + + +def underline(text): + """Return the text, with underline IRC formatting.""" + return ''.join([CONTROL_UNDERLINE, text, CONTROL_UNDERLINE]) diff --git a/irc.py b/irc.py new file mode 100755 index 0000000..631439d --- /dev/null +++ b/irc.py @@ -0,0 +1,411 @@ +# coding=utf-8 +# irc.py - An Utility IRC Bot +# Copyright 2008, Sean B. Palmer, inamidst.com +# Copyright 2012, Elsie Powell, http://embolalia.com +# Copyright © 2012, Elad Alfassa +# +# Licensed under the Eiffel Forum License 2. +# +# When working on core IRC protocol related features, consult protocol +# documentation at http://www.irchelp.org/irchelp/rfc/ +from __future__ import unicode_literals, absolute_import, print_function, division + +import sys +import time +import socket +import asyncore +import asynchat +import os +import codecs +import traceback +from logger import get_logger +from tools import stderr, Identifier +from trigger import PreTrigger +try: + import ssl + if not hasattr(ssl, 'match_hostname'): + # Attempt to import ssl_match_hostname from python-backports + import backports.ssl_match_hostname + ssl.match_hostname = backports.ssl_match_hostname.match_hostname + ssl.CertificateError = backports.ssl_match_hostname.CertificateError + has_ssl = True +except ImportError: + # no SSL support + has_ssl = False + +import errno +import threading +from datetime import datetime +if sys.version_info.major >= 3: + unicode = str + +LOGGER = get_logger(__name__) + + +class Bot(asynchat.async_chat): + def __init__(self, config): + ca_certs = config.core.ca_certs + + asynchat.async_chat.__init__(self) + self.set_terminator(b'\n') + self.buffer = '' + + self.nick = Identifier(config.core.nick) + """Sopel's current ``Identifier``. Changing this while Sopel is running is + untested.""" + self.user = config.core.user + """Sopel's user/ident.""" + self.name = config.core.name + """Sopel's "real name", as used for whois.""" + + self.stack = {} + self.ca_certs = ca_certs + self.enabled_capabilities = set() + self.hasquit = False + + self.sending = threading.RLock() + self.writing_lock = threading.Lock() + self.raw = None + + # Right now, only accounting for two op levels. + # This might be expanded later. + # These lists are filled in startup.py, as of right now. + # Are these even touched at all anymore? Remove in 7.0. + self.ops = dict() + """Deprecated. Use bot.channels instead.""" + self.halfplus = dict() + """Deprecated. Use bot.channels instead.""" + self.voices = dict() + """Deprecated. Use bot.channels instead.""" + + # We need this to prevent error loops in handle_error + self.error_count = 0 + + self.connection_registered = False + """ Set to True when a server has accepted the client connection and + messages can be sent and received. """ + + # Work around bot.connecting missing in Python older than 2.7.4 + if not hasattr(self, "connecting"): + self.connecting = False + + def log_raw(self, line, prefix): + """Log raw line to the raw log.""" + if not self.config.core.log_raw: + return + if not os.path.isdir(self.config.core.logdir): + try: + os.mkdir(self.config.core.logdir) + except Exception as e: + stderr('There was a problem creating the logs directory.') + stderr('%s %s' % (str(e.__class__), str(e))) + stderr('Please fix this and then run Sopel again.') + os._exit(1) + f = codecs.open(os.path.join(self.config.core.logdir, 'raw.log'), + 'a', encoding='utf-8') + f.write(prefix + unicode(time.time()) + "\t") + temp = line.replace('\n', '') + + f.write(temp) + f.write("\n") + f.close() + + def safe(self, string): + """Remove newlines from a string.""" + if sys.version_info.major >= 3 and isinstance(string, bytes): + string = string.decode('utf8') + elif sys.version_info.major < 3: + if not isinstance(string, unicode): + string = unicode(string, encoding='utf8') + string = string.replace('\n', '') + string = string.replace('\r', '') + return string + + def write(self, args, text=None): + args = [self.safe(arg) for arg in args] + if text is not None: + text = self.safe(text) + try: + self.writing_lock.acquire() # Blocking lock, can't send two things + # at a time + + # From RFC2812 Internet Relay Chat: Client Protocol + # Section 2.3 + # + # https://tools.ietf.org/html/rfc2812.html + # + # IRC messages are always lines of characters terminated with a + # CR-LF (Carriage Return - Line Feed) pair, and these messages SHALL + # NOT exceed 512 characters in length, counting all characters + # including the trailing CR-LF. Thus, there are 510 characters + # maximum allowed for the command and its parameters. There is no + # provision for continuation of message lines. + + if text is not None: + temp = (' '.join(args) + ' :' + text)[:510] + '\r\n' + else: + temp = ' '.join(args)[:510] + '\r\n' + self.log_raw(temp, '>>') + self.send(temp.encode('utf-8')) + finally: + self.writing_lock.release() + + def run(self, host, port=6667): + try: + self.initiate_connect(host, port) + except socket.error as e: + stderr('Connection error: %s' % e) + + def initiate_connect(self, host, port): + stderr('Connecting to %s:%s...' % (host, port)) + source_address = ((self.config.core.bind_host, 0) + if self.config.core.bind_host else None) + self.set_socket(socket.create_connection((host, port), + source_address=source_address)) + if self.config.core.use_ssl and has_ssl: + self.send = self._ssl_send + self.recv = self._ssl_recv + elif not has_ssl and self.config.core.use_ssl: + stderr('SSL is not avilable on your system, attempting connection ' + 'without it') + self.connect((host, port)) + try: + asyncore.loop() + except KeyboardInterrupt: + print('KeyboardInterrupt') + self.quit('KeyboardInterrupt') + + def quit(self, message): + """Disconnect from IRC and close the bot.""" + self.write(['QUIT'], message) + self.hasquit = True + # Wait for acknowledgement from the server. By RFC 2812 it should be + # an ERROR msg, but many servers just close the connection. Either way + # is fine by us. + # Closing the connection now would mean that stuff in the buffers that + # has not yet been processed would never be processed. It would also + # release the main thread, which is problematic because whomever called + # quit might still want to do something before main thread quits. + + def handle_close(self): + self.connection_registered = False + + if hasattr(self, '_shutdown'): + self._shutdown() + stderr('Closed!') + + # This will eventually call asyncore dispatchers close method, which + # will release the main thread. This should be called last to avoid + # race conditions. + self.close() + + def handle_connect(self): + if self.config.core.use_ssl and has_ssl: + if not self.config.core.verify_ssl: + self.ssl = ssl.wrap_socket(self.socket, + do_handshake_on_connect=True, + suppress_ragged_eofs=True) + else: + self.ssl = ssl.wrap_socket(self.socket, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + cert_reqs=ssl.CERT_REQUIRED, + ca_certs=self.ca_certs) + try: + ssl.match_hostname(self.ssl.getpeercert(), self.config.core.host) + except ssl.CertificateError: + stderr("Invalid certficate, hostname mismatch!") + os.unlink(self.config.core.pid_file_path) + os._exit(1) + self.set_socket(self.ssl) + + # Request list of server capabilities. IRCv3 servers will respond with + # CAP * LS (which we handle in coretasks). v2 servers will respond with + # 421 Unknown command, which we'll ignore + self.write(('CAP', 'LS', '302')) + + if self.config.core.auth_method == 'server': + password = self.config.core.auth_password + self.write(('PASS', password)) + self.write(('NICK', self.nick)) + self.write(('USER', self.user, '+iw', self.nick), self.name) + + stderr('Connected.') + self.last_ping_time = datetime.now() + timeout_check_thread = threading.Thread(target=self._timeout_check) + timeout_check_thread.daemon = True + timeout_check_thread.start() + ping_thread = threading.Thread(target=self._send_ping) + ping_thread.daemon = True + ping_thread.start() + + def _timeout_check(self): + while self.connected or self.connecting: + if (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout): + stderr('Ping timeout reached after %s seconds, closing connection' % self.config.core.timeout) + self.handle_close() + break + else: + time.sleep(int(self.config.core.timeout)) + + def _send_ping(self): + while self.connected or self.connecting: + if self.connected and (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout) / 2: + try: + self.write(('PING', self.config.core.host)) + except socket.error: + pass + time.sleep(int(self.config.core.timeout) / 2) + + def _ssl_send(self, data): + """Replacement for self.send() during SSL connections.""" + try: + result = self.socket.send(data) + return result + except ssl.SSLError as why: + if why[0] in (asyncore.EWOULDBLOCK, errno.ESRCH): + return 0 + else: + raise why + return 0 + + def _ssl_recv(self, buffer_size): + """Replacement for self.recv() during SSL connections. + + From: http://evanfosmark.com/2010/09/ssl-support-in-asynchatasync_chat + + """ + try: + data = self.socket.read(buffer_size) + if not data: + self.handle_close() + return b'' + return data + except ssl.SSLError as why: + if why[0] in (asyncore.ECONNRESET, asyncore.ENOTCONN, + asyncore.ESHUTDOWN): + self.handle_close() + return '' + elif why[0] == errno.ENOENT: + # Required in order to keep it non-blocking + return b'' + else: + raise + + def collect_incoming_data(self, data): + # We can't trust clients to pass valid unicode. + try: + data = unicode(data, encoding='utf-8') + except UnicodeDecodeError: + # not unicode, let's try cp1252 + try: + data = unicode(data, encoding='cp1252') + except UnicodeDecodeError: + # Okay, let's try ISO8859-1 + try: + data = unicode(data, encoding='iso8859-1') + except: + # Discard line if encoding is unknown + return + + if data: + self.log_raw(data, '<<') + self.buffer += data + + def found_terminator(self): + line = self.buffer + if line.endswith('\r'): + line = line[:-1] + self.buffer = '' + self.last_ping_time = datetime.now() + pretrigger = PreTrigger(self.nick, line) + if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']): + pretrigger.tags.pop('account', None) + + if pretrigger.event == 'PING': + self.write(('PONG', pretrigger.args[-1])) + elif pretrigger.event == 'ERROR': + LOGGER.error("ERROR recieved from server: %s", pretrigger.args[-1]) + if self.hasquit: + self.close_when_done() + elif pretrigger.event == '433': + stderr('Nickname already in use!') + self.handle_close() + + self.dispatch(pretrigger) + + def dispatch(self, pretrigger): + pass + + def error(self, trigger=None): + """Called internally when a module causes an error.""" + try: + trace = traceback.format_exc() + if sys.version_info.major < 3: + trace = trace.decode('utf-8', errors='xmlcharrefreplace') + stderr(trace) + try: + lines = list(reversed(trace.splitlines())) + report = [lines[0].strip()] + for line in lines: + line = line.strip() + if line.startswith('File "'): + report.append(line[0].lower() + line[1:]) + break + else: + report.append('source unknown') + + signature = '%s (%s)' % (report[0], report[1]) + # TODO: make not hardcoded + log_filename = os.path.join(self.config.core.logdir, 'exceptions.log') + with codecs.open(log_filename, 'a', encoding='utf-8') as logfile: + logfile.write('Signature: %s\n' % signature) + if trigger: + logfile.write('from {} at {}. Message was: {}\n'.format( + trigger.nick, str(datetime.now()), trigger.group(0))) + logfile.write(trace) + logfile.write( + '----------------------------------------\n\n' + ) + except Exception as e: + stderr("Could not save full traceback!") + LOGGER.error("Could not save traceback from %s to file: %s", trigger.sender, str(e)) + + if trigger and self.config.core.reply_errors and trigger.sender is not None: + self.msg(trigger.sender, signature) + if trigger: + LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(signature), trigger.raw)) + except Exception as e: + if trigger and self.config.core.reply_errors and trigger.sender is not None: + self.msg(trigger.sender, "Got an error.") + if trigger: + LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(e), trigger.raw)) + + def handle_error(self): + """Handle any uncaptured error in the core. + + Overrides asyncore's handle_error. + + """ + trace = traceback.format_exc() + stderr(trace) + LOGGER.error('Fatal error in core, please review exception log') + # TODO: make not hardcoded + logfile = codecs.open( + os.path.join(self.config.core.logdir, 'exceptions.log'), + 'a', + encoding='utf-8' + ) + logfile.write('Fatal error in core, handle_error() was called\n') + logfile.write('last raw line was %s' % self.raw) + logfile.write(trace) + logfile.write('Buffer:\n') + logfile.write(self.buffer) + logfile.write('----------------------------------------\n\n') + logfile.close() + if self.error_count > 10: + if (datetime.now() - self.last_error_timestamp).seconds < 5: + stderr("Too many errors, can't continue") + os._exit(1) + self.last_error_timestamp = datetime.now() + self.error_count = self.error_count + 1 diff --git a/loader.py b/loader.py new file mode 100755 index 0000000..0b7a678 --- /dev/null +++ b/loader.py @@ -0,0 +1,227 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import imp +import os.path +import re +import sys + +from tools import itervalues, get_command_regexp + +if sys.version_info.major >= 3: + basestring = (str, bytes) + +# Can be implementation-dependent +_regex_type = type(re.compile('')) + + +def get_module_description(path): + good_file = (os.path.isfile(path) and path.endswith('.py') + and not path.startswith('_')) + good_dir = (os.path.isdir(path) and + os.path.isfile(os.path.join(path, '__init__.py'))) + if good_file: + name = os.path.basename(path)[:-3] + return (name, path, imp.PY_SOURCE) + elif good_dir: + name = os.path.basename(path) + return (name, path, imp.PKG_DIRECTORY) + else: + return None + + +def _update_modules_from_dir(modules, directory): + # Note that this modifies modules in place + for path in os.listdir(directory): + path = os.path.join(directory, path) + result = get_module_description(path) + if result: + modules[result[0]] = result[1:] + + +def enumerate_modules(config, show_all=False): + """Map the names of modules to the location of their file. + + Return a dict mapping the names of modules to a tuple of the module name, + the pathname and either `imp.PY_SOURCE` or `imp.PKG_DIRECTORY`. This + searches the regular modules directory and all directories specified in the + `core.extra` attribute of the `config` object. If two modules have the same + name, the last one to be found will be returned and the rest will be + ignored. Modules are found starting in the regular directory, followed by + `~/.sopel/modules`, and then through the extra directories in the order + that the are specified. + + If `show_all` is given as `True`, the `enable` and `exclude` + configuration options will be ignored, and all modules will be shown + (though duplicates will still be ignored as above). + """ + modules = {} + + # First, add modules from the regular modules directory + main_dir = os.path.dirname(os.path.abspath(__file__)) + modules_dir = os.path.join(main_dir, 'modules') + _update_modules_from_dir(modules, modules_dir) + for path in os.listdir(modules_dir): + break + + # Then, find PyPI installed modules + # TODO does this work with all possible install mechanisms? + try: + import sopel_modules + except: + pass + else: + for directory in sopel_modules.__path__: + _update_modules_from_dir(modules, directory) + + # Next, look in ~/.sopel/modules + home_modules_dir = os.path.join(config.homedir, 'modules') + if not os.path.isdir(home_modules_dir): + os.makedirs(home_modules_dir) + _update_modules_from_dir(modules, home_modules_dir) + + # Last, look at all the extra directories. + for directory in config.core.extra: + _update_modules_from_dir(modules, directory) + + # Coretasks is special. No custom user coretasks. + ct_path = os.path.join(main_dir, 'coretasks.py') + modules['coretasks'] = (ct_path, imp.PY_SOURCE) + + # If caller wants all of them, don't apply white and blacklists + if show_all: + return modules + + # Apply whitelist, if present + enable = config.core.enable + if enable: + enabled_modules = {'coretasks': modules['coretasks']} + for module in enable: + if module in modules: + enabled_modules[module] = modules[module] + modules = enabled_modules + + # Apply blacklist, if present + exclude = config.core.exclude + for module in exclude: + if module in modules: + del modules[module] + + return modules + + +def compile_rule(nick, pattern): + # Not sure why this happens on reloads, but it shouldn't cause problems… + if isinstance(pattern, _regex_type): + return pattern + + nick = re.escape(nick) + pattern = pattern.replace('$nickname', nick) + pattern = pattern.replace('$nick', r'{}[,:]\s+'.format(nick)) + flags = re.IGNORECASE + if '\n' in pattern: + flags |= re.VERBOSE + return re.compile(pattern, flags) + + +def trim_docstring(doc): + """Get the docstring as a series of lines that can be sent""" + if not doc: + return [] + lines = doc.expandtabs().splitlines() + indent = sys.maxsize + for line in lines[1:]: + stripped = line.lstrip() + if stripped: + indent = min(indent, len(line) - len(stripped)) + trimmed = [lines[0].strip()] + if indent < sys.maxsize: + for line in lines[1:]: + trimmed.append(line[:].rstrip()) + while trimmed and not trimmed[-1]: + trimmed.pop() + while trimmed and not trimmed[0]: + trimmed.pop(0) + return trimmed + + +def clean_callable(func, config): + """Compiles the regexes, moves commands into func.rule, fixes up docs and + puts them in func._docs, and sets defaults""" + nick = config.core.nick + prefix = config.core.prefix + help_prefix = config.core.help_prefix + func._docs = {} + doc = trim_docstring(func.__doc__) + example = None + + func.unblockable = getattr(func, 'unblockable', False) + func.priority = getattr(func, 'priority', 'medium') + func.thread = getattr(func, 'thread', True) + func.rate = getattr(func, 'rate', 0) + func.channel_rate = getattr(func, 'channel_rate', 0) + func.global_rate = getattr(func, 'global_rate', 0) + + if not hasattr(func, 'event'): + func.event = ['PRIVMSG'] + else: + if isinstance(func.event, basestring): + func.event = [func.event.upper()] + else: + func.event = [event.upper() for event in func.event] + + if hasattr(func, 'rule'): + if isinstance(func.rule, basestring): + func.rule = [func.rule] + func.rule = [compile_rule(nick, rule) for rule in func.rule] + + if hasattr(func, 'commands'): + func.rule = getattr(func, 'rule', []) + for command in func.commands: + regexp = get_command_regexp(prefix, command) + func.rule.append(regexp) + if hasattr(func, 'example'): + example = func.example[0]["example"] + example = example.replace('$nickname', nick) + if example[0] != help_prefix and not example.startswith(nick): + example = help_prefix + example[len(help_prefix):] + if doc or example: + for command in func.commands: + func._docs[command] = (doc, example) + + +def load_module(name, path, type_): + """Load a module, and sort out the callables and shutdowns""" + if type_ == imp.PY_SOURCE: + with open(path) as mod: + module = imp.load_module(name, mod, path, ('.py', 'U', type_)) + elif type_ == imp.PKG_DIRECTORY: + module = imp.load_module(name, None, path, ('', '', type_)) + else: + raise TypeError('Unsupported module type') + return module, os.path.getmtime(path) + + +def is_triggerable(obj): + return any(hasattr(obj, attr) for attr in ('rule', 'rule', 'intent', + 'commands')) + + +def clean_module(module, config): + callables = [] + shutdowns = [] + jobs = [] + urls = [] + for obj in itervalues(vars(module)): + if callable(obj): + if getattr(obj, '__name__', None) == 'shutdown': + shutdowns.append(obj) + elif is_triggerable(obj): + clean_callable(obj, config) + callables.append(obj) + elif hasattr(obj, 'interval'): + clean_callable(obj, config) + jobs.append(obj) + elif hasattr(obj, 'url_regex'): + urls.append(obj) + return callables, jobs, shutdowns, urls diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..84ba149 --- /dev/null +++ b/logger.py @@ -0,0 +1,55 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import logging + + +class IrcLoggingHandler(logging.Handler): + def __init__(self, bot, level): + super(IrcLoggingHandler, self).__init__(level) + self._bot = bot + self._channel = bot.config.core.logging_channel + + def emit(self, record): + try: + msg = self.format(record) + self._bot.msg(self._channel, msg) + except (KeyboardInterrupt, SystemExit): + raise + except: + self.handleError(record) + + +class ChannelOutputFormatter(logging.Formatter): + def __init__(self): + super(ChannelOutputFormatter, self).__init__( + fmt='[%(filename)s] %(msg)s' + ) + + def formatException(self, exc_info): + # logging will through a newline between the message and this, but + # that's fine because Sopel will strip it back out anyway + return ' - ' + repr(exc_info[1]) + + +def setup_logging(bot): + level = bot.config.core.logging_level or 'WARNING' + logging.basicConfig(level=level) + logger = logging.getLogger('sopel') + if bot.config.core.logging_channel: + handler = IrcLoggingHandler(bot, level) + handler.setFormatter(ChannelOutputFormatter()) + logger.addHandler(handler) + + +def get_logger(name=None): + """Return a logger for a module, if the name is given. + + This is equivalent to `logging.getLogger('sopel.modules.' + name)` when + name is given, and `logging.getLogger('sopel')` when it is not. The latter + case is intended for use in Sopel's core; modules should call + `get_logger(__name__)` to get a logger.""" + if name: + return logging.getLogger('sopel.modules.' + name) + else: + return logging.getLogger('sopel') diff --git a/module.py b/module.py new file mode 100755 index 0000000..0fce459 --- /dev/null +++ b/module.py @@ -0,0 +1,460 @@ +# coding=utf-8 +"""This contains decorators and tools for creating callable plugin functions. +""" +# Copyright 2013, Ari Koivula, +# Copyright © 2013, Elad Alfassa +# Copyright 2013, Lior Ramati +# Licensed under the Eiffel Forum License 2. + +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +import test_tools +import functools + +NOLIMIT = 1 +"""Return value for ``callable``\s, which supresses rate limiting for the call. + +Returning this value means the triggering user will not be +prevented from triggering the command again within the rate limit. This can +be used, for example, to allow a user to rety a failed command immediately. + +.. versionadded:: 4.0 +""" + +VOICE = 1 +HALFOP = 2 +OP = 4 +ADMIN = 8 +OWNER = 16 + + +def unblockable(function): + """Decorator which exempts the function from nickname and hostname blocking. + + This can be used to ensure events such as JOIN are always recorded. + """ + function.unblockable = True + return function + + +def interval(*args): + """Decorates a function to be called by the bot every X seconds. + + This decorator can be used multiple times for multiple intervals, or all + intervals can be given at once as arguments. The first time the function + will be called is X seconds after the bot was started. + + Unlike other plugin functions, ones decorated by interval must only take a + :class:`sopel.bot.Sopel` as their argument; they do not get a trigger. The + bot argument will not have a context, so functions like ``bot.say()`` will + not have a default destination. + + There is no guarantee that the bot is connected to a server or joined a + channel when the function is called, so care must be taken. + + Example::: + + import sopel.module + @sopel.module.interval(5) + def spam_every_5s(bot): + if "#here" in bot.channels: + bot.msg("#here", "It has been five seconds!") + + """ + def add_attribute(function): + if not hasattr(function, "interval"): + function.interval = [] + for arg in args: + function.interval.append(arg) + return function + + return add_attribute + + +def rule(value): + """Decorate a function to be called when a line matches the given pattern + + This decorator can be used multiple times to add more rules. + + Args: + value: A regular expression which will trigger the function. + + If the Sopel instance is in a channel, or sent a PRIVMSG, where a string + matching this expression is said, the function will execute. Note that + captured groups here will be retrievable through the Trigger object later. + + Inside the regular expression, some special directives can be used. $nick + will be replaced with the nick of the bot and , or :, and $nickname will be + replaced with the nick of the bot. + """ + def add_attribute(function): + if not hasattr(function, "rule"): + function.rule = [] + function.rule.append(value) + return function + + return add_attribute + + +def thread(value): + """Decorate a function to specify if it should be run in a separate thread. + + Functions run in a separate thread (as is the default) will not prevent the + bot from executing other functions at the same time. Functions not run in a + separate thread may be started while other functions are still running, but + additional functions will not start until it is completed. + + Args: + value: Either True or False. If True the function is called in + a separate thread. If False from the main thread. + + """ + def add_attribute(function): + function.thread = value + return function + return add_attribute + + +def commands(*command_list): + """Decorate a function to set one or more commands to trigger it. + + This decorator can be used to add multiple commands to one callable in a + single line. The resulting match object will have the command as the first + group, rest of the line, excluding leading whitespace, as the second group. + Parameters 1 through 4, seperated by whitespace, will be groups 3-6. + + Args: + command: A string, which can be a regular expression. + + Returns: + A function with a new command appended to the commands + attribute. If there is no commands attribute, it is added. + + Example: + @commands("hello"): + If the command prefix is "\.", this would trigger on lines starting + with ".hello". + + @commands('j', 'join') + If the command prefix is "\.", this would trigger on lines starting + with either ".j" or ".join". + + """ + def add_attribute(function): + if not hasattr(function, "commands"): + function.commands = [] + function.commands.extend(command_list) + return function + return add_attribute + + +def nickname_commands(*command_list): + """Decorate a function to trigger on lines starting with "$nickname: command". + + This decorator can be used multiple times to add multiple rules. The + resulting match object will have the command as the first group, rest of + the line, excluding leading whitespace, as the second group. Parameters 1 + through 4, seperated by whitespace, will be groups 3-6. + + Args: + command: A string, which can be a regular expression. + + Returns: + A function with a new regular expression appended to the rule + attribute. If there is no rule attribute, it is added. + + Example: + @nickname_commands("hello!"): + Would trigger on "$nickname: hello!", "$nickname, hello!", + "$nickname hello!", "$nickname hello! parameter1" and + "$nickname hello! p1 p2 p3 p4 p5 p6 p7 p8 p9". + @nickname_commands(".*"): + Would trigger on anything starting with "$nickname[:,]? ", and + would have never have any additional parameters, as the command + would match the rest of the line. + + """ + def add_attribute(function): + if not hasattr(function, "rule"): + function.rule = [] + rule = r""" + ^ + $nickname[:,]? # Nickname. + \s+({command}) # Command as group 1. + (?:\s+ # Whitespace to end command. + ( # Rest of the line as group 2. + (?:(\S+))? # Parameters 1-4 as groups 3-6. + (?:\s+(\S+))? + (?:\s+(\S+))? + (?:\s+(\S+))? + .* # Accept anything after the parameters. Leave it up to + # the module to parse the line. + ))? # Group 1 must be None, if there are no parameters. + $ # EoL, so there are no partial matches. + """.format(command='|'.join(command_list)) + function.rule.append(rule) + return function + + return add_attribute + + +def priority(value): + """Decorate a function to be executed with higher or lower priority. + + Args: + value: Priority can be one of "high", "medium", "low". Defaults to + medium. + + Priority allows you to control the order of callable execution, if your + module needs it. + + """ + def add_attribute(function): + function.priority = value + return function + return add_attribute + + +def event(*event_list): + """Decorate a function to be triggered on specific IRC events. + + This is one of a number of events, such as 'JOIN', 'PART', 'QUIT', etc. + (More details can be found in RFC 1459.) When the Sopel bot is sent one of + these events, the function will execute. Note that functions with an event + must also be given a rule to match (though it may be '.*', which will + always match) or they will not be triggered. + + :class:`sopel.tools.events` provides human-readable names for many of the + numeric events, which may help your code be clearer. + """ + def add_attribute(function): + if not hasattr(function, "event"): + function.event = [] + function.event.extend(event_list) + return function + return add_attribute + + +def intent(*intent_list): + """Decorate a callable trigger on a message with any of the given intents. + + .. versionadded:: 5.2.0 + """ + def add_attribute(function): + if not hasattr(function, "intents"): + function.intents = [] + function.intents.extend(intent_list) + return function + return add_attribute + + +def rate(user=0, channel=0, server=0): + """Decorate a function to limit how often it can be triggered on a per-user + basis, in a channel, or across the server (bot). A value of zero means no + limit. If a function is given a rate of 20, that function may only be used + once every 20 seconds in the scope corresponding to the parameter. + Users on the admin list in Sopel’s configuration are exempted from rate + limits. + + Rate-limited functions that use scheduled future commands should import + threading.Timer() instead of sched, or rate limiting will not work properly. + """ + def add_attribute(function): + function.rate = user + function.channel_rate = channel + function.global_rate = server + return function + return add_attribute + + +def require_privmsg(message=None): + """Decorate a function to only be triggerable from a private message. + + If it is triggered in a channel message, `message` will be said if given. + """ + def actual_decorator(function): + @functools.wraps(function) + def _nop(*args, **kwargs): + # Assign trigger and bot for easy access later + bot, trigger = args[0:2] + if trigger.is_privmsg: + return function(*args, **kwargs) + else: + if message and not callable(message): + bot.say(message) + return _nop + # Hack to allow decorator without parens + if callable(message): + return actual_decorator(message) + return actual_decorator + + +def require_chanmsg(message=None): + """Decorate a function to only be triggerable from a channel message. + + If it is triggered in a private message, `message` will be said if given. + """ + def actual_decorator(function): + @functools.wraps(function) + def _nop(*args, **kwargs): + # Assign trigger and bot for easy access later + bot, trigger = args[0:2] + if not trigger.is_privmsg: + return function(*args, **kwargs) + else: + if message and not callable(message): + bot.say(message) + return _nop + # Hack to allow decorator without parens + if callable(message): + return actual_decorator(message) + return actual_decorator + + +def require_privilege(level, message=None): + """Decorate a function to require at least the given channel permission. + + `level` can be one of the privilege levels defined in this module. If the + user does not have the privilege, `message` will be said if given. If it is + a private message, no checking will be done.""" + def actual_decorator(function): + @functools.wraps(function) + def guarded(bot, trigger, *args, **kwargs): + # If this is a privmsg, ignore privilege requirements + if trigger.is_privmsg: + return function(bot, trigger, *args, **kwargs) + channel_privs = bot.privileges[trigger.sender] + allowed = channel_privs.get(trigger.nick, 0) >= level + if not trigger.is_privmsg and not allowed: + if message and not callable(message): + bot.say(message) + else: + return function(bot, trigger, *args, **kwargs) + return guarded + return actual_decorator + + +def require_admin(message=None): + """Decorate a function to require the triggering user to be a bot admin. + + If they are not, `message` will be said if given.""" + def actual_decorator(function): + @functools.wraps(function) + def guarded(bot, trigger, *args, **kwargs): + if not trigger.admin: + if message and not callable(message): + bot.say(message) + else: + return function(bot, trigger, *args, **kwargs) + return guarded + # Hack to allow decorator without parens + if callable(message): + return actual_decorator(message) + return actual_decorator + + +def require_owner(message=None): + """Decorate a function to require the triggering user to be the bot owner. + + If they are not, `message` will be said if given.""" + def actual_decorator(function): + @functools.wraps(function) + def guarded(bot, trigger, *args, **kwargs): + if not trigger.owner: + if message and not callable(message): + bot.say(message) + else: + return function(bot, trigger, *args, **kwargs) + return guarded + # Hack to allow decorator without parens + if callable(message): + return actual_decorator(message) + return actual_decorator + + +def url(url_rule): + """Decorate a function to handle URLs. + + This decorator takes a regex string that will be matched against URLs in a + message. The function it decorates, in addition to the bot and trigger, + must take a third argument ``match``, which is the regular expression match + of the url. This should be used rather than the matching in trigger, in + order to support e.g. the ``.title`` command. + """ + def actual_decorator(function): + @functools.wraps(function) + def helper(bot, trigger, match=None): + match = match or trigger + return function(bot, trigger, match) + helper.url_regex = re.compile(url_rule) + return helper + return actual_decorator + + +class example(object): + """Decorate a function with an example. + + Add an example attribute into a function and generate a test. + """ + # TODO dat doc doe >_< + def __init__(self, msg, result=None, privmsg=False, admin=False, + owner=False, repeat=1, re=False, ignore=None): + """Accepts arguments for the decorator. + + Args: + msg - The example message to give to the function as input. + result - Resulting output from calling the function with msg. + privmsg - If true, make the message appear to have sent in a + private message to the bot. If false, make it appear to have + come from a channel. + admin - Bool. Make the message appear to have come from an admin. + owner - Bool. Make the message appear to have come from an owner. + repeat - How many times to repeat the test. Usefull for tests that + return random stuff. + re - Bool. If true, result is interpreted as a regular expression. + ignore - a list of outputs to ignore. + + """ + # Wrap result into a list for get_example_test + if isinstance(result, list): + self.result = result + elif result is not None: + self.result = [result] + else: + self.result = None + self.use_re = re + self.msg = msg + self.privmsg = privmsg + self.admin = admin + self.owner = owner + self.repeat = repeat + + if isinstance(ignore, list): + self.ignore = ignore + elif ignore is not None: + self.ignore = [ignore] + else: + self.ignore = [] + + def __call__(self, func): + if not hasattr(func, "example"): + func.example = [] + + if self.result: + test = test_tools.get_example_test( + func, self.msg, self.result, self.privmsg, self.admin, + self.owner, self.repeat, self.use_re, self.ignore + ) + test_tools.insert_into_module( + test, func.__module__, func.__name__, 'test_example' + ) + + record = { + "example": self.msg, + "result": self.result, + "privmsg": self.privmsg, + "admin": self.admin, + } + func.example.append(record) + return func diff --git a/modules/8ball.py b/modules/8ball.py new file mode 100755 index 0000000..4a38680 --- /dev/null +++ b/modules/8ball.py @@ -0,0 +1,21 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Classic 8ball. +""" +import module +import random + +@module.commands('8ball') +def eightball(bot, trigger): + """ + Classic 8ball. + """ + response = [ "No", "Probably not", "Don't count on it", "It'll never happen", + "Stop trying", "Uncertain", "Who knows~", "Doubtful", "I don't know", + "Maybe", "It could happen", "Yes", "Fate is a curious mistress", + "Your life is meaningless", "Who knows~", "The winds of change are always blowing", + "Nah", "The tides have turned" ] + + msg = response[ random.randint(0, len(response)-1) ] + bot.reply(msg) diff --git a/modules/admin.py b/modules/admin.py new file mode 100755 index 0000000..83eea1b --- /dev/null +++ b/modules/admin.py @@ -0,0 +1,229 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +admin.py - Sopel Admin Module +Copyright 2010-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich +(yanovich.net) +Copyright © 2012, Elad Alfassa, +Copyright 2013, Ari Koivula + +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +from config.types import ( + StaticSection, ValidatedAttribute, FilenameAttribute +) +import module + + +class AdminSection(StaticSection): + hold_ground = ValidatedAttribute('hold_ground', bool, default=False) + """Auto re-join on kick""" + auto_accept_invite = ValidatedAttribute('auto_accept_invite', bool, + default=True) + + +def configure(config): + config.define_section('admin', AdminSection) + config.admin.configure_setting('hold_ground', + "Automatically re-join after being kicked?") + config.admin.configure_setting('auto_accept_invite', + 'Automatically join channels when invited?') + + +def setup(bot): + bot.config.define_section('admin', AdminSection) + + +@module.require_privmsg +@module.require_admin +@module.commands('join') +@module.priority('low') +@module.example('.join #example or .join #example key') +def join(bot, trigger): + """Join the specified channel. This is an admin-only command.""" + channel, key = trigger.group(3), trigger.group(4) + if not channel: + return + elif not key: + bot.join(channel) + else: + bot.join(channel, key) + + +@module.require_privmsg +@module.require_admin +@module.commands('part') +@module.priority('low') +@module.example('.part #example') +def part(bot, trigger): + """Part the specified channel. This is an admin-only command.""" + channel, _sep, part_msg = trigger.group(2).partition(' ') + if part_msg: + bot.part(channel, part_msg) + else: + bot.part(channel) + + +@module.require_owner +@module.commands('quit') +@module.priority('low') +def quit(bot, trigger): + """Quit from the server. This is an owner-only command.""" + quit_message = trigger.group(2) + if not quit_message: + quit_message = 'Quitting on command from %s' % trigger.nick + + bot.quit(quit_message) + + +@module.require_privmsg +@module.require_admin +@module.commands('msg') +@module.priority('low') +@module.example('.msg #YourPants Does anyone else smell neurotoxin?') +def msg(bot, trigger): + """ + Send a message to a given channel or nick. Can only be done in privmsg by an + admin. + """ + if trigger.group(2) is None: + return + + channel, _sep, message = trigger.group(2).partition(' ') + message = message.strip() + if not channel or not message: + return + + bot.msg(channel, message) + + +@module.require_privmsg +@module.require_admin +@module.commands('me') +@module.priority('low') +def me(bot, trigger): + """ + Send an ACTION (/me) to a given channel or nick. Can only be done in privmsg + by an admin. + """ + if trigger.group(2) is None: + return + + channel, _sep, action = trigger.group(2).partition(' ') + action = action.strip() + if not channel or not action: + return + + msg = '\x01ACTION %s\x01' % action + bot.msg(channel, msg) + + +@module.event('INVITE') +@module.rule('.*') +@module.priority('low') +def invite_join(bot, trigger): + """ + Join a channel sopel is invited to, if the inviter is an admin. + """ + if trigger.admin or bot.config.admin.auto_accept_invite: + bot.join(trigger.args[1]) + return + + +@module.event('KICK') +@module.rule(r'.*') +@module.priority('low') +def hold_ground(bot, trigger): + """ + This function monitors all kicks across all channels sopel is in. If it + detects that it is the one kicked it'll automatically join that channel. + + WARNING: This may not be needed and could cause problems if sopel becomes + annoying. Please use this with caution. + """ + if bot.config.admin.hold_ground: + channel = trigger.sender + if trigger.args[1] == bot.nick: + bot.join(channel) + + +@module.require_privmsg +@module.require_admin +@module.commands('mode') +@module.priority('low') +def mode(bot, trigger): + """Set a user mode on Sopel. Can only be done in privmsg by an admin.""" + mode = trigger.group(3) + bot.write(('MODE ', bot.nick + ' ' + mode)) + + +@module.require_privmsg("This command only works as a private message.") +@module.require_admin("This command requires admin privileges.") +@module.commands('set') +@module.example('.set core.owner Me') +def set_config(bot, trigger): + """See and modify values of sopels config object. + + Trigger args: + arg1 - section and option, in the form "section.option" + arg2 - value + + If there is no section, section will default to "core". + If value is None, the option will be deleted. + """ + # Get section and option from first argument. + arg1 = trigger.group(3).split('.') + if len(arg1) == 1: + section_name, option = "core", arg1[0] + elif len(arg1) == 2: + section_name, option = arg1 + else: + bot.reply("Usage: .set section.option value") + return + section = getattr(bot.config, section_name) + static_sec = isinstance(section, StaticSection) + + if static_sec and not hasattr(section, option): + bot.say('[{}] section has no option {}.'.format(section_name, option)) + return + + # Display current value if no value is given. + value = trigger.group(4) + if not value: + if not static_sec and bot.config.parser.has_option(section, option): + bot.reply("Option %s.%s does not exist." % (section_name, option)) + return + # Except if the option looks like a password. Censor those to stop them + # from being put on log files. + if option.endswith("password") or option.endswith("pass"): + value = "(password censored)" + else: + value = getattr(section, option) + bot.reply("%s.%s = %s" % (section_name, option, value)) + return + + # Otherwise, set the value to one given as argument 2. + if static_sec: + descriptor = getattr(section.__class__, option) + try: + if isinstance(descriptor, FilenameAttribute): + value = descriptor.parse(bot.config, descriptor, value) + else: + value = descriptor.parse(value) + except ValueError as exc: + bot.say("Can't set attribute: " + str(exc)) + return + setattr(section, option, value) + + +@module.require_privmsg +@module.require_admin +@module.commands('save') +@module.example('.save') +def save_config(bot, trigger): + """Save state of sopels config object to the configuration file.""" + bot.config.save() diff --git a/modules/adminchannel.py b/modules/adminchannel.py new file mode 100755 index 0000000..f1d0bc2 --- /dev/null +++ b/modules/adminchannel.py @@ -0,0 +1,276 @@ +# coding=utf-8 +# Copyright 2010-2011, Michael Yanovich, Alek Rollyson, and Elsie Powell +# Copyright © 2012, Elad Alfassa +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +import formatting +from module import commands, priority, OP, HALFOP, require_privilege, require_chanmsg +from tools import Identifier + + +def default_mask(trigger): + welcome = formatting.color('Welcome to:', formatting.colors.PURPLE) + chan = formatting.color(trigger.sender, formatting.colors.TEAL) + topic_ = formatting.bold('Topic:') + topic_ = formatting.color('| ' + topic_, formatting.colors.PURPLE) + arg = formatting.color('{}', formatting.colors.GREEN) + return '{} {} {} {}'.format(welcome, chan, topic_, arg) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('kick') +@priority('high') +def kick(bot, trigger): + """ + Kick a user from the channel. + """ + if bot.privileges[trigger.sender][bot.nick] < HALFOP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 2: + return + opt = Identifier(text[1]) + nick = opt + channel = trigger.sender + reasonidx = 2 + if not opt.is_nick(): + if argc < 3: + return + nick = text[2] + channel = opt + reasonidx = 3 + reason = ' '.join(text[reasonidx:]) + if nick != bot.config.core.nick: + bot.write(['KICK', channel, nick], reason) + + +def configureHostMask(mask): + if mask == '*!*@*': + return mask + if re.match('^[^.@!/]+$', mask) is not None: + return '%s!*@*' % mask + if re.match('^[^@!]+$', mask) is not None: + return '*!*@%s' % mask + + m = re.match('^([^!@]+)@$', mask) + if m is not None: + return '*!%s@*' % m.group(1) + + m = re.match('^([^!@]+)@([^@!]+)$', mask) + if m is not None: + return '*!%s@%s' % (m.group(1), m.group(2)) + + m = re.match('^([^!@]+)!(^[!@]+)@?$', mask) + if m is not None: + return '%s!%s@*' % (m.group(1), m.group(2)) + return '' + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('ban') +@priority('high') +def ban(bot, trigger): + """ + This give admins the ability to ban a user. + The bot must be a Channel Operator for this command to work. + """ + if bot.privileges[trigger.sender][bot.nick] < HALFOP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 2: + return + opt = Identifier(text[1]) + banmask = opt + channel = trigger.sender + if not opt.is_nick(): + if argc < 3: + return + channel = opt + banmask = text[2] + banmask = configureHostMask(banmask) + if banmask == '': + return + bot.write(['MODE', channel, '+b', banmask]) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('unban') +def unban(bot, trigger): + """ + This give admins the ability to unban a user. + The bot must be a Channel Operator for this command to work. + """ + if bot.privileges[trigger.sender][bot.nick] < HALFOP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 2: + return + opt = Identifier(text[1]) + banmask = opt + channel = trigger.sender + if not opt.is_nick(): + if argc < 3: + return + channel = opt + banmask = text[2] + banmask = configureHostMask(banmask) + if banmask == '': + return + bot.write(['MODE', channel, '-b', banmask]) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('quiet') +def quiet(bot, trigger): + """ + This gives admins the ability to quiet a user. + The bot must be a Channel Operator for this command to work. + """ + if bot.privileges[trigger.sender][bot.nick] < OP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 2: + return + opt = Identifier(text[1]) + quietmask = opt + channel = trigger.sender + if not opt.is_nick(): + if argc < 3: + return + quietmask = text[2] + channel = opt + quietmask = configureHostMask(quietmask) + if quietmask == '': + return + bot.write(['MODE', channel, '+q', quietmask]) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('unquiet') +def unquiet(bot, trigger): + """ + This gives admins the ability to unquiet a user. + The bot must be a Channel Operator for this command to work. + """ + if bot.privileges[trigger.sender][bot.nick] < OP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 2: + return + opt = Identifier(text[1]) + quietmask = opt + channel = trigger.sender + if not opt.is_nick(): + if argc < 3: + return + quietmask = text[2] + channel = opt + quietmask = configureHostMask(quietmask) + if quietmask == '': + return + bot.write(['MODE', channel, '-q', quietmask]) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('kickban', 'kb') +@priority('high') +def kickban(bot, trigger): + """ + This gives admins the ability to kickban a user. + The bot must be a Channel Operator for this command to work. + .kickban [#chan] user1 user!*@* get out of here + """ + if bot.privileges[trigger.sender][bot.nick] < HALFOP: + return bot.reply("I'm not a channel operator!") + text = trigger.group().split() + argc = len(text) + if argc < 4: + return + opt = Identifier(text[1]) + nick = opt + mask = text[2] + channel = trigger.sender + reasonidx = 3 + if not opt.is_nick(): + if argc < 5: + return + channel = opt + nick = text[2] + mask = text[3] + reasonidx = 4 + reason = ' '.join(text[reasonidx:]) + mask = configureHostMask(mask) + if mask == '': + return + bot.write(['MODE', channel, '+b', mask]) + bot.write(['KICK', channel, nick], reason) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('topic') +def topic(bot, trigger): + """ + This gives ops the ability to change the topic. + The bot must be a Channel Operator for this command to work. + """ + if bot.privileges[trigger.sender][bot.nick] < HALFOP: + return bot.reply("I'm not a channel operator!") + if not trigger.group(2): + return + channel = trigger.sender.lower() + + narg = 1 + mask = None + mask = bot.db.get_channel_value(channel, 'topic_mask') + mask = mask or default_mask(trigger) + mask = mask.replace('%s', '{}') + narg = len(re.findall('{}', mask)) + + top = trigger.group(2) + args = [] + if top: + args = top.split('~', narg) + + if len(args) != narg: + message = "Not enough arguments. You gave {}, it requires {}.".format( + len(args), narg) + return bot.say(message) + topic = mask.format(*args) + + bot.write(('TOPIC', channel + ' :' + topic)) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('tmask') +def set_mask(bot, trigger): + """ + Set the mask to use for .topic in the current channel. {} is used to allow + substituting in chunks of text. + """ + bot.db.set_channel_value(trigger.sender, 'topic_mask', trigger.group(2)) + bot.say("Gotcha, " + trigger.nick) + + +@require_chanmsg +@require_privilege(OP, 'You are not a channel operator.') +@commands('showmask') +def show_mask(bot, trigger): + """Show the topic mask for the current channel.""" + mask = bot.db.get_channel_value(trigger.sender, 'topic_mask') + mask = mask or default_mask(trigger) + bot.say(mask) diff --git a/modules/announce.py b/modules/announce.py new file mode 100755 index 0000000..c5c5aa5 --- /dev/null +++ b/modules/announce.py @@ -0,0 +1,22 @@ +# coding=utf-8 +""" +announce.py - Send a message to all channels +Copyright © 2013, Elad Alfassa, +Licensed under the Eiffel Forum License 2. + +""" +from module import commands, example + + +@commands('announce') +@example('.announce Some important message here') +def announce(bot, trigger): + """ + Send an announcement to all channels the bot is in + """ + if not trigger.admin: + bot.reply('Sorry, I can\'t let you do that') + return + for channel in bot.channels: + bot.msg(channel, '[ANNOUNCEMENT] %s' % trigger.group(2)) + bot.reply('Announce complete.') diff --git a/modules/ascii.py b/modules/ascii.py new file mode 100755 index 0000000..965566d --- /dev/null +++ b/modules/ascii.py @@ -0,0 +1,192 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +from io import BytesIO +from PIL import Image +import requests +import module +from tools import web +from PIL import ImageFont +from PIL import ImageDraw + +ASCII_CHARS = "$@%#*+=-:. " +headers = {'User-Agent': 'we wuz ascii and shiet'} + + +def scale_image(image, size=(100,100)): + """ + Resizes an image while preserving the aspect ratio. Chooses the + dimension to scale by based on whichever is larger, ensuring that + neither width or height is ever larger than the accepted size tuple. + """ + original_width, original_height = image.size + original_width = original_width * 2 # because characters are generally + if original_width > original_height: # displayed as a 1:2 square + if original_width > size[0]: + new_width = 100 + aspect_ratio = original_height/float(original_width) + new_height = int(aspect_ratio * new_width) + else: + new_width, new_height = image.size + else: + if original_height > size[1]: + new_height = 100 + aspect_ratio = original_width/float(original_height) + new_width = int(aspect_ratio * new_height) + else: + new_width, new_height = image.size + image = image.resize((new_width, new_height)) + return image + + +def pixels_to_chars(image, reverse=False): + """ + Maps each pixel to an ascii char based on the range + in which it lies. + + 0-255 is divided into 11 ranges of 25 pixels each. + """ + range_width = int(255 / len(ASCII_CHARS)) + (255 % len(ASCII_CHARS) > 0) + + pixels_in_image = list(image.getdata()) + pixels_to_chars = [] + for pixel_value in pixels_in_image: + if reverse: + index = -int(pixel_value/range_width)-1 + else: + index = int(pixel_value/range_width) + pixels_to_chars.append(ASCII_CHARS[ index ]) + + return "".join(pixels_to_chars) + + +def open_image(imagePath): + """ + Opens the image at the supplied file path in PIL. If an internet URL + is supplied, it will download the image and then open it. + """ + try: + if imagePath.startswith("http"): + res = requests.get(imagePath, headers=headers, verify=True, timeout=20) + res.raise_for_status() + image = Image.open(BytesIO(res.content)) + else: + image = Image.open(imagePath) + except FileNotFoundError as e: + return e + except OSError: + return e + except Exception as e: + return("Error opening image file: " + imagePath) + + return image + + +def image_to_ascii(image, reverse=False): + """ + Reads an image file and converts it to ascii art. Returns a + newline-delineated string. If reverse is True, the ascii scale is + reversed. + """ + image = scale_image(image) + image = image.convert('L') + + chars = pixels_to_chars(image, reverse) + + image_ascii = [] + for index in range(0, len(chars), image.size[0]): + image_ascii.append( chars[index: index + image.size[0]] ) + image.close() + del image + return "\n".join(image_ascii) + + +def ascii_to_image(image_ascii): + """ + Creates a plain image and draws text on it. + """ + width = len(image_ascii[:image_ascii.index("\n")]) * 8 + height = (image_ascii.count("\n")+1) * 12 + 4 + + font = ImageFont.truetype("LiberationMono-Regular.ttf", 14) + image = Image.new("RGB", (width, height), (255,255,255)) + draw = ImageDraw.Draw(image) + draw.text((0,0), image_ascii, (0,0,0), font=font, spacing=0) + return image + + +def handle_gif(output, reverse=False): + image = open_image(args.imagePath) + ascii_seq = [] + new_image = ascii_to_image(image_to_ascii(image, reverse)) + image.seek(1) + while True: + try: + im = ascii_to_image(image_to_ascii(image, reverse)) + ascii_seq.append() + image.seek(image.tell()+1) + except EOFError: + break # end of sequence + + new_image.save(args.output, save_all=True, append_images=ascii_seq, duration=60, loop=0, optimize=True) + + +@module.rate(user=60) +@module.require_chanmsg(message="It's impolite to whisper.") +@module.commands('ascii') +@module.example('.ascii [-r] https://www.freshports.org/images/freshports.jpg') +def ascii(bot, trigger): + """ + Downloads an image and converts it to ascii. + """ + reverse = False + if trigger.group(3) == "-r": + imagePath = trigger.group(4) + reverse = True + else: + imagePath = trigger.group(2) + + if not web.secCheck(bot, imagePath): + return bot.reply("Known malicious site. Ignoring.") + + if not imagePath.startswith("http"): + bot.reply("Internet requests only.") + return + + image = open_image(imagePath) + image_ascii = image_to_ascii(image, reverse) + bot.say(image_ascii) + + +if __name__=='__main__': + import argparse + + # TODO: satisfy PEP8 + parser = argparse.ArgumentParser(description="Converts an image file to ascii art.") + parser.add_argument("imagePath", help="The full path to the image file.") + parser.add_argument("-r", "--reverse", action="store_true", help="Reverses the ascii scale.") + parser.add_argument("-o", "--output", help="Outputs the ascii art into a file at the specified path.") + parser.add_argument("-i", "--image", action="store_true", help="Outputs the ascii art as an image rather than plain text. Requires --output.") + parser.add_argument("-a", "--animated", action="store_true", help="Handles animated GIFs. Includes --image.") + parser.set_defaults(reverse=False, image=False, animated=False) + args = parser.parse_args() + + if args.animated: # --animated includes --image + args.image = True + if args.image: # --image requires --output + if not args.output: + parser.error("--image requires --output") + + if args.animated: + handle_gif(args.output, args.reverse) + else: + image = open_image(args.imagePath) + image_ascii = image_to_ascii(image, args.reverse) + if args.image: + image = ascii_to_image(image_ascii) + image.save(args.output, "PNG") + elif args.output: + with open(args.output, "w+") as file: + file.write(image_ascii) + else: + print(image_ascii) diff --git a/modules/away.py b/modules/away.py new file mode 100755 index 0000000..6509349 --- /dev/null +++ b/modules/away.py @@ -0,0 +1,56 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Marks a user as away with an optional message and informs anyone who attempt +to ping them of their away status. Goes away when the user talks again. +""" +from module import commands, example, rule, priority +from tools import Identifier + +def setup(bot): + bot.memory['away'] = {} + + +@commands('away') +@example('.away Gonna go kill myself.', 'User is now away: Gonna go kill myself.') +def away(bot, trigger): + """ + Stores in the user's name and away message in memory. + """ + if not trigger.group(2): + bot.memory['away'][trigger.nick] = "" + else: + bot.memory['away'][trigger.nick] = trigger.group(2) + + +@rule('(.*)') +@priority('low') +def message(bot, trigger): + """ + If an away users name is said, print their away message. + """ + for key in bot.memory['away'].keys(): + msg = "\x0308" + key + "\x03 is away: \x0311" + \ + bot.memory['away'][key] + if trigger.startswith(key+":"): + return bot.say(msg) + elif trigger.startswith(key+","): + return bot.say(msg) + elif trigger == key: + return bot.say(msg) +""" + name = Identifier(trigger.group(1)) + if name in bot.memory['away'].keys(): + msg = "\x0308" + name + "\x03 is away: \x0311" + \ + bot.memory['away'][name] + bot.say(msg) +""" + +@rule('(.*)') +@priority('low') +def notAway(bot, trigger): + """ + If an away user says something, remove them from the away dict. + """ + if trigger.nick in bot.memory['away'].keys() and not trigger.group(0).startswith(".away"): + bot.memory['away'].pop(trigger.nick, None) diff --git a/modules/banhe.py b/modules/banhe.py new file mode 100755 index 0000000..44a1b7e --- /dev/null +++ b/modules/banhe.py @@ -0,0 +1,49 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ban he +ban he +ban he +""" +import module +import modules.adminchannel +import time +from trigger import PreTrigger, Trigger +from tools import Identifier, get_command_regexp + +@module.commands('banhe') +@module.example('.banhe assfaggot 30m') +def banhe(bot, trigger): + """ + Bans he for a set period of time. Admins may set the period of time, + non-admins only get 20 second bans. + """ + banhee, period = trigger.group(3), trigger.group(4) + + # here we construct a brand new trigger from scratch becasue doing + # things the right way just isn't in the budget. + fake_line = ":" + bot.nick + trigger.raw[trigger.raw.find("!"):] + fake_pretrigger = PreTrigger(trigger.nick, fake_line) + fake_regexp = get_command_regexp(".", "banhe") + fake_match = fake_regexp.match(fake_pretrigger.args[-1]) + fake_trigger = Trigger(bot.config, fake_pretrigger, fake_match) + + if not trigger.admin: + period = 20 + else: + conv = {'s':1, 'm':60, 'h':3600, 'd':86400} + try: + period = conv[period[-1]] * int(period[:-1]) + except (KeyError, ValueError, TypeError): + period = 0 + + modules.adminchannel.ban(bot, fake_trigger) + if period > 2592000: + bot.reply("It's too big, Onii-chan.") + if not period or period > 2592000: + return bot.say("Banned \x0304" + banhee + "\x03 for \x0309∞\x03 seconds.") + + bot.say("Banned \x0304" + banhee + "\x03 for \x0309" + str(period) + "\x03 seconds.") + time.sleep(period) + modules.adminchannel.unban(bot, fake_trigger) + bot.say("Unbanned \x0304" + banhee + "\x03") diff --git a/modules/bq.py b/modules/bq.py new file mode 100755 index 0000000..8e90986 --- /dev/null +++ b/modules/bq.py @@ -0,0 +1,20 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Various things related to Banished Quest. +""" +from module import commands +from tools.time import relativeTime +#from datetime import datetime + +@commands('bq') +def BQstatus(bot, trigger): + """ + Displays the current status of BQ. + """ + status = "\x0304DEAD" + #deathdate = datetime(year=2017, month=2, day=16, hour=0, minute=19) + deathdate = "[2017-02-16 00:19:00]" + msg = "Banished Quest status: " + status + "\nTime since death: " + msg += relativeTime(bot, trigger.nick, deathdate) + bot.say(msg) diff --git a/modules/bugzilla.py b/modules/bugzilla.py new file mode 100755 index 0000000..12329ce --- /dev/null +++ b/modules/bugzilla.py @@ -0,0 +1,97 @@ +# coding=utf-8 +"""Bugzilla issue reporting module + +Copyright 2013-2015, Embolalia, embolalia.com +Licensed under the Eiffel Forum License 2. +""" +import re + +import xmltodict +import requests + +import tools +from config.types import StaticSection, ListAttribute +from logger import get_logger +from module import rule + + +regex = None +LOGGER = get_logger(__name__) + + +class BugzillaSection(StaticSection): + domains = ListAttribute('domains') + """The domains of the Bugzilla instances from which to get information.""" + + +def configure(config): + config.define_section('bugzilla', BugzillaSection) + config.bugzilla.configure_setting( + 'domains', + 'Enter the domains of the Bugzillas you want extra information ' + 'from (e.g. bugzilla.gnome.org)' + ) + + +def setup(bot): + global regex + bot.config.define_section('bugzilla', BugzillaSection) + + if not bot.config.bugzilla.domains: + return + if not bot.memory.contains('url_callbacks'): + bot.memory['url_callbacks'] = tools.SopelMemory() + + domains = '|'.join(bot.config.bugzilla.domains) + regex = re.compile((r'https?://(%s)' + '(/show_bug.cgi\?\S*?)' + '(id=\d+)') + % domains) + bot.memory['url_callbacks'][regex] = show_bug + + +def shutdown(bot): + del bot.memory['url_callbacks'][regex] + + +@rule(r'.*https?://(\S+?)' + '(/show_bug.cgi\?\S*?)' + '(id=\d+).*') +def show_bug(bot, trigger, match=None): + """Show information about a Bugzilla bug.""" + match = match or trigger + domain = match.group(1) + if domain not in bot.config.bugzilla.domains: + return + url = 'https://%s%sctype=xml&%s' % match.groups() + data = requests.get(url) + bug = xmltodict.parse(data).get('bugzilla').get('bug') + error = bug.get('@error', None) # error="NotPermitted" + + if error: + LOGGER.warning('Bugzilla error: %s' % error) + bot.say('[BUGZILLA] Unable to get infomation for ' + 'linked bug (%s)' % error) + return + + message = ('[BUGZILLA] %s | Product: %s | Component: %s | Version: %s | ' + + 'Importance: %s | Status: %s | Assigned to: %s | ' + + 'Reported: %s | Modified: %s') + + resolution = bug.get('resolution') + if resolution is not None: + status = bug.get('bug_status') + ' ' + resolution + else: + status = bug.get('bug_status') + + assigned_to = bug.get('assigned_to') + if isinstance(assigned_to, dict): + assigned_to = assigned_to.get('@name') + + message = message % ( + bug.get('short_desc'), bug.get('product'), + bug.get('component'), bug.get('version'), + (bug.get('priority') + ' ' + bug.get('bug_severity')), + status, assigned_to, bug.get('creation_ts'), + bug.get('delta_ts')) + bot.say(message) diff --git a/modules/calc.py b/modules/calc.py new file mode 100755 index 0000000..f2cc863 --- /dev/null +++ b/modules/calc.py @@ -0,0 +1,66 @@ +# coding=utf-8 +""" +calc.py - Sopel Calculator Module +Copyright 2008, Sean B. Palmer, inamidst.com +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +from module import commands, example +from tools.calculation import eval_equation +import sys, requests +if sys.version_info.major >= 3: + unichr = chr + + +BASE_TUMBOLIA_URI = 'https://tumbolia-two.appspot.com/' + + +@commands('c', 'calc') +@example('.c 5 + 3', '8') +@example('.c 0.9*10', '9') +@example('.c 10*0.9', '9') +@example('.c 2*(1+2)*3', '18') +@example('.c 2**10', '1024') +@example('.c 5 // 2', '2') +@example('.c 5 / 2', '2.5') +def c(bot, trigger): + """Evaluate some calculation.""" + if not trigger.group(2): + return bot.reply("Nothing to calculate.") + # Account for the silly non-Anglophones and their silly radix point. + eqn = trigger.group(2).replace(',', '.') + try: + result = eval_equation(eqn) + result = "{:.10g}".format(result) + except ZeroDivisionError: + result = "Division by zero is not supported in this universe." + except Exception as e: + result = "{error}: {msg}".format(error=type(e), msg=e) + bot.reply(result) + + +@commands('py') +@example('.py len([1,2,3])', '3') +def py(bot, trigger): + """Evaluate a Python expression.""" + if not trigger.group(2): + return bot.say("Need an expression to evaluate") + + query = trigger.group(2) + uri = BASE_TUMBOLIA_URI + 'py/' + res = requests.get(uri + query) + res.raise_for_status() + answer = res.text + if answer: + #bot.say can potentially lead to 3rd party commands triggering. + bot.say(answer) + else: + bot.reply('Sorry, no result.') + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/clock.py b/modules/clock.py new file mode 100755 index 0000000..31d1e9b --- /dev/null +++ b/modules/clock.py @@ -0,0 +1,276 @@ +# coding=utf-8 +# Copyright 2008-9, Sean B. Palmer, inamidst.com +# Copyright 2012, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. + +try: + import pytz +except ImportError: + pytz = None + +from module import commands, example, OP +from tools.time import ( + get_timezone, format_time, validate_format, validate_timezone +) +from config.types import StaticSection, ValidatedAttribute + + +class TimeSection(StaticSection): + tz = ValidatedAttribute( + 'tz', + parse=validate_timezone, + serialize=validate_timezone, + default='UTC' + ) + """Default time zone (see http://sopel.chat/tz)""" + time_format = ValidatedAttribute( + 'time_format', + parse=validate_format, + default='%Y-%m-%d - %T%Z' + ) + """Default time format (see http://strftime.net)""" + + +def configure(config): + config.define_section('clock', TimeSection) + config.clock.configure_setting( + 'tz', 'Preferred time zone (http://sopel.chat/tz)') + config.clock.configure_setting( + 'time_format', 'Preferred time format (http://strftime.net)') + + +def setup(bot): + bot.config.define_section('clock', TimeSection) + + +@commands('t', 'time') +@example('.t America/New_York') +def f_time(bot, trigger): + """Returns the current time.""" + if trigger.group(2): + zone = get_timezone(bot.db, bot.config, trigger.group(2).strip(), None, None) + if not zone: + bot.say('Could not find timezone %s.' % trigger.group(2).strip()) + return + else: + zone = get_timezone(bot.db, bot.config, None, trigger.nick, + trigger.sender) + time = format_time(bot.db, bot.config, zone, trigger.nick, trigger.sender) + bot.say(time) + + +@commands('settz', 'settimezone') +@example('.settz America/New_York') +def update_user(bot, trigger): + """ + Set your preferred time zone. Most timezones will work, but it's best to + use one from http://sopel.chat/tz + """ + if not pytz: + bot.reply("Sorry, I don't have timezone support installed.") + else: + tz = trigger.group(2) + if not tz: + bot.reply("What timezone do you want to set? Try one from " + "http://sopel.chat/tz") + return + if tz not in pytz.all_timezones: + bot.reply("I don't know that time zone. Try one from " + "http://sopel.chat/tz") + return + + bot.db.set_nick_value(trigger.nick, 'timezone', tz) + if len(tz) < 7: + bot.say("Okay, {}, but you should use one from http://sopel.chat/tz " + "if you use DST.".format(trigger.nick)) + else: + bot.reply('I now have you in the %s time zone.' % tz) + + +@commands('gettz', 'gettimezone') +@example('.gettz [nick]') +def get_user_tz(bot, trigger): + """ + Gets a user's preferred time zone, will show yours if no user specified + """ + if not pytz: + bot.reply("Sorry, I don't have timezone support installed.") + else: + nick = trigger.group(2) + if not nick: + nick = trigger.nick + + nick = nick.strip() + + tz = bot.db.get_nick_value(nick, 'timezone') + if tz: + bot.say('%s\'s time zone is %s.' % (nick, tz)) + else: + bot.say('%s has not set their time zone' % nick) + + +@commands('settimeformat', 'settf') +@example('.settf %Y-%m-%dT%T%z') +def update_user_format(bot, trigger): + """ + Sets your preferred format for time. Uses the standard strftime format. You + can use http://strftime.net or your favorite search engine to learn more. + """ + tformat = trigger.group(2) + if not tformat: + bot.reply("What format do you want me to use? Try using" + " http://strftime.net to make one.") + return + + tz = get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) + + # Get old format as back-up + old_format = bot.db.get_nick_value(trigger.nick, 'time_format') + + # Save the new format in the database so we can test it. + bot.db.set_nick_value(trigger.nick, 'time_format', tformat) + + try: + timef = format_time(db=bot.db, zone=tz, nick=trigger.nick) + except: + bot.reply("That format doesn't work. Try using" + " http://strftime.net to make one.") + # New format doesn't work. Revert save in database. + bot.db.set_nick_value(trigger.nick, 'time_format', old_format) + return + bot.reply("Got it. Your time will now appear as %s. (If the " + "timezone is wrong, you might try the settz command)" + % timef) + + +@commands('gettimeformat', 'gettf') +@example('.gettf [nick]') +def get_user_format(bot, trigger): + """ + Gets a user's preferred time format, will show yours if no user specified + """ + nick = trigger.group(2) + if not nick: + nick = trigger.nick + + nick = nick.strip() + + # Get old format as back-up + format = bot.db.get_nick_value(nick, 'time_format') + + if format: + bot.say("%s's time format: %s." % (nick, format)) + else: + bot.say("%s hasn't set a custom time format" % nick) + + +@commands('setchanneltz', 'setctz') +@example('.setctz America/New_York') +def update_channel(bot, trigger): + """ + Set the preferred time zone for the channel. + """ + if bot.privileges[trigger.sender][trigger.nick] < OP: + return + elif not pytz: + bot.reply("Sorry, I don't have timezone support installed.") + else: + tz = trigger.group(2) + if not tz: + bot.reply("What timezone do you want to set? Try one from " + "http://sopel.chat/tz") + return + if tz not in pytz.all_timezones: + bot.reply("I don't know that time zone. Try one from " + "http://sopel.chat/tz") + return + + bot.db.set_channel_value(trigger.sender, 'timezone', tz) + if len(tz) < 7: + bot.say("Okay, {}, but you should use one from http://sopel.chat/tz " + "if you use DST.".format(trigger.nick)) + else: + bot.reply( + 'I now have {} in the {} time zone.'.format(trigger.sender, tz)) + + +@commands('getchanneltz', 'getctz') +@example('.getctz [channel]') +def get_channel_tz(bot, trigger): + """ + Gets the preferred channel timezone, or the current channel timezone if no + channel given. + """ + if not pytz: + bot.reply("Sorry, I don't have timezone support installed.") + else: + channel = trigger.group(2) + if not channel: + channel = trigger.sender + + channel = channel.strip() + + timezone = bot.db.get_channel_value(channel, 'timezone') + if timezone: + bot.say('%s\'s timezone: %s' % (channel, timezone)) + else: + bot.say('%s has no preferred timezone' % channel) + + +@commands('setchanneltimeformat', 'setctf') +@example('.setctf %Y-%m-%dT%T%z') +def update_channel_format(bot, trigger): + """ + Sets your preferred format for time. Uses the standard strftime format. You + can use http://strftime.net or your favorite search engine to learn more. + """ + if bot.privileges[trigger.sender][trigger.nick] < OP: + return + + tformat = trigger.group(2) + if not tformat: + bot.reply("What format do you want me to use? Try using" + " http://strftime.net to make one.") + + tz = get_timezone(bot.db, bot.config, None, None, trigger.sender) + + # Get old format as back-up + old_format = bot.db.get_channel_value(trigger.sender, 'time_format') + + # Save the new format in the database so we can test it. + bot.db.set_channel_value(trigger.sender, 'time_format', tformat) + + try: + timef = format_time(db=bot.db, zone=tz, channel=trigger.sender) + except: + bot.reply("That format doesn't work. Try using" + " http://strftime.net to make one.") + # New format doesn't work. Revert save in database. + bot.db.set_channel_value(trigger.sender, 'time_format', old_format) + return + bot.db.set_channel_value(trigger.sender, 'time_format', tformat) + bot.reply("Got it. Times in this channel will now appear as %s " + "unless a user has their own format set. (If the timezone" + " is wrong, you might try the settz and channeltz " + "commands)" % timef) + + +@commands('getchanneltimeformat', 'getctf') +@example('.getctf [channel]') +def get_channel_format(bot, trigger): + """ + Gets the channel's preferred time format, will return current channel's if + no channel name is given + """ + + channel = trigger.group(2) + if not channel: + channel = trigger.sender + + channel = channel.strip() + + tformat = bot.db.get_channel_value(channel, 'time_format') + if tformat: + bot.say('%s\'s time format: %s' % (channel, tformat)) + else: + bot.say('%s has no preferred time format' % channel) diff --git a/modules/countdown.py b/modules/countdown.py new file mode 100755 index 0000000..8d1b389 --- /dev/null +++ b/modules/countdown.py @@ -0,0 +1,39 @@ +# coding=utf-8 +""" +countdown.py - Sopel Countdown Module +Copyright 2011, Michael Yanovich, yanovich.net +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +import datetime + +from module import commands, NOLIMIT + + +@commands('countdown') +def generic_countdown(bot, trigger): + """ + .countdown - displays a countdown to a given date. + """ + text = trigger.group(2) + if not text: + bot.say("Please use correct format: .countdown 2012 12 21") + return NOLIMIT + text = trigger.group(2).split() + if text and (len(text) == 3 and text[0].isdigit() and text[1].isdigit() + and text[2].isdigit()): + try: + diff = (datetime.datetime(int(text[0]), int(text[1]), int(text[2])) + - datetime.datetime.today()) + except: + bot.say("Please use correct format: .countdown 2012 12 21") + return NOLIMIT + bot.say(str(diff.days) + " days, " + str(diff.seconds // 3600) + + " hours and " + + str(diff.seconds % 3600 // 60) + + " minutes until " + + text[0] + " " + text[1] + " " + text[2]) + else: + bot.say("Please use correct format: .countdown 2012 12 21") + return NOLIMIT diff --git a/modules/currency.py b/modules/currency.py new file mode 100755 index 0000000..2a077d8 --- /dev/null +++ b/modules/currency.py @@ -0,0 +1,51 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +from module import commands, example +import wolfram +import requests + + +@commands('cur', 'currency', 'exchange') +@example('.cur 20 EUR in USD') +def exchange(bot, trigger): + """Show the exchange rate between two currencies""" + if not trigger.group(2): + bot.say('You must provide a query.') + return + if not bot.config.wolfram.app_id: + bot.say('Wolfram|Alpha API app ID not configured.') + return + + lines = wolfram.wa_query(bot.config.wolfram.app_id, trigger.group(2), bot.config.wolfram.units) + bot.say(lines) + + +@commands('btc', 'bitcoin') +@example('.btc 20 EUR') +def bitcoin(bot, trigger): + args = trigger.args[1].split(' ') # because trigger.group() is stupid + if len(args) == 3: # two arguments were supplied + amount = args[1] + to = args[2] + if not amount.replace('.','').isdigit(): + bot.say("Stop being dumb.") + return + elif len(args) == 2: # one argument was supplied + if args[1].replace('.','').isdigit(): # argument is the amount + amount = args[1] + to = 'USD' + else: + to = args[1] + amount = 1 + else: # no arguments were supplied + amount = 1 + to = 'USD' + to = to.upper() + + res = requests.get("https://api.coindesk.com/v1/bpi/currentprice/{}.json".format(to), verify=True) + if res.text[:5] == "Sorry": + bot.say(res.text) + return + rate = res.json()['bpi'][to]['rate_float'] + calc = float(amount) * rate + bot.say('\x0310' + str(amount) + ' BTC\x03 = \x0312' + str(calc) + ' ' + to + ' (' + res.json()['bpi'][to]['description'] + ')') diff --git a/modules/dice.py b/modules/dice.py new file mode 100755 index 0000000..17128b3 --- /dev/null +++ b/modules/dice.py @@ -0,0 +1,259 @@ +# coding=utf-8 +""" +dice.py - Dice Module +Copyright 2010-2013, Dimitri "Tyrope" Molenaars, TyRope.nl +Copyright 2013, Ari Koivula, +Licensed under the Eiffel Forum License 2. + +http://sopel.chat/ +""" +from __future__ import unicode_literals, absolute_import, print_function, division +import random +import re +import operator + +import module +from tools.calculation import eval_equation + + +class DicePouch: + def __init__(self, num_of_die, type_of_die, addition): + """Initialize dice pouch and roll the dice. + + Args: + num_of_die: number of dice in the pouch. + type_of_die: how many faces the dice have. + addition: how much is added to the result of the dice. + """ + self.num = num_of_die + self.type = type_of_die + self.addition = addition + + self.dice = {} + self.dropped = {} + + self.roll_dice() + + def roll_dice(self): + """Roll all the dice in the pouch.""" + self.dice = {} + self.dropped = {} + for __ in range(self.num): + number = random.randint(1, self.type) + count = self.dice.setdefault(number, 0) + self.dice[number] = count + 1 + + def drop_lowest(self, n): + """Drop n lowest dice from the result. + + Args: + n: the number of dice to drop. + """ + + sorted_x = sorted(self.dice.items(), key=operator.itemgetter(0)) + + for i, count in sorted_x: + count = self.dice[i] + if n == 0: + break + elif n < count: + self.dice[i] = count - n + self.dropped[i] = n + break + else: + self.dice[i] = 0 + self.dropped[i] = count + n = n - count + + for i, count in self.dropped.items(): + if self.dice[i] == 0: + del self.dice[i] + + def get_simple_string(self): + """Return the values of the dice like (2+2+2[+1+1])+1.""" + dice = self.dice.items() + faces = ("+".join([str(face)] * times) for face, times in dice) + dice_str = "+".join(faces) + + dropped_str = "" + if self.dropped: + dropped = self.dropped.items() + dfaces = ("+".join([str(face)] * times) for face, times in dropped) + dropped_str = "[+%s]" % ("+".join(dfaces),) + + plus_str = "" + if self.addition: + plus_str = "{:+d}".format(self.addition) + + return "(%s%s)%s" % (dice_str, dropped_str, plus_str) + + def get_compressed_string(self): + """Return the values of the dice like (3x2[+2x1])+1.""" + dice = self.dice.items() + faces = ("%dx%d" % (times, face) for face, times in dice) + dice_str = "+".join(faces) + + dropped_str = "" + if self.dropped: + dropped = self.dropped.items() + dfaces = ("%dx%d" % (times, face) for face, times in dropped) + dropped_str = "[+%s]" % ("+".join(dfaces),) + + plus_str = "" + if self.addition: + plus_str = "{:+d}".format(self.addition) + + return "(%s%s)%s" % (dice_str, dropped_str, plus_str) + + def get_sum(self): + """Get the sum of non-dropped dice and the addition.""" + result = self.addition + for face, times in self.dice.items(): + result += face * times + return result + + def get_number_of_faces(self): + """Returns sum of different faces for dropped and not dropped dice + + This can be used to estimate, whether the result can be shown in + compressed form in a reasonable amount of space. + """ + return len(self.dice) + len(self.dropped) + + +def _roll_dice(bot, dice_expression): + result = re.search( + r""" + (?P-?\d*) + d + (?P-?\d+) + (v(?P-?\d+))? + $""", + dice_expression, + re.IGNORECASE | re.VERBOSE) + + dice_num = int(result.group('dice_num') or 1) + dice_type = int(result.group('dice_type')) + + # Dice can't have zero or a negative number of sides. + if dice_type <= 0: + bot.reply("I don't have any dice with %d sides. =(" % dice_type) + return None # Signal there was a problem + + # Can't roll a negative number of dice. + if dice_num < 0: + bot.reply("I'd rather not roll a negative amount of dice. =(") + return None # Signal there was a problem + + # Upper limit for dice should be at most a million. Creating a dict with + # more than a million elements already takes a noticeable amount of time + # on a fast computer and ~55kB of memory. + if dice_num > 1000: + bot.reply('I only have 1000 dice. =(') + return None # Signal there was a problem + + dice = DicePouch(dice_num, dice_type, 0) + + if result.group('drop_lowest'): + drop = int(result.group('drop_lowest')) + if drop >= 0: + dice.drop_lowest(drop) + else: + bot.reply("I can't drop the lowest %d dice. =(" % drop) + + return dice + + +@module.commands("roll") +@module.commands("dice") +@module.commands("d") +@module.priority("medium") +@module.example(".roll 3d1+1", 'You roll 3d1+1: (1+1+1)+1 = 4') +@module.example(".roll 3d1v2+1", 'You roll 3d1v2+1: (1[+1+1])+1 = 2') +@module.example(".roll 2d4", 'You roll 2d4: \(\d\+\d\) = \d', re=True) +@module.example(".roll 100d1", '[^:]*: \(100x1\) = 100', re=True) +@module.example(".roll 1001d1", 'I only have 1000 dice. =(') +@module.example(".roll 1d1 + 1d1", 'You roll 1d1 + 1d1: (1) + (1) = 2') +@module.example(".roll 1d1+1d1", 'You roll 1d1+1d1: (1)+(1) = 2') +def roll(bot, trigger): + """.dice XdY[vZ][+N], rolls dice and reports the result. + + X is the number of dice. Y is the number of faces in the dice. Z is the + number of lowest dice to be dropped from the result. N is the constant to + be applied to the end result. + """ + # This regexp is only allowed to have one captured group, because having + # more would alter the output of re.findall. + dice_regexp = r"-?\d*[dD]-?\d+(?:[vV]-?\d+)?" + + # Get a list of all dice expressions, evaluate them and then replace the + # expressions in the original string with the results. Replacing is done + # using string formatting, so %-characters must be escaped. + if not trigger.group(2): + return bot.reply("No dice to roll.") + arg_str = trigger.group(2) + dice_expressions = re.findall(dice_regexp, arg_str) + arg_str = arg_str.replace("%", "%%") + arg_str = re.sub(dice_regexp, "%s", arg_str) + + f = lambda dice_expr: _roll_dice(bot, dice_expr) + dice = list(map(f, dice_expressions)) + + if None in dice: + # Stop computing roll if there was a problem rolling dice. + return + + def _get_eval_str(dice): + return "(%d)" % (dice.get_sum(),) + + def _get_pretty_str(dice): + if dice.num <= 10: + return dice.get_simple_string() + elif dice.get_number_of_faces() <= 10: + return dice.get_compressed_string() + else: + return "(...)" + + eval_str = arg_str % (tuple(map(_get_eval_str, dice))) + pretty_str = arg_str % (tuple(map(_get_pretty_str, dice))) + + # Showing the actual error will hopefully give a better hint of what is + # wrong with the syntax than a generic error message. + try: + result = eval_equation(eval_str) + except Exception as e: + bot.reply("SyntaxError, eval(%s), %s" % (eval_str, e)) + return + + bot.reply("You roll %s: %s = %d" % ( + trigger.group(2), pretty_str, result)) + + +@module.commands("choice") +@module.commands("ch") +@module.commands("choose") +@module.priority("medium") +def choose(bot, trigger): + """ + .choice option1|option2|option3 - Makes a difficult choice easy. + """ + if not trigger.group(2): + return bot.reply('I\'d choose an option, but you didn\'t give me any.') + choices = [trigger.group(2)] + for delim in '|\\/,': + choices = trigger.group(2).split(delim) + if len(choices) > 1: + break + # Use a different delimiter in the output, to prevent ambiguity. + for show_delim in ',|/\\': + if show_delim not in trigger.group(2): + show_delim += ' ' + break + + pick = random.choice(choices) + return bot.reply('Your options: %s. My choice: %s' % (show_delim.join(choices), pick)) + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/echo.py b/modules/echo.py new file mode 100755 index 0000000..f52cb46 --- /dev/null +++ b/modules/echo.py @@ -0,0 +1,13 @@ +#! /usr/bin/env python3 +#-*- coding:utf-8 -*- +""" +Echo. +""" +import module + +@module.commands('echo') +@module.example('.echo balloons') +def echo(bot, trigger): + """Echos the given string.""" + if trigger.group(2): + bot.say(trigger.group(2)) diff --git a/modules/etymology.py b/modules/etymology.py new file mode 100755 index 0000000..2140c5b --- /dev/null +++ b/modules/etymology.py @@ -0,0 +1,97 @@ +# coding=utf-8 +""" +etymology.py - Sopel Etymology Module +Copyright 2007-9, Sean B. Palmer, inamidst.com +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +import re + +import requests + +from module import commands, example, NOLIMIT + +etyuri = 'http://etymonline.com/?term=%s' +etysearch = 'http://etymonline.com/?search=%s' + +r_definition = re.compile(r'(?ims)]*>.*?') +r_tag = re.compile(r'<(?!!)[^>]+>') +r_whitespace = re.compile(r'[\t\r\n ]+') + +abbrs = [ + 'cf', 'lit', 'etc', 'Ger', 'Du', 'Skt', 'Rus', 'Eng', 'Amer.Eng', 'Sp', + 'Fr', 'N', 'E', 'S', 'W', 'L', 'Gen', 'J.C', 'dial', 'Gk', + '19c', '18c', '17c', '16c', 'St', 'Capt', 'obs', 'Jan', 'Feb', 'Mar', + 'Apr', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'c', 'tr', 'e', 'g' +] +t_sentence = r'^.*?(?') + s = s.replace('<', '<') + s = s.replace('&', '&') + return s + + +def text(html): + html = r_tag.sub('', html) + html = r_whitespace.sub(' ', html) + return unescape(html).strip() + + +def etymology(word): + # @@ sbp, would it be possible to have a flag for .ety to get 2nd/etc + # entries? - http://swhack.com/logs/2006-07-19#T15-05-29 + + if len(word) > 25: + raise ValueError("Word too long: %s[...]" % word[:10]) + word = {'axe': 'ax/axe'}.get(word, word) + + bytes = requests.get(etyuri % word) + definitions = r_definition.findall(bytes) + + if not definitions: + return None + + defn = text(definitions[0]) + m = r_sentence.match(defn) + if not m: + return None + sentence = m.group(0) + + maxlength = 275 + if len(sentence) > maxlength: + sentence = sentence[:maxlength] + words = sentence[:-5].split(' ') + words.pop() + sentence = ' '.join(words) + ' [...]' + + sentence = '"' + sentence.replace('"', "'") + '"' + return sentence + ' - ' + (etyuri % word) + + +@commands('ety') +@example('.ety word') +def f_etymology(bot, trigger): + """Look up the etymology of a word""" + word = trigger.group(2) + + try: + result = etymology(word) + except IOError: + msg = "Can't connect to etymonline.com (%s)" % (etyuri % word) + bot.msg(trigger.sender, msg) + return NOLIMIT + except (AttributeError, TypeError): + result = None + + if result is not None: + bot.msg(trigger.sender, result) + else: + uri = etysearch % word + msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) + bot.msg(trigger.sender, msg) + return NOLIMIT diff --git a/modules/find.py b/modules/find.py new file mode 100755 index 0000000..ab77ef9 --- /dev/null +++ b/modules/find.py @@ -0,0 +1,139 @@ +# coding=utf-8 +"""Sopel Spelling correction module + +This module will fix spelling errors if someone corrects them +using the sed notation (s///) commonly found in vi/vim. +""" +# Copyright 2011, Michael Yanovich, yanovich.net +# Copyright 2013, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. +# Contributions from: Matt Meinwald and Morgan Goose +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +from tools import Identifier, SopelMemory +from module import rule, priority +from formatting import bold + + +def setup(bot): + bot.memory['find_lines'] = SopelMemory() + + +@rule('.*') +@priority('low') +def collectlines(bot, trigger): + """Create a temporary log of what people say""" + + # Don't log things in PM + if trigger.is_privmsg: + return + + # Add a log for the channel and nick, if there isn't already one + if trigger.sender not in bot.memory['find_lines']: + bot.memory['find_lines'][trigger.sender] = SopelMemory() + if Identifier(trigger.nick) not in bot.memory['find_lines'][trigger.sender]: + bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] = list() + + # Create a temporary list of the user's lines in a channel + templist = bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] + line = trigger.group() + if line.startswith("s/"): # Don't remember substitutions + return + elif line.startswith("\x01ACTION"): # For /me messages + line = line[:-1] + templist.append(line) + else: + templist.append(line) + + del templist[:-10] # Keep the log to 10 lines per person + + bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] = templist + + +#Match nick, s/find/replace/flags. Flags and nick are optional, nick can be +#followed by comma or colon, anything after the first space after the third +#slash is ignored, you can escape slashes with backslashes, and if you want to +#search for an actual backslash followed by an actual slash, you're shit out of +#luck because this is the fucking regex of death as it is. +@rule(r"""(?: + (\S+) # Catch a nick in group 1 + [:,]\s+)? # Followed by colon/comma and whitespace, if given + s/ # The literal s/ + ( # Group 2 is the thing to find + (?:\\/ | [^/])+ # One or more non-slashes or escaped slashes + )/( # Group 3 is what to replace with + (?:\\/ | [^/])* # One or more non-slashes or escaped slashes + ) + (?:/(\S+))? # Optional slash, followed by group 4 (flags) + """) +@priority('high') +def findandreplace(bot, trigger): + # Don't bother in PM + if trigger.is_privmsg: + return + + # Correcting other person vs self. + rnick = Identifier(trigger.group(1) or trigger.nick) + + search_dict = bot.memory['find_lines'] + # only do something if there is conversation to work with + if trigger.sender not in search_dict: + return + if Identifier(rnick) not in search_dict[trigger.sender]: + return + + #TODO rest[0] is find, rest[1] is replace. These should be made variables of + #their own at some point. + rest = [trigger.group(2), trigger.group(3)] + rest[0] = rest[0].replace(r'\/', '/') + rest[1] = rest[1].replace(r'\/', '/') + me = False # /me command + flags = (trigger.group(4) or '') + + # If g flag is given, replace all. Otherwise, replace once. + if 'g' in flags: + count = -1 + else: + count = 1 + + # repl is a lambda function which performs the substitution. i flag turns + # off case sensitivity. re.U turns on unicode replacement. + if 'i' in flags: + regex = re.compile(re.escape(rest[0]), re.U | re.I) + repl = lambda s: re.sub(regex, rest[1], s, count == 1) + else: + repl = lambda s: re.sub(rest[0], rest[1], s, count) + + # Look back through the user's lines in the channel until you find a line + # where the replacement works + new_phrase = None + for line in reversed(search_dict[trigger.sender][rnick]): + if line.startswith("\x01ACTION"): + me = True # /me command + line = line[8:] + else: + me = False + new_phrase = repl(line) + if new_phrase != line: # we are done + break + + if not new_phrase or new_phrase == line: + return # Didn't find anything + + # Save the new "edited" message. + action = (me and '\x01ACTION ') or '' # If /me message, prepend \x01ACTION + templist = search_dict[trigger.sender][rnick] + templist.append(action + new_phrase) + search_dict[trigger.sender][rnick] = templist + bot.memory['find_lines'] = search_dict + + # output + if not me: + new_phrase = '%s to say: %s' % (bold('meant'), new_phrase) + if trigger.group(1): + phrase = '%s thinks %s %s' % (trigger.nick, rnick, new_phrase) + else: + phrase = '%s %s' % (trigger.nick, new_phrase) + + bot.say(phrase) diff --git a/modules/hangman.py b/modules/hangman.py new file mode 100755 index 0000000..1bc778b --- /dev/null +++ b/modules/hangman.py @@ -0,0 +1,92 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Hangman. +""" +import random +import module + +class Hangman(): + def __init__(self): + self.running = False + + def newgame(self): + self.running = True + self.tries = 8 + self.word = self._PickWord() + self.working = [x for x in self.word] + self.blanks = list('_' * len(self.word)) + for n,char in enumerate(self.word): + if char == ' ': + self.blanks[n] = ' ' + + def _PickWord(self): + with open("/home/iou1name/.sopel/wordlist.txt",'r') as file: + lines = file.readlines() + wrd = list(lines[ random.randint(0, len(lines))-1 ].strip()) + return wrd + + def solve(self, guess): + if list(guess) == self.word: + self.running = False + self.blanks = self.word + return 'win' + + elif guess in self.word and len(guess) == 1: + while guess in self.working: + index = self.working.index(guess) + self.blanks[index] = guess + self.working[index] = '_' + return 'correct' + else: + self.tries = self.tries - 1 + if self.tries == 0: + self.running = False + self.blanks = self.word + return 'lose' + else: + return 'incorrect' + + def return_blanks(self): + return ''.join(self.blanks) + + +hangman = Hangman() + +@module.commands('hangman') +@module.example('.hangman') +def hangman_start(bot, trigger): + """Starts a game of hangman.""" + if hangman.running: + bot.reply("There is already a game running.") + return + + hangman.newgame() + bot.say(trigger.nick + " has started a game of hangman! Type .guess to guess a letter or the entire phrase.") + bot.say(hangman.return_blanks()) + + +@module.commands('guess') +@module.example('.guess a') +@module.example('.guess anus') +def guess(bot, trigger): + """Makes a guess in hangman. May either guess a single letter or the entire word/phrase.""" + if not hangman.running: + bot.reply('There is no game currently running. Use .hangman to start one') + return + + response = hangman.solve(trigger.group(2)) + + if response == 'win': + bot.say(trigger.nick + " has won!") + elif response == 'correct': + pass + elif response == 'lose': + bot.say("Game over.") + elif response == 'incorrect': + bot.reply("incorrect.") + bot.say( str(hangman.tries) + " tries left." ) + else: + bot.say('Fuck.') + + bot.say(hangman.return_blanks()) diff --git a/modules/help.py b/modules/help.py new file mode 100755 index 0000000..8ee9412 --- /dev/null +++ b/modules/help.py @@ -0,0 +1,51 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +help.py - Sopel Help Module +Copyright 2008, Sean B. Palmer, inamidst.com +Copyright © 2013, Elad Alfassa, +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import textwrap +import collections +import json + +from logger import get_logger +from module import commands, rule, example, priority + +logger = get_logger(__name__) + + +@rule('$nick' '(?i)(help|doc) +([A-Za-z]+)(?:\?+)?$') +@example('.help tell') +@commands('help', 'commands') +@priority('low') +def help(bot, trigger): + """Shows a command's documentation, and possibly an example.""" + if trigger.group(2): + name = trigger.group(2) + name = name.lower() + + if name in bot.doc: + newlines = [''] + lines = list(filter(None, bot.doc[name][0])) + lines = list(map(str.strip, lines)) + for line in lines: + newlines[-1] = newlines[-1] + ' ' + line + if line[-1] is '.': + newlines.append('') + newlines = list(map(str.strip, newlines)) + if bot.doc[name][1]: + newlines.append('Ex. ' + bot.doc[name][1]) + + for msg in newlines: + bot.say(msg) + else: + helps = list(bot.command_groups) + helps.sort() + msg = "Available commands: " + ', '.join(helps) + bot.say(msg) diff --git a/modules/ip.py b/modules/ip.py new file mode 100755 index 0000000..8ab2b8f --- /dev/null +++ b/modules/ip.py @@ -0,0 +1,136 @@ +# coding=utf-8 +"""GeoIP lookup module""" +# Copyright 2011, Dimitri Molenaars, TyRope.nl, +# Copyright © 2013, Elad Alfassa +# Licensed under the Eiffel Forum License 2. +import pygeoip +import socket +import os +import gzip + +urlretrieve = None +try: + from urllib import urlretrieve +except ImportError: + try: + # urlretrieve has been put under urllib.request in Python 3. + # It's also deprecated so this should probably be replaced with + # urllib2. + from urllib.request import urlretrieve + except ImportError: + pass + +from config.types import StaticSection, FilenameAttribute +from module import commands, example +from logger import get_logger + +LOGGER = get_logger(__name__) + + +class GeoipSection(StaticSection): + GeoIP_db_path = FilenameAttribute('GeoIP_db_path', directory=True) + """Path of the directory containing the GeoIP db files.""" + + +def configure(config): + config.define_section('ip', GeoipSection) + config.ip.configure_setting('GeoIP_db_path', + 'Path of the GeoIP db files') + + +def setup(bot=None): + if not bot: + return # Because of some weird pytest thing? + + bot.config.define_section('ip', GeoipSection) + + +def _decompress(source, target, delete_after_decompression=True): + """ Decompress a GZip file """ + f_in = gzip.open(source, 'rb') + f_out = open(target, 'wb') + f_out.writelines(f_in) + f_out.close() + f_in.close() + if delete_after_decompression: + os.remove(source) + + +def _find_geoip_db(bot): + """ Find the GeoIP database """ + config = bot.config + if config.ip.GeoIP_db_path: + cities_db = os.path.join(config.ip.GeoIP_db_path, 'GeoLiteCity.dat') + ipasnum_db = os.path.join(config.ip.GeoIP_db_path, 'GeoIPASNum.dat') + if os.path.isfile(cities_db) and os.path.isfile(ipasnum_db): + return config.ip.GeoIP_db_path + else: + LOGGER.warning( + 'GeoIP path configured but DB not found in configured path' + ) + if (os.path.isfile(os.path.join(bot.config.core.homedir, 'GeoLiteCity.dat')) and + os.path.isfile(os.path.join(bot.config.core.homedir, 'GeoIPASNum.dat'))): + return bot.config.core.homedir + elif (os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoLiteCity.dat')) and + os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoIPASNum.dat'))): + return '/usr/share/GeoIP' + elif urlretrieve: + LOGGER.warning('Downloading GeoIP database') + bot.say('Downloading GeoIP database, please wait...') + geolite_city_url = 'http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz' + geolite_ASN_url = 'http://download.maxmind.com/download/geoip/database/asnum/GeoIPASNum.dat.gz' + geolite_city_filepath = os.path.join(bot.config.core.homedir, 'GeoLiteCity.dat.gz') + geolite_ASN_filepath = os.path.join(bot.config.core.homedir, 'GeoIPASNum.dat.gz') + urlretrieve(geolite_city_url, geolite_city_filepath) + urlretrieve(geolite_ASN_url, geolite_ASN_filepath) + _decompress(geolite_city_filepath, geolite_city_filepath[:-3]) + _decompress(geolite_ASN_filepath, geolite_ASN_filepath[:-3]) + return bot.config.core.homedir + else: + return False + + +@commands('iplookup', 'ip') +@example('.ip 8.8.8.8', + r'[IP/Host Lookup] Hostname: google-public-dns-a.google.com | Location: United States | Region: CA | ISP: AS15169 Google Inc.', + re=True, + ignore='Downloading GeoIP database, please wait...') +def ip(bot, trigger): + """IP Lookup tool""" + if not trigger.group(2): + return bot.reply("No search term.") + query = trigger.group(2) + db_path = _find_geoip_db(bot) + if db_path is False: + LOGGER.error('Can\'t find (or download) usable GeoIP database') + bot.say('Sorry, I don\'t have a GeoIP database to use for this lookup') + return False + geolite_city_filepath = os.path.join(_find_geoip_db(bot), 'GeoLiteCity.dat') + geolite_ASN_filepath = os.path.join(_find_geoip_db(bot), 'GeoIPASNum.dat') + gi_city = pygeoip.GeoIP(geolite_city_filepath) + gi_org = pygeoip.GeoIP(geolite_ASN_filepath) + host = socket.getfqdn(query) + response = "[IP/Host Lookup] Hostname: %s" % host + try: + response += " | Location: %s" % gi_city.country_name_by_name(query) + except AttributeError: + response += ' | Location: Unknown' + except socket.gaierror: + return bot.say('[IP/Host Lookup] Unable to resolve IP/Hostname') + + region_data = gi_city.region_by_name(query) + try: + region = region_data['region_code'] # pygeoip >= 0.3.0 + except KeyError: + region = region_data['region_name'] # pygeoip < 0.3.0 + if region: + response += " | Region: %s" % region + + isp = gi_org.org_by_name(query) + response += " | ISP: %s" % isp + bot.say(response) + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/ipython.py b/modules/ipython.py new file mode 100755 index 0000000..ea1a1b2 --- /dev/null +++ b/modules/ipython.py @@ -0,0 +1,78 @@ +# coding=utf-8 +""" +ipython.py - sopel ipython console! +Copyright © 2014, Elad Alfassa +Licensed under the Eiffel Forum License 2. + +Sopel: http://sopel.chat/ +""" +from __future__ import unicode_literals, absolute_import, print_function, division +import sys +import module +if sys.version_info.major >= 3: + # Backup stderr/stdout wrappers + old_stdout = sys.stdout + old_stderr = sys.stderr + + # IPython wants actual stderr and stdout. In Python 2, it only needed that + # when actually starting the console, but in Python 3 it seems to need that + # on import as well + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ +try: + import IPython + if hasattr(IPython, 'terminal'): + from IPython.terminal.embed import InteractiveShellEmbed + else: + from IPython.frontend.terminal.embed import InteractiveShellEmbed +finally: + if sys.version_info.major >= 3: + # Restore stderr/stdout wrappers + sys.stdout = old_stdout + sys.stderr = old_stderr + +console = None + + +@module.commands('console') +def interactive_shell(bot, trigger): + """ + Starts an interactive IPython console + """ + global console + if not trigger.admin: + bot.say('Only admins can start the interactive console') + return + if 'iconsole_running' in bot.memory and bot.memory['iconsole_running']: + bot.say('Console already running') + return + if not sys.__stdout__.isatty(): + bot.say('A tty is required to start the console') + return + if bot._daemon: + bot.say('Can\'t start console when running as a daemon') + return + + # Backup stderr/stdout wrappers + old_stdout = sys.stdout + old_stderr = sys.stderr + + # IPython wants actual stderr and stdout + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + + banner1 = 'Sopel interactive shell (embedded IPython)' + banner2 = '`bot` and `trigger` are available. To exit, type exit' + exitmsg = 'Interactive shell closed' + + console = InteractiveShellEmbed(banner1=banner1, banner2=banner2, + exit_msg=exitmsg) + + bot.memory['iconsole_running'] = True + bot.say('console started') + console() + bot.memory['iconsole_running'] = False + + # Restore stderr/stdout wrappers + sys.stdout = old_stdout + sys.stderr = old_stderr diff --git a/modules/isup.py b/modules/isup.py new file mode 100755 index 0000000..1d22336 --- /dev/null +++ b/modules/isup.py @@ -0,0 +1,46 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Checks if a website is up by sending a HEAD request to it. +""" +import requests + +from module import commands, require_chanmsg +from tools import web + +@require_chanmsg(message="It's not polite to whisper.") +@commands('isup') +def isup(bot, trigger): + """Queries the given url to check if it's up or not.""" + url = trigger.group(2) + + if not url: + return bot.reply("What URL do you want to check?") + if url.startswith("192") and not trigger.owner: + return bot.reply("Do not violate the LAN.") + + if not web.secCheck(bot, url): + return bot.reply("Known malicious url. Ignoring.") + + if not url.startswith("http"): + url = "http://" + url + + try: + res = requests.head(url, verify=True) + except (requests.exceptions.MissingSchema, + requests.exceptions.InvalidSchema): + return bot.say("Missing or invalid schema. Check the URL.") + except requests.exceptions.ConnectionError: + return bot.say("Connection error. Are you sure this is a real website?") + except requests.exceptions.InvalidURL: + return bot.say("Invalid URL.") + except Exception as e: + print(e) + return bot.say("Listen buddy. I don't know what you're doing, but \ + you're not doing it right.") + + try: + res.raise_for_status() + return bot.say(url + " appears to be working from here.") + except requests.exceptions.HTTPError: + return bot.say(url + " looks down from here.") diff --git a/modules/light.py b/modules/light.py new file mode 100755 index 0000000..84fa6ac --- /dev/null +++ b/modules/light.py @@ -0,0 +1,41 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Long live the Internet of Things! +""" +import requests + +import module + + +@module.require_admin +@module.commands('lamp') +def lampToggle(bot, trigger): + """ + Turns my lamp on and off. The glory of IoT! + """ + try: + res = requests.get("http://192.168.1.12/gpio?0=toggle", timeout=10) + except requests.exceptions.ReadTimeout: + return bot.say("Connection error. Timeout reached.") + except requests.exceptions.ConnectionError: + return bot.say("Connection error. Is the unit dead?") + if res.text[32] == 'L': + bot.say("Lamp is now OFF.") + elif res.text[32] == 'H': + bot.say("Lamp is now ON.") + + +#@module.require_admin +@module.commands('roomtemp') +def roomTemp(bot, trigger): + """ + Gets the temperature of my room. + """ + try: + res = requests.get("http://192.168.1.25/", timeout=10) + except requests.exceptions.ReadTimeout: + return bot.say("Connection error. Timeout reached.") + except requests.exceptions.ConnectionError: + return bot.say("Connection error. Is the unit dead?") + bot.say(res.text) diff --git a/modules/lmgtfy.py b/modules/lmgtfy.py new file mode 100755 index 0000000..74057ef --- /dev/null +++ b/modules/lmgtfy.py @@ -0,0 +1,18 @@ +# coding=utf-8 +""" +lmgtfy.py - Sopel Let me Google that for you module +Copyright 2013, Dimitri Molenaars http://tyrope.nl/ +Licensed under the Eiffel Forum License 2. + +http://sopel.chat/ +""" +from module import commands + + +@commands('lmgtfy', 'lmgify', 'gify', 'gtfy') +def googleit(bot, trigger): + """Let me just... google that for you.""" + #No input + if not trigger.group(2): + return bot.say('http://google.com/') + bot.say('http://lmgtfy.com/?q=' + trigger.group(2).replace(' ', '+')) diff --git a/modules/meetbot.py b/modules/meetbot.py new file mode 100755 index 0000000..af461c2 --- /dev/null +++ b/modules/meetbot.py @@ -0,0 +1,432 @@ +# coding=utf-8 +""" +meetbot.py - Sopel meeting logger module +Copyright © 2012, Elad Alfassa, +Licensed under the Eiffel Forum License 2. + +This module is an attempt to implement at least some of the functionallity of Debian's meetbot +""" +import time +import os +from config.types import StaticSection, FilenameAttribute, ValidatedAttribute +from module import example, commands, rule, priority +from tools import Ddict, Identifier +import codecs + + +class MeetbotSection(StaticSection): + meeting_log_path = FilenameAttribute('meeting_log_path', directory=True, + default='~/www/meetings') + """Path to meeting logs storage directory + + This should be an absolute path, accessible on a webserver.""" + meeting_log_baseurl = ValidatedAttribute( + 'meeting_log_baseurl', + default='http://localhost/~sopel/meetings' + ) + """Base URL for the meeting logs directory""" + + +def configure(config): + config.define_section('meetbot', MeetbotSection) + config.meetbot.configure_setting( + 'meeting_log_path', + 'Enter the directory to store logs in.' + ) + config.meetbot.configure_setting( + 'meeting_log_baseurl', + 'Enter the base URL for the meeting logs.', + ) + + +def setup(bot): + bot.config.define_section('meetbot', MeetbotSection) + + +meetings_dict = Ddict(dict) # Saves metadata about currently running meetings +""" +meetings_dict is a 2D dict. + +Each meeting should have: +channel +time of start +head (can stop the meeting, plus all abilities of chairs) +chairs (can add infolines to the logs) +title +current subject +comments (what people who aren't voiced want to add) + +Using channel as the meeting ID as there can't be more than one meeting in a channel at the same time. +""" +meeting_log_path = '' # To be defined on meeting start as part of sanity checks, used by logging functions so we don't have to pass them bot +meeting_log_baseurl = '' # To be defined on meeting start as part of sanity checks, used by logging functions so we don't have to pass them bot +meeting_actions = {} # A dict of channels to the actions that have been created in them. This way we can have .listactions spit them back out later on. + + +#Get the logfile name for the meeting in the requested channel +#Used by all logging functions +def figure_logfile_name(channel): + if meetings_dict[channel]['title'] is 'Untitled meeting': + name = 'untitled' + else: + name = meetings_dict[channel]['title'] + # Real simple sluggifying. This bunch of characters isn't exhaustive, but + # whatever. It's close enough for most situations, I think. + for c in ' ./\\:*?"<>|&*`': + name = name.replace(c, '-') + timestring = time.strftime('%Y-%m-%d-%H:%M', time.gmtime(meetings_dict[channel]['start'])) + filename = timestring + '_' + name + return filename + + +#Start HTML log +def logHTML_start(channel): + logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') + timestring = time.strftime('%Y-%m-%d %H:%M', time.gmtime(meetings_dict[channel]['start'])) + title = '%s at %s, %s' % (meetings_dict[channel]['title'], channel, timestring) + logfile.write('\n\n\n\n%TITLE%\n\n\n

%TITLE%

\n'.replace('%TITLE%', title)) + logfile.write('

Meeting started by %s

    \n' % meetings_dict[channel]['head']) + logfile.close() + + +#Write a list item in the HTML log +def logHTML_listitem(item, channel): + logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') + logfile.write('
  • ' + item + '
  • \n') + logfile.close() + + +#End the HTML log +def logHTML_end(channel): + logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') + current_time = time.strftime('%H:%M:%S', time.gmtime()) + logfile.write('
\n

Meeting ended at %s UTC

\n' % current_time) + plainlog_url = meeting_log_baseurl + channel + '/' + figure_logfile_name(channel) + '.log' + logfile.write('Full log' % plainlog_url) + logfile.write('\n\n') + logfile.close() + + +#Write a string to the plain text log +def logplain(item, channel): + current_time = time.strftime('%H:%M:%S', time.gmtime()) + logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.log', 'a', encoding='utf-8') + logfile.write('[' + current_time + '] ' + item + '\r\n') + logfile.close() + + +#Check if a meeting is currently running +def ismeetingrunning(channel): + try: + if meetings_dict[channel]['running']: + return True + else: + return False + except: + return False + + +#Check if nick is a chair or head of the meeting +def ischair(nick, channel): + try: + if nick.lower() == meetings_dict[channel]['head'] or nick.lower() in meetings_dict[channel]['chairs']: + return True + else: + return False + except: + return False + + +#Start meeting (also preforms all required sanity checks) +@commands('startmeeting') +@example('.startmeeting title or .startmeeting') +def startmeeting(bot, trigger): + """ + Start a meeting. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, there is already a meeting in progress here!') + return + if trigger.is_privmsg: + bot.say('Can only start meetings in channels') + return + #Start the meeting + meetings_dict[trigger.sender]['start'] = time.time() + if not trigger.group(2): + meetings_dict[trigger.sender]['title'] = 'Untitled meeting' + else: + meetings_dict[trigger.sender]['title'] = trigger.group(2) + meetings_dict[trigger.sender]['head'] = trigger.nick.lower() + meetings_dict[trigger.sender]['running'] = True + meetings_dict[trigger.sender]['comments'] = [] + + global meeting_log_path + meeting_log_path = bot.config.meetbot.meeting_log_path + if not meeting_log_path.endswith('/'): + meeting_log_path = meeting_log_path + '/' + global meeting_log_baseurl + meeting_log_baseurl = bot.config.meetbot.meeting_log_baseurl + if not meeting_log_baseurl.endswith('/'): + meeting_log_baseurl = meeting_log_baseurl + '/' + if not os.path.isdir(meeting_log_path + trigger.sender): + try: + os.makedirs(meeting_log_path + trigger.sender) + except Exception: + bot.say("Can't create log directory for this channel, meeting not started!") + meetings_dict[trigger.sender] = Ddict(dict) + raise + return + #Okay, meeting started! + logplain('Meeting started by ' + trigger.nick.lower(), trigger.sender) + logHTML_start(trigger.sender) + meeting_actions[trigger.sender] = [] + bot.say('Meeting started! use .action, .agreed, .info, .chairs, .subject and .comments to control the meeting. to end the meeting, type .endmeeting') + bot.say('Users without speaking permission can use .comment ' + + trigger.sender + ' followed by their comment in a PM with me to ' + 'vocalize themselves.') + + +#Change the current subject (will appear as

in the HTML log) +@commands('subject') +@example('.subject roll call') +def meetingsubject(bot, trigger): + """ + Change the meeting subject. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('what is the subject?') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + meetings_dict[trigger.sender]['current_subject'] = trigger.group(2) + logfile = codecs.open(meeting_log_path + trigger.sender + '/' + figure_logfile_name(trigger.sender) + '.html', 'a', encoding='utf-8') + logfile.write('

' + trigger.group(2) + '

    ') + logfile.close() + logplain('Current subject: ' + trigger.group(2) + ', (set by ' + trigger.nick + ')', trigger.sender) + bot.say('Current subject: ' + trigger.group(2)) + + +#End the meeting +@commands('endmeeting') +@example('.endmeeting') +def endmeeting(bot, trigger): + """ + End a meeting. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + meeting_length = time.time() - meetings_dict[trigger.sender]['start'] + #TODO: Humanize time output + bot.say("Meeting ended! total meeting length %d seconds" % meeting_length) + logHTML_end(trigger.sender) + htmllog_url = meeting_log_baseurl + trigger.sender + '/' + figure_logfile_name(trigger.sender) + '.html' + logplain('Meeting ended by %s, total meeting length %d seconds' % (trigger.nick, meeting_length), trigger.sender) + bot.say('Meeting minutes: ' + htmllog_url) + meetings_dict[trigger.sender] = Ddict(dict) + del meeting_actions[trigger.sender] + + +#Set meeting chairs (people who can control the meeting) +@commands('chairs') +@example('.chairs Tyrope Jason elad') +def chairs(bot, trigger): + """ + Set the meeting chairs. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('Who are the chairs?') + return + if trigger.nick.lower() == meetings_dict[trigger.sender]['head']: + meetings_dict[trigger.sender]['chairs'] = trigger.group(2).lower().split(' ') + chairs_readable = trigger.group(2).lower().replace(' ', ', ') + logplain('Meeting chairs are: ' + chairs_readable, trigger.sender) + logHTML_listitem('Meeting chairs are: ' + chairs_readable, trigger.sender) + bot.say('Meeting chairs are: ' + chairs_readable) + else: + bot.say("Only meeting head can set chairs") + + +#Log action item in the HTML log +@commands('action') +@example('.action elad will develop a meetbot') +def meetingaction(bot, trigger): + """ + Log an action in the meeting log + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('try .action someone will do something') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + logplain('ACTION: ' + trigger.group(2), trigger.sender) + logHTML_listitem('Action: ' + trigger.group(2), trigger.sender) + meeting_actions[trigger.sender].append(trigger.group(2)) + bot.say('ACTION: ' + trigger.group(2)) + + +@commands('listactions') +@example('.listactions') +def listactions(bot, trigger): + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + for action in meeting_actions[trigger.sender]: + bot.say('ACTION: ' + action) + + +#Log agreed item in the HTML log +@commands('agreed') +@example('.agreed Bowties are cool') +def meetingagreed(bot, trigger): + """ + Log an agreement in the meeting log. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('try .action someone will do something') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + logplain('AGREED: ' + trigger.group(2), trigger.sender) + logHTML_listitem('Agreed: ' + trigger.group(2), trigger.sender) + bot.say('AGREED: ' + trigger.group(2)) + + +#Log link item in the HTML log +@commands('link') +@example('.link http://example.com') +def meetinglink(bot, trigger): + """ + Log a link in the meeing log. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('try .action someone will do something') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + link = trigger.group(2) + if not link.startswith("http"): + link = "http://" + link + try: + #title = find_title(link, verify=bot.config.core.verify_ssl) + pass + except: + title = '' + logplain('LINK: %s [%s]' % (link, title), trigger.sender) + logHTML_listitem('%s' % (link, title), trigger.sender) + bot.say('LINK: ' + link) + + +#Log informational item in the HTML log +@commands('info') +@example('.info all board members present') +def meetinginfo(bot, trigger): + """ + Log an informational item in the meeting log + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + bot.say('Can\'t do that, start meeting first') + return + if not trigger.group(2): + bot.say('try .info some informative thing') + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + logplain('INFO: ' + trigger.group(2), trigger.sender) + logHTML_listitem(trigger.group(2), trigger.sender) + bot.say('INFO: ' + trigger.group(2)) + + +#called for every single message +#Will log to plain text only +@rule('(.*)') +@priority('low') +def log_meeting(bot, trigger): + if not ismeetingrunning(trigger.sender): + return + if trigger.startswith('.endmeeting') or trigger.startswith('.chairs') or trigger.startswith('.action') or trigger.startswith('.info') or trigger.startswith('.startmeeting') or trigger.startswith('.agreed') or trigger.startswith('.link') or trigger.startswith('.subject'): + return + logplain('<' + trigger.nick + '> ' + trigger, trigger.sender) + + +@commands('comment') +def take_comment(bot, trigger): + """ + Log a comment, to be shown with other comments when a chair uses .comments. + Intended to allow commentary from those outside the primary group of people + in the meeting. + + Used in private message only, as `.comment <#channel> ` + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not trigger.sender.is_nick(): + return + if not trigger.group(4): # <2 arguements were given + bot.say('Usage: .comment <#channel> ') + return + + target, message = trigger.group(2).split(None, 1) + target = Identifier(target) + if not ismeetingrunning(target): + bot.say("There's not currently a meeting in that channel.") + else: + meetings_dict[trigger.group(3)]['comments'].append((trigger.nick, message)) + bot.say("Your comment has been recorded. It will be shown when the" + " chairs tell me to show the comments.") + bot.msg(meetings_dict[trigger.group(3)]['head'], "A new comment has been recorded.") + + +@commands('comments') +def show_comments(bot, trigger): + """ + Show the comments that have been logged for this meeting with .comment. + https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module + """ + if not ismeetingrunning(trigger.sender): + return + if not ischair(trigger.nick, trigger.sender): + bot.say('Only meeting head or chairs can do that') + return + comments = meetings_dict[trigger.sender]['comments'] + if comments: + msg = 'The following comments were made:' + bot.say(msg) + logplain('<%s> %s' % (bot.nick, msg), trigger.sender) + for comment in comments: + msg = '<%s> %s' % comment + bot.say(msg) + logplain('<%s> %s' % (bot.nick, msg), trigger.sender) + meetings_dict[trigger.sender]['comments'] = [] + else: + bot.say('No comments have been logged.') diff --git a/modules/movie.py b/modules/movie.py new file mode 100755 index 0000000..aea2f4b --- /dev/null +++ b/modules/movie.py @@ -0,0 +1,161 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +This module exracts various information from imbd. +It also contains functionality for the local movie database. +""" +import os +import threading +import random + +import requests +import bs4 + +import module + +def setup(bot): + bot.memory['movie_lock'] = threading.Lock() + + +@module.commands('movie', 'tmdb') +@module.example('.movie ThisTitleDoesNotExist', '[MOVIE] Movie not found!') +@module.example('.movie Citizen Kane', '[MOVIE] Title: Citizen Kane | Year: \ + 1941 | Rating: 8.4 | Genre: Drama, Mystery | IMDB Link: \ + http://imdb.com/title/tt0033467') +def movie(bot, trigger): + """ + Returns some information about a movie, like Title, Year, Rating, + Genre and IMDB Link. + """ + if not trigger.group(2): + return + word = trigger.group(2).strip().replace(" ", "+") + + api_key = bot.config.movie.tmdb_api_key + uri = "https://api.themoviedb.org/3/search/movie?" + \ + f"api_key={api_key}&query={word}" + data = requests.get(uri, timeout=30, verify=True).json() + try: + data = data['results'][0] + except IndexError: + return bot.reply("No results found.") + except KeyError: + print(data) + return bot.reply("An error. Please notify an adult.") + + uri = "https://api.themoviedb.org/3/genre/movie/list?" + \ + f"api_key={api_key}&language=en-US" + genres = requests.get(uri, timeout=30, verify=True).json() + try: + genres = genres['genres'] + except KeyError: + return bot.reply("An error. Please notify an adult.") + + movieGenres = [] + for genre in genres: + if genre['id'] in data['genre_ids']: + movieGenres.append(genre['name']) + + msg = "[\x0304MOVIE\x03] \x0310Title\x03: \x0312" + data['title'] + \ + "\x03 | \x0310Year\x03: \x0308" + data['release_date'][:4] + \ + "\x03 | \x0310Rating\x03: \x0312" + str(data['vote_average']) + \ + "\x03 | \x0310Genre\x03: \x0312" + ", ".join(movieGenres) + \ + "\x03 | \x0310TMDb Link\x03: \x0307" + \ + "https://www.themoviedb.org/movie/" + str(data['id']) + + msg += "\n\x0310Theater release date\x03: \x0308" + data['release_date'] + \ + "\x03 | \x0310Physical release date\x03: \x0308" + \ + phyiscalRelease(word, data['id'], api_key) + + msg += "\n\x0310Overview\x03: " + data['overview'] + + bot.say(msg) + + +def phyiscalRelease(word, tmdb_id=None, api_key=None): + """ + Attempts to find a physical US release from TMDb. Failing that, it will + find a date on www.dvdreleasedates.com. + """ + if not tmdb_id: + return "Feature not yet implemented." + + uri = f"https://api.themoviedb.org/3/movie/{tmdb_id}/release_dates?" + \ + f"api_key={api_key}" + res = requests.get(uri, timeout=30, verify=True) + res.raise_for_status() + try: + releases = res.json()['results'] + except KeyError: + return "No results found." + + for release in releases: + if release["iso_3166_1"] != "US": + continue + for date in release["release_dates"]: + if date["type"] == 5: + return date["release_date"][:10] + #return "No physical US release found." + return dvdReleaseDates(word) + + +def dvdReleaseDates(word): + """ + Scrapes www.dvdsreleasedates.com for physical release dates. + """ + uri = f"http://www.dvdsreleasedates.com/search.php?searchStr={word}" + res = requests.get(uri, timeout=30, verify=True) + soup = bs4.BeautifulSoup(res.text, "html.parser") + rDate = soup.title.text[soup.title.text.rfind("Date")+4:] + if not rDate: + rDate = "Not announced." + elif rDate.startswith("rch results for"): + rDate = "Not found." + return rDate.strip() + + +@module.commands('pickmovie', 'getmovie') +@module.example('.pickmovie', 'Commandos') +def pickMovie(bot, trigger): + """ + Picks a random movie title out of the database. + """ + bot.memory['movie_lock'].acquire() + conn = bot.db.connect() + cur = conn.cursor() + cur.execute("SELECT * FROM movie WHERE times_watched < 1 AND shitpost = 0") + movieList = cur.fetchall() + conn.close() + + roll = random.randint(0, len(movieList)-1) + bot.memory['movie_lock'].release() + bot.reply(movieList[roll][0]) + + +@module.require_admin +@module.commands('addmovie') +@module.example('.addmovie Gay Niggers From Outer Space') +def addMovie(bot, trigger): + """ + Adds the specified movie to the movie database. + """ + bot.memory['movie_lock'].acquire() + movie = trigger.group(2) + conn = bot.db.connect() + cur = conn.cursor() + insert = (movie, trigger.nick) + try: + cur.execute("INSERT INTO movie (movie_title, added_by) VALUES(?,?)", + insert) + confirm = "Added movie: " + movie + except sqlite3.IntegrityError: + confirm = "Error: " + movie + " is already in the database." + conn.commit() + conn.close() + bot.memory['movie_lock'].release() + bot.say(confirm) + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/ping.py b/modules/ping.py new file mode 100755 index 0000000..96969a8 --- /dev/null +++ b/modules/ping.py @@ -0,0 +1,28 @@ +# coding=utf-8 +""" +ping.py - Sopel Ping Module +Author: Sean B. Palmer, inamidst.com +About: http://sopel.chat +""" +import random + +from module import rule, priority, thread + + +@rule(r'(?i)(hi|hello|hey),? $nickname[ \t]*$') +def hello(bot, trigger): + greeting = random.choice(('Hi', 'Hey', 'Hello')) + punctuation = random.choice(('', '!')) + bot.say(greeting + ' ' + trigger.nick + punctuation) + + +@rule(r'(?i)(Fuck|Screw) you,? $nickname[ \t]*$') +def rude(bot, trigger): + bot.say('Watch your mouth, ' + trigger.nick + ', or I\'ll tell your mother!') + + +@rule('$nickname!') +@priority('high') +@thread(False) +def interjection(bot, trigger): + bot.say(trigger.nick + '!') diff --git a/modules/pingall.py b/modules/pingall.py new file mode 100755 index 0000000..1d9aea6 --- /dev/null +++ b/modules/pingall.py @@ -0,0 +1,17 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Pings everyone in the channel. +""" +import module + +@module.commands('pingall', 'names') +def pingAll(bot, trigger): + """ + Says the nick of everyone in the channel. Great way to get thier + attention, or just annoy them. + """ + msg = "" + for name, priv in bot.privileges[trigger.sender].items(): + msg += name + ' ' + bot.say(msg.strip()) diff --git a/modules/rand.py b/modules/rand.py new file mode 100755 index 0000000..6bffe95 --- /dev/null +++ b/modules/rand.py @@ -0,0 +1,48 @@ +# coding=utf-8 +""" +rand.py - Rand Module +Copyright 2013, Ari Koivula, +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" + +from module import commands, example +import random +import sys + + +@commands('rand') +@example('.rand 2', r'random\(0, 2\) = (0|1|2)', re=True, repeat=10) +@example('.rand -1 -1', 'random(-1, -1) = -1') +@example('.rand', r'random\(0, \d+\) = \d+', re=True) +@example('.rand 99 10', r'random\(10, 99\) = \d\d', re=True, repeat=10) +@example('.rand 10 99', r'random\(10, 99\) = \d\d', re=True, repeat=10) +def rand(bot, trigger): + """Replies with a random number between first and second argument.""" + arg1 = trigger.group(3) + arg2 = trigger.group(4) + + try: + if arg2 is not None: + low = int(arg1) + high = int(arg2) + elif arg1 is not None: + low = 0 + high = int(arg1) + else: + low = 0 + high = sys.maxsize + except (ValueError, TypeError): + return bot.reply("Arguments must be of integer type") + + if low > high: + low, high = high, low + + number = random.randint(low, high) + bot.reply("random(%d, %d) = %d" % (low, high, number)) + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/reload.py b/modules/reload.py new file mode 100755 index 0000000..ce1a339 --- /dev/null +++ b/modules/reload.py @@ -0,0 +1,110 @@ +# coding=utf-8 +""" +reload.py - Sopel Module Reloader Module +Copyright 2008, Sean B. Palmer, inamidst.com +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import collections +import sys +import time +from tools import iteritems +import loader +import module +import subprocess + + +def load_module(bot, name, path, type_): + module, mtime = loader.load_module(name, path, type_) + relevant_parts = loader.clean_module(module, bot.config) + + bot.register(*relevant_parts) + + # TODO sys.modules[name] = module + if hasattr(module, 'setup'): + module.setup(bot) + + modified = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(mtime)) + + bot.reply('%r (version: %s)' % (module, modified)) + + +@module.require_admin +@module.commands("reload") +@module.priority("low") +@module.thread(False) +def f_reload(bot, trigger): + """Reloads a module, for use by admins only.""" + + name = trigger.group(2) + + if not name or name == '*' or name.upper() == 'ALL THE THINGS': + bot._callables = { + 'high': collections.defaultdict(list), + 'medium': collections.defaultdict(list), + 'low': collections.defaultdict(list) + } + bot._command_groups = collections.defaultdict(list) + bot.setup() + return bot.reply('done') + + if name not in sys.modules: + return bot.reply('%s: not loaded, try the `load` command' % name) + + old_module = sys.modules[name] + + old_callables = {} + for obj_name, obj in iteritems(vars(old_module)): + bot.unregister(obj) + + # Also remove all references to sopel callables from top level of the + # module, so that they will not get loaded again if reloading the + # module does not override them. + for obj_name in old_callables.keys(): + delattr(old_module, obj_name) + + # Also delete the setup function + if hasattr(old_module, "setup"): + delattr(old_module, "setup") + + modules = loader.enumerate_modules(bot.config) + path, type_ = modules[name] + load_module(bot, name, path, type_) + + +@module.require_admin +@module.commands('update') +def f_update(bot, trigger): + + """Pulls the latest versions of all modules from Git""" + proc = subprocess.Popen('/usr/bin/git pull', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=True) + bot.reply(proc.communicate()[0]) + + f_reload(bot, trigger) + + +@module.require_admin +@module.commands("load") +@module.priority("low") +@module.thread(False) +def f_load(bot, trigger): + """Loads a module, for use by admins only.""" + + name = trigger.group(2) + path = '' + if not name: + return bot.reply('Load what?') + + if name in sys.modules: + return bot.reply('Module already loaded, use reload') + + mods = loader.enumerate_modules(bot.config) + if name not in mods: + return bot.reply('Module %s not found' % name) + path, type_ = mods[name] + load_module(bot, name, path, type_) diff --git a/modules/remind.py b/modules/remind.py new file mode 100755 index 0000000..7c5d5f7 --- /dev/null +++ b/modules/remind.py @@ -0,0 +1,229 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +remind.py - Sopel Reminder Module +Copyright 2011, Sean B. Palmer, inamidst.com +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import os +import re +import time +import threading +import collections +import codecs +from datetime import datetime +from module import commands, example, NOLIMIT +import tools +from tools.time import get_timezone, format_time + +try: + import pytz +except: + pytz = None + + +def filename(self): + name = self.nick + '-' + self.config.core.host + '.reminders.db' + return os.path.join(self.config.core.homedir, name) + + +def load_database(name): + data = {} + if os.path.isfile(name): + f = codecs.open(name, 'r', encoding='utf-8') + for line in f: + unixtime, channel, nick, message = line.split('\t') + message = message.rstrip('\n') + t = int(float(unixtime)) # WTFs going on here? + reminder = (channel, nick, message) + try: + data[t].append(reminder) + except KeyError: + data[t] = [reminder] + f.close() + return data + + +def dump_database(name, data): + f = codecs.open(name, 'w', encoding='utf-8') + for unixtime, reminders in tools.iteritems(data): + for channel, nick, message in reminders: + f.write('%s\t%s\t%s\t%s\n' % (unixtime, channel, nick, message)) + f.close() + + +def setup(bot): + bot.rfn = filename(bot) + bot.rdb = load_database(bot.rfn) + + def monitor(bot): + time.sleep(5) + while True: + now = int(time.time()) + unixtimes = [int(key) for key in bot.rdb] + oldtimes = [t for t in unixtimes if t <= now] + if oldtimes: + for oldtime in oldtimes: + for (channel, nick, message) in bot.rdb[oldtime]: + if message: + bot.msg(channel, nick + ': ' + message) + else: + bot.msg(channel, nick + '!') + del bot.rdb[oldtime] + dump_database(bot.rfn, bot.rdb) + time.sleep(2.5) + + targs = (bot,) + t = threading.Thread(target=monitor, args=targs) + t.start() + +scaling = collections.OrderedDict([ + ('years', 365.25 * 24 * 3600), + ('year', 365.25 * 24 * 3600), + ('yrs', 365.25 * 24 * 3600), + ('y', 365.25 * 24 * 3600), + + ('months', 29.53059 * 24 * 3600), + ('month', 29.53059 * 24 * 3600), + ('mo', 29.53059 * 24 * 3600), + + ('weeks', 7 * 24 * 3600), + ('week', 7 * 24 * 3600), + ('wks', 7 * 24 * 3600), + ('wk', 7 * 24 * 3600), + ('w', 7 * 24 * 3600), + + ('days', 24 * 3600), + ('day', 24 * 3600), + ('d', 24 * 3600), + + ('hours', 3600), + ('hour', 3600), + ('hrs', 3600), + ('hr', 3600), + ('h', 3600), + + ('minutes', 60), + ('minute', 60), + ('mins', 60), + ('min', 60), + ('m', 60), + + ('seconds', 1), + ('second', 1), + ('secs', 1), + ('sec', 1), + ('s', 1), +]) + +periods = '|'.join(scaling.keys()) + + +@commands('remind') +@example('.remind 3h45m Go to class') +def remind(bot, trigger): + """Gives you a reminder in the given amount of time.""" + if not trigger.group(2): + bot.say("Missing arguments for reminder command.") + return NOLIMIT + if trigger.group(3) and not trigger.group(4): + bot.say("No message given for reminder.") + return NOLIMIT + duration = 0 + message = filter(None, re.split('(\d+(?:\.\d+)? ?(?:(?i)' + periods + ')) ?', + trigger.group(2))[1:]) + reminder = '' + stop = False + for piece in message: + grp = re.match('(\d+(?:\.\d+)?) ?(.*) ?', piece) + if grp and not stop: + length = float(grp.group(1)) + factor = scaling.get(grp.group(2).lower(), 60) + duration += length * factor + else: + reminder = reminder + piece + stop = True + if duration == 0: + return bot.reply("Sorry, didn't understand the input.") + + if duration % 1: + duration = int(duration) + 1 + else: + duration = int(duration) + timezone = get_timezone( + bot.db, bot.config, None, trigger.nick, trigger.sender) + create_reminder(bot, trigger, duration, reminder, timezone) + + +@commands('at') +@example('.at 13:47 Do your homework!') +def at(bot, trigger): + """ + Gives you a reminder at the given time. Takes hh:mm:ssTimezone + message. Timezone is any timezone Sopel takes elsewhere; the best choices + are those from the tzdb; a list of valid options is available at + http://sopel.chat/tz . The seconds and timezone are optional. + """ + if not trigger.group(2): + bot.say("No arguments given for reminder command.") + return NOLIMIT + if trigger.group(3) and not trigger.group(4): + bot.say("No message given for reminder.") + return NOLIMIT + regex = re.compile(r'(\d+):(\d+)(?::(\d+))?([^\s\d]+)? (.*)') + match = regex.match(trigger.group(2)) + if not match: + bot.reply("Sorry, but I didn't understand your input.") + return NOLIMIT + hour, minute, second, tz, message = match.groups() + if not second: + second = '0' + + if pytz: + timezone = get_timezone(bot.db, bot.config, tz, + trigger.nick, trigger.sender) + if not timezone: + timezone = 'UTC' + now = datetime.now(pytz.timezone(timezone)) + at_time = datetime(now.year, now.month, now.day, + int(hour), int(minute), int(second), + tzinfo=now.tzinfo) + timediff = at_time - now + else: + if tz and tz.upper() != 'UTC': + bot.reply("I don't have timzeone support installed.") + return NOLIMIT + now = datetime.now() + at_time = datetime(now.year, now.month, now.day, + int(hour), int(minute), int(second)) + timediff = at_time - now + + duration = timediff.seconds + + if duration < 0: + duration += 86400 + create_reminder(bot, trigger, duration, message, 'UTC') + + +def create_reminder(bot, trigger, duration, message, tz): + t = int(time.time()) + duration + reminder = (trigger.sender, trigger.nick, message) + try: + bot.rdb[t].append(reminder) + except KeyError: + bot.rdb[t] = [reminder] + + dump_database(bot.rfn, bot.rdb) + + if duration >= 60: + remind_at = datetime.utcfromtimestamp(t) + timef = format_time(bot.db, bot.config, tz, trigger.nick, + trigger.sender, remind_at) + + bot.reply('Okay, will remind at %s' % timef) + else: + bot.reply('Okay, will remind in %s secs' % duration) diff --git a/modules/resistor.py b/modules/resistor.py new file mode 100755 index 0000000..7a9fd72 --- /dev/null +++ b/modules/resistor.py @@ -0,0 +1,46 @@ +#! /usr/bin/env python3 +# coding=utf-8 +""" +Resistor color band codes. +""" +import module + +@module.commands('resist') +@module.example('.resist 10k', 'brown black orange gold') +def resist(bot, trigger): + """Displays the color band code of a resistor for the given resistance.""" + suffix = {'k': 1000, 'm': 10000000} + digit = {'0': 'black', '1': 'brown', '2': 'red', '3': 'orange', '4': 'yellow', + '5': 'green', '6': 'blue', '7': 'violet', '8': 'grey', '9': 'white', + '-1': 'gold', '-2': 'silver'} + + if not trigger.group(2)[-1].isdigit(): + value = trigger.group(2)[:-1] + else: + value = trigger.group(2) + + try: + value = float(value) + except ValueError: + return 'Invalid input' + + if not trigger.group(2)[-1].isdigit(): + value = value * suffix[trigger.group(2)[-1]] + valueStr = str(value) + + if value >= 10: + colorCode = digit[valueStr[0]] + " " + digit[valueStr[1]] + " " + else: + colorCode = digit[valueStr[2]] + " " + digit['0'] + " " + + if value < 0.1: + return "Value to small. Just like your dick." + elif value < 1: + colorCode = colorCode + digit['-2'] + elif value < 10: + colorCode = colorCode + digit['-1'] + else: + colorCode = colorCode + digit[str(len(valueStr)-4)] + + colorCode = colorCode + " gold" + bot.say(colorCode) \ No newline at end of file diff --git a/modules/safety.py b/modules/safety.py new file mode 100755 index 0000000..5fe1f9c --- /dev/null +++ b/modules/safety.py @@ -0,0 +1,198 @@ +# coding=utf-8 +""" +safety.py - Alerts about malicious URLs +Copyright © 2014, Elad Alfassa, +Licensed under the Eiffel Forum License 2. + +This module uses virustotal.com +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import requests +from config.types import StaticSection, ValidatedAttribute, ListAttribute +from formatting import color, bold +from logger import get_logger +from module import OP +import tools +import sys +import json +import time +import os.path +import re +import module + +if sys.version_info.major > 2: + unicode = str + from urllib.request import urlretrieve + from urllib.parse import urlparse +else: + from urllib import urlretrieve + from urlparse import urlparse + +LOGGER = get_logger(__name__) + +vt_base_api_url = 'https://www.virustotal.com/vtapi/v2/url/' +malware_domains = set() +known_good = [] + + +class SafetySection(StaticSection): + enabled_by_default = ValidatedAttribute('enabled_by_default', bool, default=True) + """Enable URL safety in all channels where it isn't explicitly disabled.""" + known_good = ListAttribute('known_good') + """List of "known good" domains to ignore.""" + vt_api_key = ValidatedAttribute('vt_api_key') + """Optional VirusTotal API key.""" + + +def configure(config): + config.define_section('safety', SafetySection) + config.safety.configure_setting( + 'enabled_by_default', + "Enable URL safety in channels that don't specifically disable it?", + ) + config.safety.configure_setting( + 'known_good', + 'Enter any domains to whitelist', + ) + config.safety.configure_setting( + 'vt_api_key', + "Optionally, enter a VirusTotal API key to improve malicious URL " + "protection.\nOtherwise, only the Malwarebytes DB will be used." + ) + + +def setup(bot): + bot.config.define_section('safety', SafetySection) + + bot.memory['safety_cache'] = tools.SopelMemory() + for item in bot.config.safety.known_good: + known_good.append(re.compile(item, re.I)) + + loc = os.path.join(bot.config.homedir, 'malwaredomains.txt') + if os.path.isfile(loc): + if os.path.getmtime(loc) < time.time() - 24 * 60 * 60 * 7: + # File exists but older than one week, update + _download_malwaredomains_db(loc) + else: + _download_malwaredomains_db(loc) + with open(loc, 'r') as f: + for line in f: + clean_line = unicode(line).strip().lower() + if clean_line != '': + malware_domains.add(clean_line) + + +def _download_malwaredomains_db(path): + print('Downloading malwaredomains db...') + urlretrieve('http://mirror1.malwaredomains.com/files/justdomains', path) + + +@module.rule('(?u).*(https?://\S+).*') +@module.priority('high') +def url_handler(bot, trigger): + """ Check for malicious URLs """ + check = True # Enable URL checking + strict = False # Strict mode: kick on malicious URL + positives = 0 # Number of engines saying it's malicious + total = 0 # Number of total engines + use_vt = True # Use VirusTotal + check = bot.config.safety.enabled_by_default + if check is None: + # If not set, assume default + check = True + # DB overrides config: + setting = bot.db.get_channel_value(trigger.sender, 'safety') + if setting is not None: + if setting == 'off': + return # Not checking + elif setting in ['on', 'strict', 'local', 'local strict']: + check = True + if setting == 'strict' or setting == 'local strict': + strict = True + if setting == 'local' or setting == 'local strict': + use_vt = False + + if not check: + return # Not overriden by DB, configured default off + + netloc = urlparse(trigger.group(1)).netloc + if any(regex.search(netloc) for regex in known_good): + return # Whitelisted + + apikey = bot.config.safety.vt_api_key + try: + if apikey is not None and use_vt: + payload = {'resource': unicode(trigger), + 'apikey': apikey, + 'scan': '1'} + + if trigger not in bot.memory['safety_cache']: + result = requests.post(vt_base_api_url + 'report', payload) + if sys.version_info.major > 2: + result = result.decode('utf-8') + result = json.loads(result) + age = time.time() + data = {'positives': result['positives'], + 'total': result['total'], + 'age': age} + bot.memory['safety_cache'][trigger] = data + if len(bot.memory['safety_cache']) > 1024: + _clean_cache(bot) + else: + print('using cache') + result = bot.memory['safety_cache'][trigger] + positives = result['positives'] + total = result['total'] + except Exception: + LOGGER.debug('Error from checking URL with VT.', exc_info=True) + pass # Ignoring exceptions with VT so MalwareDomains will always work + + if unicode(netloc).lower() in malware_domains: + # malwaredomains is more trustworthy than some VT engines + # therefor it gets a weight of 10 engines when calculating confidence + positives += 10 + total += 10 + + if positives > 1: + # Possibly malicious URL detected! + confidence = '{}%'.format(round((positives / total) * 100)) + msg = 'link posted by %s is possibly malicious ' % bold(trigger.nick) + msg += '(confidence %s - %s/%s)' % (confidence, positives, total) + bot.say('[' + bold(color('WARNING', 'red')) + '] ' + msg) + if strict: + bot.write(['KICK', trigger.sender, trigger.nick, + 'Posted a malicious link']) + + +@module.commands('safety') +def toggle_safety(bot, trigger): + """ Set safety setting for channel """ + if not trigger.admin and bot.privileges[trigger.sender][trigger.nick] < OP: + bot.reply('Only channel operators can change safety settings') + return + allowed_states = ['strict', 'on', 'off', 'local', 'local strict'] + if not trigger.group(2) or trigger.group(2).lower() not in allowed_states: + options = ' / '.join(allowed_states) + bot.reply('Available options: %s' % options) + return + + channel = trigger.sender.lower() + bot.db.set_channel_value(channel, 'safety', trigger.group(2).lower()) + bot.reply('Safety is now set to "%s" on this channel' % trigger.group(2)) + + +# Clean the cache every day, also when > 1024 entries +@module.interval(24 * 60 * 60) +def _clean_cache(bot): + """ Cleanup old entries in URL cache """ + # TODO probably should be using locks here, to make sure stuff doesn't + # explode + oldest_key_age = 0 + oldest_key = '' + for key, data in tools.iteritems(bot.memory['safety_cache']): + if data['age'] > oldest_key_age: + oldest_key_age = data['age'] + oldest_key = key + if oldest_key in bot.memory['safety_cache']: + del bot.memory['safety_cache'][oldest_key] diff --git a/modules/scramble.py b/modules/scramble.py new file mode 100755 index 0000000..41371fc --- /dev/null +++ b/modules/scramble.py @@ -0,0 +1,79 @@ +# coding=utf-8 +""" +Scramble. +""" +import random +import module + +class Scramble(): + def __init__(self): + self.running = False + + def newgame(self): + self.running = True + self.word = self._PickWord() + self.shuffled = [x for x in self.word] + random.shuffle(self.shuffled) + + def _PickWord(self): + with open("/home/iou1name/.sopel/words6.txt",'r') as file: + lines = file.readlines() + wrd = list(lines[ random.randint(0, len(lines))-1 ].strip()) + return wrd + + def gameover(self): + self.running = False + self.shuffled = self.word + + +def isAnagram(givenWord, givenGuess): + word = [x for x in givenWord] + guess = [x for x in givenGuess] + with open('/home/iou1name/.sopel/words6.txt', 'r') as file: + words = file.readlines() + if not ''.join(guess)+'\n' in words: + return "notaword" + del words + + for char in word: + if char in guess: + guess.pop(guess.index(char)) + else: + return 'incorrect' + return 'correct' + + +scramble = Scramble() + +@module.commands('scramble') +@module.example('.scramble') +def scramble_start(bot, trigger): + """Starts a game of scramble.""" + if scramble.running: + bot.reply("There is already a game running.") + return + + scramble.newgame() + bot.say(trigger.nick + " has started a game of scramble! Type .sc to guess the solution.") + bot.say(''.join(scramble.shuffled)) + + +@module.commands('sc') +@module.example('.sc anus') +def guess(bot, trigger): + """Makes a guess in scramble.""" + if not scramble.running: + bot.reply('There is no game currently running. Use .scramble to start one') + return + + response = isAnagram(scramble.word, list(trigger.group(2))) + + if response == 'correct': + bot.say(trigger.nick + " has won!") + scramble.gameover() + elif response == 'notaword': + bot.say("I don't recognize that word.") + elif response == 'incorrect': + bot.reply("incorrect.") + + bot.say(''.join(scramble.shuffled)) diff --git a/modules/search.py b/modules/search.py new file mode 100755 index 0000000..03bd5e0 --- /dev/null +++ b/modules/search.py @@ -0,0 +1,128 @@ +# coding=utf-8 +# Copyright 2008-9, Sean B. Palmer, inamidst.com +# Copyright 2012, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +import requests +from module import commands, example +import json +import sys + +if sys.version_info.major < 3: + from urllib import quote_plus +else: + from urllib.parse import quote_plus + + +def formatnumber(n): + """Format a number with beautiful commas.""" + parts = list(str(n)) + for i in range((len(parts) - 3), 0, -3): + parts.insert(i, ',') + return ''.join(parts) + +r_bing = re.compile(r'

    ') + + +def duck_search(query): + query = query.replace('!', '') + uri = 'http://duckduckgo.com/html/?q=%s&kl=uk-en' % query + bytes = requests.get(uri) + if 'requests-result' in bytes: # filter out the adds on top of the page + bytes = bytes.split('requests-result')[1] + m = r_duck.search(bytes) + if m: + return requests.decode(m.group(1)) + +# Alias google_search to duck_search +google_search = duck_search + + +def duck_api(query): + if '!bang' in query.lower(): + return 'https://duckduckgo.com/bang.html' + + # This fixes issue #885 (https://github.com/sopel-irc/sopel/issues/885) + # It seems that duckduckgo api redirects to its Instant answer API html page + # if the query constains special charactares that aren't urlencoded. + # So in order to always get a JSON response back the query is urlencoded + query = quote_plus(query) + uri = 'http://api.duckduckgo.com/?q=%s&format=json&no_html=1&no_redirect=1' % query + results = json.loads(requests.get(uri)) + if results['Redirect']: + return results['Redirect'] + else: + return None + + +@commands('duck', 'ddg', 'g') +@example('.duck privacy or .duck !mcwiki obsidian') +def duck(bot, trigger): + """Queries Duck Duck Go for the specified input.""" + query = trigger.group(2) + if not query: + return bot.reply('.ddg what?') + + # If the API gives us something, say it and stop + result = duck_api(query) + if result: + bot.reply(result) + return + + # Otherwise, look it up on the HTMl version + uri = duck_search(query) + + if uri: + bot.reply(uri) + if 'last_seen_url' in bot.memory: + bot.memory['last_seen_url'][trigger.sender] = uri + else: + bot.reply("No results found for '%s'." % query) + + +@commands('search') +@example('.search nerdfighter') +def search(bot, trigger): + """Searches Bing and Duck Duck Go.""" + if not trigger.group(2): + return bot.reply('.search for what?') + query = trigger.group(2) + bu = bing_search(query) or '-' + du = duck_search(query) or '-' + + if bu == du: + result = '%s (b, d)' % bu + else: + if len(bu) > 150: + bu = '(extremely long link)' + if len(du) > 150: + du = '(extremely long link)' + result = '%s (b), %s (d)' % (bu, du) + + bot.reply(result) + + +@commands('suggest') +def suggest(bot, trigger): + """Suggest terms starting with given input""" + if not trigger.group(2): + return bot.reply("No query term.") + query = trigger.group(2) + uri = 'http://requestssitedev.de/temp-bin/suggest.pl?q=' + answer = requests.get(uri + query.replace('+', '%2B')) + if answer: + bot.say(answer) + else: + bot.reply('Sorry, no result.') diff --git a/modules/seen.py b/modules/seen.py new file mode 100755 index 0000000..d7ccad3 --- /dev/null +++ b/modules/seen.py @@ -0,0 +1,50 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +When was this user last seen. +""" +import time +import datetime +from tools import Identifier +from tools.time import get_timezone, format_time, relativeTime +from module import commands, rule, priority, thread + + +@commands('seen') +def seen(bot, trigger): + """Reports when and where the user was last seen.""" + if not trigger.group(2): + bot.say(".seen - Reports when was last seen.") + return + nick = trigger.group(2).strip() + if nick == bot.nick: + bot.reply("I'm right here!") + return + timestamp = bot.db.get_nick_value(nick, 'seen_timestamp') + if timestamp: + channel = bot.db.get_nick_value(nick, 'seen_channel') + message = bot.db.get_nick_value(nick, 'seen_message') + action = bot.db.get_nick_value(nick, 'seen_action') + + tz = get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) + saw = datetime.datetime.utcfromtimestamp(timestamp) + timestamp = format_time(bot.db, bot.config, tz, trigger.nick, + trigger.sender, saw) + + reltime = relativeTime(bot, nick, timestamp) + msg = "Last heard from \x0308{}\x03 at {} (\x0312{}\x03) in \x0312{}".format(nick, timestamp, reltime, channel) + + bot.reply(msg) + else: + bot.say("I haven't seen \x0308{}".format(nick)) + + +@thread(False) +@rule('(.*)') +@priority('low') +def note(bot, trigger): + if not trigger.is_privmsg: + bot.db.set_nick_value(trigger.nick, 'seen_timestamp', time.time()) + bot.db.set_nick_value(trigger.nick, 'seen_channel', trigger.sender) + bot.db.set_nick_value(trigger.nick, 'seen_message', trigger) + bot.db.set_nick_value(trigger.nick, 'seen_action', 'intent' in trigger.tags) diff --git a/modules/spellcheck.py b/modules/spellcheck.py new file mode 100755 index 0000000..8c45368 --- /dev/null +++ b/modules/spellcheck.py @@ -0,0 +1,53 @@ +# coding=utf-8 +""" +spellcheck.py - Sopel spell check Module +Copyright © 2012, Elad Alfassa, +Copyright © 2012, Lior Ramati +Licensed under the Eiffel Forum License 2. + +http://sopel.chat + +This module relies on pyenchant, on Fedora and Red Hat based system, it can be found in the package python-enchant +""" +try: + import enchant +except ImportError: + enchant = None +from module import commands, example + + +@commands('spellcheck', 'spell') +@example('.spellcheck stuff') +def spellcheck(bot, trigger): + """ + Says whether the given word is spelled correctly, and gives suggestions if + it's not. + """ + if not enchant: + bot.say("Missing pyenchant module.") + if not trigger.group(2): + return + word = trigger.group(2).rstrip() + if " " in word: + bot.say("One word at a time, please") + return + dictionary = enchant.Dict("en_US") + dictionary_uk = enchant.Dict("en_GB") + # I don't want to make anyone angry, so I check both American and British English. + if dictionary_uk.check(word): + if dictionary.check(word): + bot.say(word + " is spelled correctly") + else: + bot.say(word + " is spelled correctly (British)") + elif dictionary.check(word): + bot.say(word + " is spelled correctly (American)") + else: + msg = word + " is not spelled correctly. Maybe you want one of these spellings:" + sugWords = [] + for suggested_word in dictionary.suggest(word): + sugWords.append(suggested_word) + for suggested_word in dictionary_uk.suggest(word): + sugWords.append(suggested_word) + for suggested_word in sorted(set(sugWords)): # removes duplicates + msg = msg + " '" + suggested_word + "'," + bot.say(msg) diff --git a/modules/tell.py b/modules/tell.py new file mode 100755 index 0000000..c29bca2 --- /dev/null +++ b/modules/tell.py @@ -0,0 +1,174 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Leave a message for someone. +""" +import os +from datetime import datetime +import threading +import sys + +from tools import Identifier, iterkeys +from tools.time import get_timezone, format_time, relativeTime +from module import commands, nickname_commands, rule, priority, example + +maximum = 40 + + +def loadReminders(fname, lock): + lock.acquire() + try: + result = {} + f = open(fname) + for line in f: + line = line.strip() + if sys.version_info.major < 3: + line = line.decode('utf-8') + if line: + try: + tellee, teller, timenow, msg = line.split('\t', 4) + except ValueError: + continue # @@ hmm + result.setdefault(tellee, []).append((teller, timenow, msg)) + f.close() + finally: + lock.release() + return result + + +def dumpReminders(fname, data, lock): + lock.acquire() + try: + f = open(fname, 'w') + for tellee in iterkeys(data): + for remindon in data[tellee]: + line = '\t'.join((tellee,) + remindon) + try: + to_write = line + '\n' + if sys.version_info.major < 3: + to_write = to_write.encode('utf-8') + f.write(to_write) + except IOError: + break + try: + f.close() + except IOError: + pass + finally: + lock.release() + return True + + +def setup(bot): + fname = bot.nick + '-' + bot.config.core.host + '.tell.db' + bot.tell_filename = os.path.join(bot.config.core.homedir, fname) + if not os.path.exists(bot.tell_filename): + try: + f = open(bot.tell_filename, 'w') + except OSError: + pass + else: + f.write('') + f.close() + bot.memory['tell_lock'] = threading.Lock() + bot.memory['reminders'] = loadReminders(bot.tell_filename, bot.memory['tell_lock']) + + +@commands('tell') +@example('.tell Embolalia you broke something again.') +def f_remind(bot, trigger): + """Give someone a message the next time they're seen""" + teller = trigger.nick + + if not trigger.group(3): + bot.reply("Tell whom?") + return + + tellee = trigger.group(3).rstrip('.,:;') + msg = trigger.group(2).lstrip(tellee).lstrip() + + if not msg: + bot.reply("Tell %s what?" % tellee) + return + + tellee = Identifier(tellee) + + if not os.path.exists(bot.tell_filename): + return + + if len(tellee) > 20: + return bot.reply('That nickname is too long.') + if tellee == bot.nick: + return bot.reply("I'm here now, you can tell me whatever you want!") + + if not tellee in (Identifier(teller), bot.nick, 'me'): + tz = get_timezone(bot.db, bot.config, None, tellee) + timenow = format_time(bot.db, bot.config, tz, tellee) + bot.memory['tell_lock'].acquire() + try: + if not tellee in bot.memory['reminders']: + bot.memory['reminders'][tellee] = [(teller, timenow, msg)] + else: + bot.memory['reminders'][tellee].append((teller, timenow, msg)) + finally: + bot.memory['tell_lock'].release() + + response = "I'll pass that on when %s is around." % tellee + + bot.reply(response) + elif Identifier(teller) == tellee: + bot.say('You can tell yourself that.') + else: + bot.say("Hey, I'm not as stupid as Monty you know!") + + dumpReminders(bot.tell_filename, bot.memory['reminders'], bot.memory['tell_lock']) # @@ tell + + +def getReminders(bot, channel, key, tellee): + lines = [] + template = "%s: \x0310%s\x03 (\x0308%s\x03) %s [\x0312%s\x03]" + + bot.memory['tell_lock'].acquire() + try: + for (teller, telldate, msg) in bot.memory['reminders'][key]: + lines.append(template % (tellee, msg, teller, telldate, relativeTime(bot, tellee, telldate))) + + try: + del bot.memory['reminders'][key] + except KeyError: + bot.msg(channel, 'Er...') + finally: + bot.memory['tell_lock'].release() + return lines + + +@rule('(.*)') +@priority('low') +def message(bot, trigger): + + tellee = trigger.nick + channel = trigger.sender + + if not os.path.exists(bot.tell_filename): + return + + reminders = [] + remkeys = list(reversed(sorted(bot.memory['reminders'].keys()))) + + for remkey in remkeys: + if not remkey.endswith('*') or remkey.endswith(':'): + if tellee == remkey: + reminders.extend(getReminders(bot, channel, remkey, tellee)) + elif tellee.startswith(remkey.rstrip('*:')): + reminders.extend(getReminders(bot, channel, remkey, tellee)) + + for line in reminders[:maximum]: + bot.say(line) + + if reminders[maximum:]: + bot.say('Further messages sent privately') + for line in reminders[maximum:]: + bot.msg(tellee, line) + + if len(bot.memory['reminders'].keys()) != remkeys: + dumpReminders(bot.tell_filename, bot.memory['reminders'], bot.memory['tell_lock']) # @@ tell diff --git a/modules/tld.py b/modules/tld.py new file mode 100755 index 0000000..658ad3f --- /dev/null +++ b/modules/tld.py @@ -0,0 +1,69 @@ +# coding=utf-8 +""" +tld.py - Sopel TLD Module +Copyright 2009-10, Michael Yanovich, yanovich.net +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +from module import commands, example +import re +import sys +import requests +if sys.version_info.major >= 3: + unicode = str + +uri = 'https://en.wikipedia.org/wiki/List_of_Internet_top-level_domains' +r_tag = re.compile(r'<(?!!)[^>]+>') + + +@commands('tld') +@example('.tld ru') +def gettld(bot, trigger): + """Show information about the given Top Level Domain.""" + page = requests.get(uri).text + tld = trigger.group(2) + if tld[0] == '.': + tld = tld[1:] + search = r'(?i)\.{0}\n(\.{0}\n(.*)\n([A-Za-z0-9].*?)\n]*>(.*?)\n]*>(.*?)\n' + search = search.format(tld) + re_country = re.compile(search) + matches = re_country.findall(page) + if matches: + matches = list(matches[0]) + i = 0 + while i < len(matches): + matches[i] = r_tag.sub("", matches[i]) + i += 1 + desc = matches[2] + if len(desc) > 400: + desc = desc[:400] + "..." + reply = "%s -- %s. IDN: %s, DNSSEC: %s" % (matches[1], desc, + matches[3], matches[4]) + bot.reply(reply) + else: + search = r'.{0}\n(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n' + search = search.format(unicode(tld)) + re_country = re.compile(search) + matches = re_country.findall(page) + if matches: + matches = matches[0] + dict_val = dict() + dict_val["country"], dict_val["expl"], dict_val["notes"], dict_val["idn"], dict_val["dnssec"], dict_val["sld"] = matches + for key in dict_val: + if dict_val[key] == " ": + dict_val[key] = "N/A" + dict_val[key] = r_tag.sub('', dict_val[key]) + if len(dict_val["notes"]) > 400: + dict_val["notes"] = dict_val["notes"][:400] + "..." + reply = "%s (%s, %s). IDN: %s, DNSSEC: %s, SLD: %s" % (dict_val["country"], dict_val["expl"], dict_val["notes"], dict_val["idn"], dict_val["dnssec"], dict_val["sld"]) + else: + reply = "No matches found for TLD: {0}".format(unicode(tld)) + bot.reply(reply) diff --git a/modules/translate.py b/modules/translate.py new file mode 100755 index 0000000..1fbde30 --- /dev/null +++ b/modules/translate.py @@ -0,0 +1,208 @@ +# coding=utf-8 +""" +translate.py - Sopel Translation Module +Copyright 2008, Sean B. Palmer, inamidst.com +Copyright © 2013-2014, Elad Alfassa +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +import json +import random +import sys +import requests + +from module import rule, commands, priority, example + +mangle_lines = {} +if sys.version_info.major >= 3: + unicode = str + + +def translate(text, in_lang='auto', out_lang='en', verify_ssl=True): + raw = False + if unicode(out_lang).endswith('-raw'): + out_lang = out_lang[:-4] + raw = True + + headers = { + 'User-Agent': 'Mozilla/5.0' + + '(X11; U; Linux i686)' + + 'Gecko/20071127 Firefox/2.0.0.11' + } + + query = { + "client": "gtx", + "sl": in_lang, + "tl": out_lang, + "dt": "t", + "q": text, + } + url = "http://translate.googleapis.com/translate_a/single" + result = requests.get(url, params=query, timeout=40, headers=headers, + verify=verify_ssl).text + + if result == '[,,""]': + return None, in_lang + + while ',,' in result: + result = result.replace(',,', ',null,') + result = result.replace('[,', '[null,') + + data = json.loads(result) + + if raw: + return str(data), 'en-raw' + + try: + language = data[2] # -2][0][0] + except: + language = '?' + + return ''.join(x[0] for x in data[0]), language + + +@rule(u'$nickname[,:]\s+(?:([a-z]{2}) +)?(?:([a-z]{2}|en-raw) +)?["“](.+?)["”]\? *$') +@example('$nickname: "mon chien"? or $nickname: fr "mon chien"?') +@priority('low') +def tr(bot, trigger): + """Translates a phrase, with an optional language hint.""" + in_lang, out_lang, phrase = trigger.groups() + + if (len(phrase) > 350) and (not trigger.admin): + return bot.reply('Phrase must be under 350 characters.') + + if phrase.strip() == '': + return bot.reply('You need to specify a string for me to translate!') + + in_lang = in_lang or 'auto' + out_lang = out_lang or 'en' + + if in_lang != out_lang: + msg, in_lang = translate(phrase, in_lang, out_lang, + verify_ssl=bot.config.core.verify_ssl) + if sys.version_info.major < 3 and isinstance(msg, str): + msg = msg.decode('utf-8') + if msg: + msg = web.decode(msg) # msg.replace(''', "'") + msg = '"%s" (%s to %s, translate.google.com)' % (msg, in_lang, out_lang) + else: + msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (in_lang, out_lang) + + bot.reply(msg) + else: + bot.reply('Language guessing failed, so try suggesting one!') + + +@commands('translate', 'tr') +@example('.tr :en :fr my dog', '"mon chien" (en to fr, translate.google.com)') +@example('.tr היי', '"Hey" (iw to en, translate.google.com)') +@example('.tr mon chien', '"my dog" (fr to en, translate.google.com)') +def tr2(bot, trigger): + """Translates a phrase, with an optional language hint.""" + command = trigger.group(2) + + if not command: + return bot.reply('You did not give me anything to translate') + + def langcode(p): + return p.startswith(':') and (2 < len(p) < 10) and p[1:].isalpha() + + args = ['auto', 'en'] + + for i in range(2): + if ' ' not in command: + break + prefix, cmd = command.split(' ', 1) + if langcode(prefix): + args[i] = prefix[1:] + command = cmd + phrase = command + + if (len(phrase) > 350) and (not trigger.admin): + return bot.reply('Phrase must be under 350 characters.') + + if phrase.strip() == '': + return bot.reply('You need to specify a string for me to translate!') + + src, dest = args + if src != dest: + msg, src = translate(phrase, src, dest, + verify_ssl=bot.config.core.verify_ssl) + if sys.version_info.major < 3 and isinstance(msg, str): + msg = msg.decode('utf-8') + if msg: + msg = web.decode(msg) # msg.replace(''', "'") + msg = '"%s" (%s to %s, translate.google.com)' % (msg, src, dest) + else: + msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (src, dest) + + bot.reply(msg) + else: + bot.reply('Language guessing failed, so try suggesting one!') + + +def get_random_lang(long_list, short_list): + random_index = random.randint(0, len(long_list) - 1) + random_lang = long_list[random_index] + if random_lang not in short_list: + short_list.append(random_lang) + else: + return get_random_lang(long_list, short_list) + return short_list + + +@commands('mangle', 'mangle2') +def mangle(bot, trigger): + """Repeatedly translate the input until it makes absolutely no sense.""" + verify_ssl = bot.config.core.verify_ssl + global mangle_lines + long_lang_list = ['fr', 'de', 'es', 'it', 'no', 'he', 'la', 'ja', 'cy', 'ar', 'yi', 'zh', 'nl', 'ru', 'fi', 'hi', 'af', 'jw', 'mr', 'ceb', 'cs', 'ga', 'sv', 'eo', 'el', 'ms', 'lv'] + lang_list = [] + for __ in range(0, 8): + lang_list = get_random_lang(long_lang_list, lang_list) + random.shuffle(lang_list) + if trigger.group(2) is None: + try: + phrase = (mangle_lines[trigger.sender.lower()], '') + except: + bot.reply("What do you want me to mangle?") + return + else: + phrase = (trigger.group(2).strip(), '') + if phrase[0] == '': + bot.reply("What do you want me to mangle?") + return + for lang in lang_list: + backup = phrase + try: + phrase = translate(phrase[0], 'en', lang, + verify_ssl=verify_ssl) + except: + phrase = False + if not phrase: + phrase = backup + break + + try: + phrase = translate(phrase[0], lang, 'en', verify_ssl=verify_ssl) + except: + phrase = backup + continue + + if not phrase: + phrase = backup + break + bot.reply(phrase[0]) + + +@rule('(.*)') +@priority('low') +def collect_mangle_lines(bot, trigger): + global mangle_lines + mangle_lines[trigger.sender.lower()] = "%s said '%s'" % (trigger.nick, (trigger.group(0).strip())) + + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/unicode_info.py b/modules/unicode_info.py new file mode 100755 index 0000000..ce02eba --- /dev/null +++ b/modules/unicode_info.py @@ -0,0 +1,51 @@ +# coding=utf-8 +"""Codepoints Module""" +# Copyright 2013, Elsie Powell, embolalia.com +# Copyright 2008, Sean B. Palmer, inamidst.com +# Licensed under the Eiffel Forum License 2. +import unicodedata +import sys +from module import commands, example, NOLIMIT + +if sys.version_info.major >= 3: + unichr = chr + + +@commands('u') +@example('.u ‽', 'U+203D INTERROBANG (‽)') +@example('.u 203D', 'U+203D INTERROBANG (‽)') +def codepoint(bot, trigger): + arg = trigger.group(2) + if not arg: + bot.reply('What code point do you want me to look up?') + return NOLIMIT + stripped = arg.strip() + if len(stripped) > 0: + arg = stripped + if len(arg) > 1: + if arg.startswith('U+'): + arg = arg[2:] + try: + arg = unichr(int(arg, 16)) + except: + bot.reply("That's not a valid code point.") + return NOLIMIT + + # Get the hex value for the code point, and drop the 0x from the front + point = str(hex(ord(u'' + arg)))[2:] + # Make the hex 4 characters long with preceding 0s, and all upper case + point = point.rjust(4, str('0')).upper() + try: + name = unicodedata.name(arg) + except ValueError: + return 'U+%s (No name found)' % point + + if not unicodedata.combining(arg): + template = 'U+%s %s (%s)' + else: + template = 'U+%s %s (\xe2\x97\x8c%s)' + bot.say(template % (point, name, arg)) + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/units.py b/modules/units.py new file mode 100755 index 0000000..108e19a --- /dev/null +++ b/modules/units.py @@ -0,0 +1,186 @@ +# coding=utf-8 +""" +units.py - Unit conversion module for Sopel +Copyright © 2013, Elad Alfassa, +Copyright © 2013, Dimitri Molenaars, +Licensed under the Eiffel Forum License 2. + +""" +import re + +from module import commands, example, NOLIMIT + +find_temp = re.compile('(-?[0-9]*\.?[0-9]*)[ °]*(K|C|F)', re.IGNORECASE) +find_length = re.compile('([0-9]*\.?[0-9]*)[ ]*(mile[s]?|mi|inch|in|foot|feet|ft|yard[s]?|yd|(?:milli|centi|kilo|)meter[s]?|[mkc]?m|ly|light-year[s]?|au|astronomical unit[s]?|parsec[s]?|pc)', re.IGNORECASE) +find_mass = re.compile('([0-9]*\.?[0-9]*)[ ]*(lb|lbm|pound[s]?|ounce|oz|(?:kilo|)gram(?:me|)[s]?|[k]?g)', re.IGNORECASE) + + +def f_to_c(temp): + return (float(temp) - 32) * 5 / 9 + + +def c_to_k(temp): + return temp + 273.15 + + +def c_to_f(temp): + return (9.0 / 5.0 * temp + 32) + + +def k_to_c(temp): + return temp - 273.15 + + +@commands('temp') +@example('.temp 100F', '37.78°C = 100.00°F = 310.93K') +@example('.temp 100C', '100.00°C = 212.00°F = 373.15K') +@example('.temp 100K', '-173.15°C = -279.67°F = 100.00K') +def temperature(bot, trigger): + """ + Convert temperatures + """ + try: + source = find_temp.match(trigger.group(2)).groups() + except (AttributeError, TypeError): + bot.reply("That's not a valid temperature.") + return NOLIMIT + unit = source[1].upper() + numeric = float(source[0]) + celsius = 0 + if unit == 'C': + celsius = numeric + elif unit == 'F': + celsius = f_to_c(numeric) + elif unit == 'K': + celsius = k_to_c(numeric) + + kelvin = c_to_k(celsius) + fahrenheit = c_to_f(celsius) + bot.reply("{:.2f}°C = {:.2f}°F = {:.2f}K".format(celsius, fahrenheit, kelvin)) + + +@commands('length', 'distance') +@example('.distance 3m', '3.00m = 9 feet, 10.11 inches') +@example('.distance 3km', '3.00km = 1.86 miles') +@example('.distance 3 miles', '4.83km = 3.00 miles') +@example('.distance 3 inch', '7.62cm = 3.00 inches') +@example('.distance 3 feet', '91.44cm = 3 feet, 0.00 inches') +@example('.distance 3 yards', '2.74m = 9 feet, 0.00 inches') +@example('.distance 155cm', '1.55m = 5 feet, 1.02 inches') +@example('.length 3 ly', '28382191417742.40km = 17635876112814.77 miles') +@example('.length 3 au', '448793612.10km = 278867421.71 miles') +@example('.length 3 parsec', '92570329129020.20km = 57520535754731.61 miles') +def distance(bot, trigger): + """ + Convert distances + """ + try: + source = find_length.match(trigger.group(2)).groups() + except (AttributeError, TypeError): + bot.reply("That's not a valid length unit.") + return NOLIMIT + unit = source[1].lower() + numeric = float(source[0]) + meter = 0 + if unit in ("meters", "meter", "m"): + meter = numeric + elif unit in ("millimeters", "millimeter", "mm"): + meter = numeric / 1000 + elif unit in ("kilometers", "kilometer", "km"): + meter = numeric * 1000 + elif unit in ("miles", "mile", "mi"): + meter = numeric / 0.00062137 + elif unit in ("inch", "in"): + meter = numeric / 39.370 + elif unit in ("centimeters", "centimeter", "cm"): + meter = numeric / 100 + elif unit in ("feet", "foot", "ft"): + meter = numeric / 3.2808 + elif unit in ("yards", "yard", "yd"): + meter = numeric / (3.2808 / 3) + elif unit in ("light-year", "light-years", "ly"): + meter = numeric * 9460730472580800 + elif unit in ("astronomical unit", "astronomical units", "au"): + meter = numeric * 149597870700 + elif unit in ("parsec", "parsecs", "pc"): + meter = numeric * 30856776376340068 + + if meter >= 1000: + metric_part = '{:.2f}km'.format(meter / 1000) + elif meter < 0.01: + metric_part = '{:.2f}mm'.format(meter * 1000) + elif meter < 1: + metric_part = '{:.2f}cm'.format(meter * 100) + else: + metric_part = '{:.2f}m'.format(meter) + + # Shit like this makes me hate being an American. + inch = meter * 39.37 + foot = int(inch) // 12 + inch = inch - (foot * 12) + yard = foot // 3 + mile = meter * 0.000621371192 + + if yard > 500: + stupid_part = '{:.2f} miles'.format(mile) + else: + parts = [] + if yard >= 100: + parts.append('{} yards'.format(yard)) + foot -= (yard * 3) + + if foot == 1: + parts.append('1 foot') + elif foot != 0: + parts.append('{:.0f} feet'.format(foot)) + + parts.append('{:.2f} inches'.format(inch)) + + stupid_part = ', '.join(parts) + + bot.reply('{} = {}'.format(metric_part, stupid_part)) + + +@commands('weight', 'mass') +def mass(bot, trigger): + """ + Convert mass + """ + try: + source = find_mass.match(trigger.group(2)).groups() + except (AttributeError, TypeError): + bot.reply("That's not a valid mass unit.") + return NOLIMIT + unit = source[1].lower() + numeric = float(source[0]) + metric = 0 + if unit in ("gram", "grams", "gramme", "grammes", "g"): + metric = numeric + elif unit in ("kilogram", "kilograms", "kilogramme", "kilogrammes", "kg"): + metric = numeric * 1000 + elif unit in ("lb", "lbm", "pound", "pounds"): + metric = numeric * 453.59237 + elif unit in ("oz", "ounce"): + metric = numeric * 28.35 + + if metric >= 1000: + metric_part = '{:.2f}kg'.format(metric / 1000) + else: + metric_part = '{:.2f}g'.format(metric) + + ounce = metric * .035274 + pound = int(ounce) // 16 + ounce = ounce - (pound * 16) + + if pound > 1: + stupid_part = '{} pounds'.format(pound) + if ounce > 0.01: + stupid_part += ' {:.2f} ounces'.format(ounce) + else: + stupid_part = '{:.2f} oz'.format(ounce) + + bot.reply('{} = {}'.format(metric_part, stupid_part)) + +if __name__ == "__main__": + from test_tools import run_example_tests + run_example_tests(__file__) diff --git a/modules/uptime.py b/modules/uptime.py new file mode 100755 index 0000000..99fa936 --- /dev/null +++ b/modules/uptime.py @@ -0,0 +1,26 @@ +# coding=utf-8 +""" +uptime.py - Uptime module +Copyright 2014, Fabian Neundorf +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +import datetime + +from module import commands + + +def setup(bot): + if "uptime" not in bot.memory: + bot.memory["uptime"] = datetime.datetime.utcnow() + + +@commands('uptime') +def uptime(bot, trigger): + """.uptime - Returns the uptime of Sopel.""" + delta = datetime.timedelta(seconds=round((datetime.datetime.utcnow() - + bot.memory["uptime"]) + .total_seconds())) + bot.say("I've been sitting here for {} and I keep " + "going!".format(delta)) diff --git a/modules/url.py b/modules/url.py new file mode 100755 index 0000000..3c9df13 --- /dev/null +++ b/modules/url.py @@ -0,0 +1,68 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +URL parsing. +""" +import re +from module import rule +from tools import web +from config.types import ValidatedAttribute, ListAttribute, StaticSection +import requests +from urllib.parse import urlparse +from html.parser import HTMLParser + +headers = {"User-Agent": "bix nood gimme the title", "Range": "bytes=0-4096"} + +class UrlSection(StaticSection): + # TODO some validation rules maybe? + exclude = ListAttribute('exclude') + exclusion_char = ValidatedAttribute('exclusion_char', default='!') + + +def configure(config): + config.define_section('url', UrlSection) + config.url.configure_setting( + 'exclude', + 'Enter regular expressions for each URL you would like to exclude.' + ) + config.url.configure_setting( + 'exclusion_char', + 'Enter a character which can be prefixed to suppress URL titling' + ) + + +@rule('(?u).*(https?://\S+).*') +def title_auto(bot, trigger): + """ + Automatically show titles for URLs. For shortened URLs/redirects, find + where the URL redirects to and show the title for that. + """ + url_finder = re.compile(r'(?u)(%s?(?:http|https|ftp)(?:://\S+))' % + (bot.config.url.exclusion_char), re.IGNORECASE) + if re.match(bot.config.core.prefix + 'title', trigger): + return + + urls = re.findall(url_finder, trigger) + if len(urls) == 0: + return + + for url in urls: + # Avoid fetching known malicious links + if not web.secCheck(bot, url): + continue + try: + res = requests.get(url, headers=headers, verify=True) + except requests.exceptions.ConnectionError: + continue + if res.status_code == 404: + continue + res.raise_for_status() + if not res.headers["Content-Type"].startswith("text/html"): + continue + if res.text.find("") == -1: + continue + title = res.text[res.text.find("<title>")+7:res.text.find("")] + title = HTMLParser().unescape(title) + title = title.replace("\n","").strip() + hostname = urlparse(url).hostname + bot.say('[ \x0310%s \x03] - \x0304%s' % (title, hostname)) diff --git a/modules/version.py b/modules/version.py new file mode 100755 index 0000000..63ba5f9 --- /dev/null +++ b/modules/version.py @@ -0,0 +1,81 @@ +# coding=utf-8 +""" +version.py - Sopel Version Module +Copyright 2009, Silas Baronda +Copyright 2014, Dimitri Molenaars +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from datetime import datetime +import re +from os import path + +import module + + +log_line = re.compile('\S+ (\S+) (.*? <.*?>) (\d+) (\S+)\tcommit[^:]*: (.+)') + + +def git_info(): + repo = path.join(path.dirname(path.dirname(path.dirname(__file__))), '.git') + head = path.join(repo, 'HEAD') + if path.isfile(head): + with open(head) as h: + head_loc = h.readline()[5:-1] # strip ref: and \n + head_file = path.join(repo, head_loc) + if path.isfile(head_file): + with open(head_file) as h: + sha = h.readline() + if sha: + return sha + + +@module.commands('version') +def version(bot, trigger): + """Display the latest commit version, if Sopel is running in a git repo.""" + release = sopel.__version__ + sha = git_info() + if not sha: + msg = 'Sopel v. ' + release + if release[-4:] == '-git': + msg += ' at unknown commit.' + bot.reply(msg) + return + + bot.reply("Sopel v. {} at commit: {}".format(sopel.__version__, sha)) + + +@module.intent('VERSION') +@module.rate(20) +@module.rule('.*') +def ctcp_version(bot, trigger): + print('wat') + bot.write(('NOTICE', trigger.nick), + '\x01VERSION Sopel IRC Bot version %s\x01' % sopel.__version__) + + +@module.rule('\x01SOURCE\x01') +@module.rate(20) +def ctcp_source(bot, trigger): + bot.write(('NOTICE', trigger.nick), + '\x01SOURCE https://github.com/sopel-irc/sopel/\x01') + + +@module.rule('\x01PING\s(.*)\x01') +@module.rate(10) +def ctcp_ping(bot, trigger): + text = trigger.group() + text = text.replace("PING ", "") + text = text.replace("\x01", "") + bot.write(('NOTICE', trigger.nick), + '\x01PING {0}\x01'.format(text)) + + +@module.rule('\x01TIME\x01') +@module.rate(20) +def ctcp_time(bot, trigger): + dt = datetime.now() + current_time = dt.strftime("%A, %d. %B %Y %I:%M%p") + bot.write(('NOTICE', trigger.nick), + '\x01TIME {0}\x01'.format(current_time)) diff --git a/modules/weather.py b/modules/weather.py new file mode 100755 index 0000000..c55c629 --- /dev/null +++ b/modules/weather.py @@ -0,0 +1,181 @@ +# coding=utf-8 +# Copyright 2008, Sean B. Palmer, inamidst.com +# Copyright 2012, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division + +from module import commands, example, NOLIMIT + +import xmltodict, requests + + +def woeid_search(query): + """ + Find the first Where On Earth ID for the given query. Result is the etree + node for the result, so that location data can still be retrieved. Returns + None if there is no result, or the woeid field is empty. + """ + query = 'q=select * from geo.places where text="%s"' % query + body = requests.get('http://query.yahooapis.com/v1/public/yql?' + query).content + parsed = xmltodict.parse(body).get('query') + results = parsed.get('results') + if results is None or results.get('place') is None: + return None + if type(results.get('place')) is list: + return results.get('place')[0] + return results.get('place') + + +def get_cover(parsed): + try: + condition = parsed['channel']['item']['yweather:condition'] + except KeyError: + return 'unknown' + text = condition['@text'] + # code = int(condition['code']) + # TODO parse code to get those little icon thingies. + return text + + +def get_temp(parsed): + try: + condition = parsed['channel']['item']['yweather:condition'] + temp = int(condition['@temp']) + except (KeyError, ValueError): + return 'unknown' + f = round((temp * 1.8) + 32, 2) + return (u'%d\u00B0C (%d\u00B0F)' % (temp, f)) + + +def get_humidity(parsed): + try: + humidity = parsed['channel']['yweather:atmosphere']['@humidity'] + except (KeyError, ValueError): + return 'unknown' + return "Humidity: %s%%" % humidity + + +def get_wind(parsed): + try: + wind_data = parsed['channel']['yweather:wind'] + kph = float(wind_data['@speed']) + m_s = float(round(kph / 3.6, 1)) + speed = int(round(kph / 1.852, 0)) + degrees = int(wind_data['@direction']) + except (KeyError, ValueError): + return 'unknown' + + if speed < 1: + description = 'Calm' + elif speed < 4: + description = 'Light air' + elif speed < 7: + description = 'Light breeze' + elif speed < 11: + description = 'Gentle breeze' + elif speed < 16: + description = 'Moderate breeze' + elif speed < 22: + description = 'Fresh breeze' + elif speed < 28: + description = 'Strong breeze' + elif speed < 34: + description = 'Near gale' + elif speed < 41: + description = 'Gale' + elif speed < 48: + description = 'Strong gale' + elif speed < 56: + description = 'Storm' + elif speed < 64: + description = 'Violent storm' + else: + description = 'Hurricane' + + if (degrees <= 22.5) or (degrees > 337.5): + degrees = u'\u2193' + elif (degrees > 22.5) and (degrees <= 67.5): + degrees = u'\u2199' + elif (degrees > 67.5) and (degrees <= 112.5): + degrees = u'\u2190' + elif (degrees > 112.5) and (degrees <= 157.5): + degrees = u'\u2196' + elif (degrees > 157.5) and (degrees <= 202.5): + degrees = u'\u2191' + elif (degrees > 202.5) and (degrees <= 247.5): + degrees = u'\u2197' + elif (degrees > 247.5) and (degrees <= 292.5): + degrees = u'\u2192' + elif (degrees > 292.5) and (degrees <= 337.5): + degrees = u'\u2198' + + return description + ' ' + str(m_s) + 'm/s (' + degrees + ')' + + +@commands('weather', 'wea') +@example('.weather London') +def weather(bot, trigger): + """.weather location - Show the weather at the given location.""" + + location = trigger.group(2) + woeid = '' + if not location: + woeid = bot.db.get_nick_value(trigger.nick, 'woeid') + if not woeid: + return bot.msg(trigger.sender, "I don't know where you live. " + + 'Give me a location, like .weather London, or tell me where you live by saying .setlocation London, for example.') + else: + location = location.strip() + woeid = bot.db.get_nick_value(location, 'woeid') + if woeid is None: + first_result = woeid_search(location) + if first_result is not None: + woeid = first_result.get('woeid') + + if not woeid: + return bot.reply("I don't know where that is.") + + query = 'q=select * from weather.forecast where woeid="%s" and u=\'c\'' % woeid + body = requests.get('http://query.yahooapis.com/v1/public/yql?' + query).content + parsed = xmltodict.parse(body).get('query') + results = parsed.get('results') + if results is None: + return bot.reply("No forecast available. Try a more specific location.") + location = results.get('channel').get('title') + cover = get_cover(results) + temp = get_temp(results) + humidity = get_humidity(results) + wind = get_wind(results) + bot.say(u'%s: %s, %s, %s, %s' % (location, cover, temp, humidity, wind)) + + +@commands('setlocation', 'setwoeid') +@example('.setlocation Columbus, OH') +def update_woeid(bot, trigger): + """Set your default weather location.""" + if not trigger.group(2): + bot.reply('Give me a location, like "Washington, DC" or "London".') + return NOLIMIT + + first_result = woeid_search(trigger.group(2)) + if first_result is None: + return bot.reply("I don't know where that is.") + + woeid = first_result.get('woeid') + + bot.db.set_nick_value(trigger.nick, 'woeid', woeid) + + neighborhood = first_result.get('locality2') or '' + if neighborhood: + neighborhood = neighborhood.get('#text') + ', ' + city = first_result.get('locality1') or '' + # This is to catch cases like 'Bawlf, Alberta' where the location is + # thought to be a "LocalAdmin" rather than a "Town" + if city: + city = city.get('#text') + else: + city = first_result.get('name') + state = first_result.get('admin1').get('#text') or '' + country = first_result.get('country').get('#text') or '' + bot.reply('I now have you at WOEID %s (%s%s, %s, %s)' % + (woeid, neighborhood, city, state, country)) diff --git a/modules/wikipedia.py b/modules/wikipedia.py new file mode 100755 index 0000000..c67a08f --- /dev/null +++ b/modules/wikipedia.py @@ -0,0 +1,134 @@ +# coding=utf-8 +# Copyright 2013 Elsie Powell - embolalia.com +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division +import tools +from config.types import StaticSection, ValidatedAttribute +from module import NOLIMIT, commands, example, rule +import json +import re +import requests + +import sys +if sys.version_info.major < 3: + from urlparse import unquote as _unquote + unquote = lambda s: _unquote(s.encode('utf-8')).decode('utf-8') +else: + from urllib.parse import unquote + +REDIRECT = re.compile(r'^REDIRECT (.*)') + + +class WikipediaSection(StaticSection): + default_lang = ValidatedAttribute('default_lang', default='en') + """The default language to find articles from.""" + lang_per_channel = ValidatedAttribute('lang_per_channel') + + +def setup(bot): + bot.config.define_section('wikipedia', WikipediaSection) + + regex = re.compile('([a-z]+).(wikipedia.org/wiki/)([^ ]+)') + if not bot.memory.contains('url_callbacks'): + bot.memory['url_callbacks'] = tools.SopelMemory() + bot.memory['url_callbacks'][regex] = mw_info + + +def configure(config): + config.define_section('wikipedia', WikipediaSection) + config.wikipedia.configure_setting( + 'default_lang', + "Enter the default language to find articles from." + ) + + +def mw_search(server, query, num): + """ + Searches the specified MediaWiki server for the given query, and returns + the specified number of results. + """ + search_url = ('http://%s/w/api.php?format=json&action=query' + '&list=search&srlimit=%d&srprop=timestamp&srwhat=text' + '&srsearch=') % (server, num) + search_url += query + query = json.loads(requests.get(search_url).text) + if 'query' in query: + query = query['query']['search'] + return [r['title'] for r in query] + else: + return None + + +def say_snippet(bot, server, query, show_url=True): + page_name = query.replace('_', ' ') + query = query.replace(' ', '_') + snippet = mw_snippet(server, query) + msg = '[WIKIPEDIA] {} | "{}"'.format(page_name, snippet) + if show_url: + msg = msg + ' | https://{}/wiki/{}'.format(server, query) + bot.say(msg) + + +def mw_snippet(server, query): + """ + Retrives a snippet of the specified length from the given page on the given + server. + """ + snippet_url = ('https://' + server + '/w/api.php?format=json' + '&action=query&prop=extracts&exintro&explaintext' + '&exchars=300&redirects&titles=') + snippet_url += query + snippet = json.loads(requests.get(snippet_url).text) + snippet = snippet['query']['pages'] + + # For some reason, the API gives the page *number* as the key, so we just + # grab the first page number in the results. + snippet = snippet[list(snippet.keys())[0]] + + return snippet['extract'] + + +@rule('.*/([a-z]+\.wikipedia.org)/wiki/([^ ]+).*') +def mw_info(bot, trigger, found_match=None): + """ + Retrives a snippet of the specified length from the given page on the given + server. + """ + match = found_match or trigger + say_snippet(bot, match.group(1), unquote(match.group(2)), show_url=False) + + +@commands('w', 'wiki', 'wik') +@example('.w San Francisco') +def wikipedia(bot, trigger): + lang = bot.config.wikipedia.default_lang + + #change lang if channel has custom language set + if (trigger.sender and not trigger.sender.is_nick() and + bot.config.wikipedia.lang_per_channel): + customlang = re.search('(' + trigger.sender + '):(\w+)', + bot.config.wikipedia.lang_per_channel) + if customlang is not None: + lang = customlang.group(2) + + if trigger.group(2) is None: + bot.reply("What do you want me to look up?") + return NOLIMIT + + query = trigger.group(2) + args = re.search(r'^-([a-z]{2,12})\s(.*)', query) + if args is not None: + lang = args.group(1) + query = args.group(2) + + if not query: + bot.reply('What do you want me to look up?') + return NOLIMIT + server = lang + '.wikipedia.org' + query = mw_search(server, query, 1) + if not query: + bot.reply("I can't find any results for that.") + return NOLIMIT + else: + query = query[0] + say_snippet(bot, server, query) diff --git a/modules/wiktionary.py b/modules/wiktionary.py new file mode 100755 index 0000000..a54f92e --- /dev/null +++ b/modules/wiktionary.py @@ -0,0 +1,101 @@ +# coding=utf-8 +""" +wiktionary.py - Sopel Wiktionary Module +Copyright 2009, Sean B. Palmer, inamidst.com +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" + +import re +import requests + +from module import commands, example + +uri = 'http://en.wiktionary.org/w/index.php?title={}&printable=yes' +r_tag = re.compile(r'<[^>]+>') +r_ul = re.compile(r'(?ims)
      .*?
    ') + + +def text(html): + text = r_tag.sub('', html).strip() + text = text.replace('\n', ' ') + text = text.replace('\r', '') + text = text.replace('(intransitive', '(intr.') + text = text.replace('(transitive', '(trans.') + return text + + +def wikt(word): + bytes = requests.get(uri.format(word)) + bytes = r_ul.sub('', bytes) + + mode = None + etymology = None + definitions = {} + for line in bytes.splitlines(): + if 'id="Etymology"' in line: + mode = 'etymology' + elif 'id="Noun"' in line: + mode = 'noun' + elif 'id="Verb"' in line: + mode = 'verb' + elif 'id="Adjective"' in line: + mode = 'adjective' + elif 'id="Adverb"' in line: + mode = 'adverb' + elif 'id="Interjection"' in line: + mode = 'interjection' + elif 'id="Particle"' in line: + mode = 'particle' + elif 'id="Preposition"' in line: + mode = 'preposition' + elif 'id="' in line: + mode = None + + elif (mode == 'etmyology') and ('

    ' in line): + etymology = text(line) + elif (mode is not None) and ('

  • ' in line): + definitions.setdefault(mode, []).append(text(line)) + + if ' 300: + result = result[:295] + '[...]' + bot.say(result) diff --git a/modules/willilike.py b/modules/willilike.py new file mode 100755 index 0000000..9a4b0e8 --- /dev/null +++ b/modules/willilike.py @@ -0,0 +1,19 @@ +#! /usr/bin/env python3 +#-*- coding:utf-8 -*- +""" +Will I like this? +""" +import module + +@module.commands('willilike') +@module.example('.willilike Banished Quest') +def echo(bot, trigger): + """An advanced AI that will determine if you like something.""" + if trigger.group(2): + bot.reply("No.") + +@module.commands('upvote') +@module.example('.willilike Banished Quest') +def upvote(bot, trigger): + """An advanced AI that will determine if you like something.""" + bot.say(trigger.nick + " upvoted this post!") diff --git a/modules/wolfram.py b/modules/wolfram.py new file mode 100755 index 0000000..bb837bc --- /dev/null +++ b/modules/wolfram.py @@ -0,0 +1,78 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Wolfram|Alpha module for Sopel IRC bot framework +Forked from code by Max Gurela (@maxpowa): +https://github.com/maxpowa/inumuta-modules/blob/e0b195c4f1e1b788fa77ec2144d39c4748886a6a/wolfram.py +Updated and packaged for PyPI by dgw (@dgw) +""" + +from config.types import StaticSection, ChoiceAttribute, ValidatedAttribute +from module import commands, example +import wolframalpha + + +@commands('wa', 'wolfram') +@example('.wa 2+2', '[W|A] 2+2 = 4') +@example('.wa python language release date', '[W|A] Python | date introduced = 1991') +def wa_command(bot, trigger): + msg = None + if not trigger.group(2): + msg = 'You must provide a query.' + if not bot.config.wolfram.app_id: + msg = 'Wolfram|Alpha API app ID not configured.' + + lines = (msg or wa_query(bot.config.wolfram.app_id, trigger.group(2), bot.config.wolfram.units)).splitlines() + + + for line in lines: + bot.say('[\x0304Wolfram\x03] {}'.format(line)) + + +def wa_query(app_id, query, units='nonmetric'): + if not app_id: + return 'Wolfram|Alpha API app ID not provided.' + client = wolframalpha.Client(app_id) + query = query.encode('utf-8').strip() + params = ( + ('format', 'plaintext'), + ('units', units), + ) + + try: + result = client.query(input=query, params=params) + except AssertionError: + return 'Temporary API issue. Try again in a moment.' + except Exception as e: + return 'Query failed: {} ({})'.format(type(e).__name__, e.message or 'Unknown error, try again!') + + num_results = 0 + try: + num_results = int(result['@numpods']) + finally: + if num_results == 0: + return 'No results found.' + + texts = [] + try: + for pod in result.pods: + try: + texts.append(pod.text) + except AttributeError: + pass # pod with no text; skip it + except Exception: + raise # raise unexpected exceptions to outer try for bug reports + if len(texts) >= 2: + break # len() is O(1); this cheaply avoids copying more strings than needed + except Exception as e: + return 'Unhandled {}; please report this query ("{}") at https://dgw.me/wabug'.format(type(e).__name__, query) + + try: + input, output = texts[0], texts[1] + except IndexError: + return 'No text-representable result found; see http://wolframalpha.com/input/?i={}'.format(query) + + output = output.replace('\n', ' | ') + if not output: + return input + return '\x0310{} \x03= \x0312{}'.format(input, output) diff --git a/modules/xkcd.py b/modules/xkcd.py new file mode 100755 index 0000000..d8dbf51 --- /dev/null +++ b/modules/xkcd.py @@ -0,0 +1,124 @@ +# coding=utf-8 +# Copyright 2010, Michael Yanovich (yanovich.net), and Morgan Goose +# Copyright 2012, Lior Ramati +# Copyright 2013, Elsie Powell (embolalia.com) +# Licensed under the Eiffel Forum License 2. +from __future__ import unicode_literals, absolute_import, print_function, division + +import random +import re +import requests +from modules.search import google_search +from module import commands, url + +ignored_sites = [ + # For google searching + 'almamater.xkcd.com', + 'blog.xkcd.com', + 'blag.xkcd.com', + 'forums.xkcd.com', + 'fora.xkcd.com', + 'forums3.xkcd.com', + 'store.xkcd.com', + 'wiki.xkcd.com', + 'what-if.xkcd.com', +] +sites_query = ' site:xkcd.com -site:' + ' -site:'.join(ignored_sites) + + +def get_info(number=None, verify_ssl=True): + if number: + url = 'https://xkcd.com/{}/info.0.json'.format(number) + else: + url = 'https://xkcd.com/info.0.json' + data = requests.get(url, verify=verify_ssl).json() + data['url'] = 'https://xkcd.com/' + str(data['num']) + return data + + +def google(query): + url = google_search(query + sites_query) + if not url: + return None + match = re.match('(?:https?://)?xkcd.com/(\d+)/?', url) + if match: + return match.group(1) + + +@commands('xkcd') +def xkcd(bot, trigger): + """ + .xkcd - Finds an xkcd comic strip. Takes one of 3 inputs: + If no input is provided it will return a random comic + If numeric input is provided it will return that comic, or the nth-latest + comic if the number is non-positive + If non-numeric input is provided it will return the first google result for those keywords on the xkcd.com site + """ + verify_ssl = bot.config.core.verify_ssl + # get latest comic for rand function and numeric input + latest = get_info(verify_ssl=verify_ssl) + max_int = latest['num'] + + # if no input is given (pre - lior's edits code) + if not trigger.group(2): # get rand comic + random.seed() + requested = get_info(random.randint(1, max_int + 1), + verify_ssl=verify_ssl) + else: + query = trigger.group(2).strip() + + numbered = re.match(r"^(#|\+|-)?(\d+)$", query) + if numbered: + query = int(numbered.group(2)) + if numbered.group(1) == "-": + query = -query + return numbered_result(bot, query, latest) + else: + # Non-number: google. + if (query.lower() == "latest" or query.lower() == "newest"): + requested = latest + else: + number = google(query) + if not number: + bot.say('Could not find any comics for that query.') + return + requested = get_info(number, verify_ssl=verify_ssl) + + say_result(bot, requested) + + +def numbered_result(bot, query, latest, verify_ssl=True): + max_int = latest['num'] + if query > max_int: + bot.say(("Sorry, comic #{} hasn't been posted yet. " + "The last comic was #{}").format(query, max_int)) + return + elif query <= -max_int: + bot.say(("Sorry, but there were only {} comics " + "released yet so far").format(max_int)) + return + elif abs(query) == 0: + requested = latest + elif query == 404 or max_int + query == 404: + bot.say("404 - Not Found") # don't error on that one + return + elif query > 0: + requested = get_info(query, verify_ssl=verify_ssl) + else: + # Negative: go back that many from current + requested = get_info(max_int + query, verify_ssl=verify_ssl) + + say_result(bot, requested) + + +def say_result(bot, result): + message = '{} | {} | Alt-text: {}'.format(result['url'], result['title'], + result['alt']) + bot.say(message) + + +@url('xkcd.com/(\d+)') +def get_url(bot, trigger, match): + verify_ssl = bot.config.core.verify_ssl + latest = get_info(verify_ssl=verify_ssl) + numbered_result(bot, int(match.group(1)), latest) diff --git a/run_script.py b/run_script.py new file mode 100755 index 0000000..7405352 --- /dev/null +++ b/run_script.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python2.7 +# coding=utf-8 +""" +Sopel - An IRC Bot +Copyright 2008, Sean B. Palmer, inamidst.com +Copyright © 2012-2014, Elad Alfassa +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import sys +from tools import stderr + +if sys.version_info < (2, 7): + stderr('Error: Requires Python 2.7 or later. Try python2.7 sopel') + sys.exit(1) +if sys.version_info.major == 3 and sys.version_info.minor < 3: + stderr('Error: When running on Python 3, Python 3.3 is required.') + sys.exit(1) + +import os +import argparse +import signal + +from __init__ import run, __version__ +from config import Config, _create_config, ConfigurationError, _wizard +import tools as tools + +homedir = os.path.join(os.path.expanduser('~'), '.sopel') + + +def enumerate_configs(extension='.cfg'): + configfiles = [] + if os.path.isdir(homedir): + sopel_dotdirfiles = os.listdir(homedir) # Preferred + for item in sopel_dotdirfiles: + if item.endswith(extension): + configfiles.append(item) + + return configfiles + + +def find_config(name, extension='.cfg'): + if os.path.isfile(name): + return name + configs = enumerate_configs(extension) + if name in configs or name + extension in configs: + if name + extension in configs: + name = name + extension + + return os.path.join(homedir, name) + + +def main(argv=None): + global homedir + # Step One: Parse The Command Line + try: + parser = argparse.ArgumentParser(description='Sopel IRC Bot', + usage='%(prog)s [options]') + parser.add_argument('-c', '--config', metavar='filename', + help='use a specific configuration file') + parser.add_argument("-d", '--fork', action="store_true", + dest="daemonize", help="Daemonize sopel") + parser.add_argument("-q", '--quit', action="store_true", dest="quit", + help="Gracefully quit Sopel") + parser.add_argument("-k", '--kill', action="store_true", dest="kill", + help="Kill Sopel") + parser.add_argument("-l", '--list', action="store_true", + dest="list_configs", + help="List all config files found") + parser.add_argument("-m", '--migrate', action="store_true", + dest="migrate_configs", + help="Migrate config files to the new format") + parser.add_argument('--quiet', action="store_true", dest="quiet", + help="Supress all output") + parser.add_argument('-w', '--configure-all', action='store_true', + dest='wizard', help='Run the configuration wizard.') + parser.add_argument('--configure-modules', action='store_true', + dest='mod_wizard', help=( + 'Run the configuration wizard, but only for the ' + 'module configuration options.')) + parser.add_argument('-v', '--version', action="store_true", + dest="version", help="Show version number and exit") + if argv: + opts = parser.parse_args(argv) + else: + opts = parser.parse_args() + + # Step Two: "Do not run as root" checks. + try: + # Linux/Mac + if os.getuid() == 0 or os.geteuid() == 0: + stderr('Error: Do not run Sopel with root privileges.') + sys.exit(1) + except AttributeError: + # Windows + if os.environ.get("USERNAME") == "Administrator": + stderr('Error: Do not run Sopel as Administrator.') + sys.exit(1) + + if opts.version: + py_ver = '%s.%s.%s' % (sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro) + print('Sopel %s (running on python %s)' % (__version__, py_ver)) + print('http://sopel.chat/') + return + elif opts.wizard: + _wizard('all', opts.config) + return + elif opts.mod_wizard: + _wizard('mod', opts.config) + return + + if opts.list_configs: + configs = enumerate_configs() + print('Config files in ~/.sopel:') + if len(configs) is 0: + print('\tNone found') + else: + for config in configs: + print('\t%s' % config) + print('-------------------------') + return + + config_name = opts.config or 'default' + + configpath = find_config(config_name) + if not os.path.isfile(configpath): + print("Welcome to Sopel!\nI can't seem to find the configuration file, so let's generate it!\n") + if not configpath.endswith('.cfg'): + configpath = configpath + '.cfg' + _create_config(configpath) + configpath = find_config(config_name) + try: + config_module = Config(configpath) + except ConfigurationError as e: + stderr(e) + sys.exit(2) + + if config_module.core.not_configured: + stderr('Bot is not configured, can\'t start') + # exit with code 2 to prevent auto restart on fail by systemd + sys.exit(2) + + logfile = os.path.os.path.join(config_module.core.logdir, 'stdio.log') + + config_module._is_daemonized = opts.daemonize + + sys.stderr = tools.OutputRedirect(logfile, True, opts.quiet) + sys.stdout = tools.OutputRedirect(logfile, False, opts.quiet) + + # Handle --quit, --kill and saving the PID to file + pid_dir = config_module.core.pid_dir + if opts.config is None: + pid_file_path = os.path.join(pid_dir, 'sopel.pid') + else: + basename = os.path.basename(opts.config) + if basename.endswith('.cfg'): + basename = basename[:-4] + pid_file_path = os.path.join(pid_dir, 'sopel-%s.pid' % basename) + if os.path.isfile(pid_file_path): + with open(pid_file_path, 'r') as pid_file: + try: + old_pid = int(pid_file.read()) + except ValueError: + old_pid = None + if old_pid is not None and tools.check_pid(old_pid): + if not opts.quit and not opts.kill: + stderr('There\'s already a Sopel instance running with this config file') + stderr('Try using the --quit or the --kill options') + sys.exit(1) + elif opts.kill: + stderr('Killing the sopel') + os.kill(old_pid, signal.SIGKILL) + sys.exit(0) + elif opts.quit: + stderr('Signaling Sopel to stop gracefully') + if hasattr(signal, 'SIGUSR1'): + os.kill(old_pid, signal.SIGUSR1) + else: + os.kill(old_pid, signal.SIGTERM) + sys.exit(0) + elif opts.kill or opts.quit: + stderr('Sopel is not running!') + sys.exit(1) + elif opts.quit or opts.kill: + stderr('Sopel is not running!') + sys.exit(1) + if opts.daemonize: + child_pid = os.fork() + if child_pid is not 0: + sys.exit() + with open(pid_file_path, 'w') as pid_file: + pid_file.write(str(os.getpid())) + + # Step Five: Initialise And Run sopel + run(config_module, pid_file_path) + except KeyboardInterrupt: + print("\n\nInterrupted") + os._exit(1) +if __name__ == '__main__': + main() diff --git a/sopel b/sopel new file mode 100755 index 0000000..2739609 --- /dev/null +++ b/sopel @@ -0,0 +1,11 @@ +#!/usr/bin/python3 + +# -*- coding: utf-8 -*- +import re +import sys + +from run_script import main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..e9d6e45 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,36 @@ +# coding=utf-8 +from __future__ import unicode_literals, division, print_function, absolute_import + +import os +import tempfile +import unittest +from sopel import config +from sopel.config import types + + +class FakeConfigSection(types.StaticSection): + attr = types.ValidatedAttribute('attr') + + +class ConfigFunctionalTest(unittest.TestCase): + def read_config(self): + configo = config.Config(self.filename) + configo.define_section('fake', FakeConfigSection) + return configo + + def setUp(self): + self.filename = tempfile.mkstemp()[1] + with open(self.filename, 'w') as fileo: + fileo.write( + "[core]\n" + "owner=embolalia" + ) + + self.config = self.read_config() + + def tearDown(self): + os.remove(self.filename) + + def test_validated_string_when_none(self): + self.config.fake.attr = None + self.assertEquals(self.config.fake.attr, None) diff --git a/test/test_db.py b/test/test_db.py new file mode 100644 index 0000000..b00e5bb --- /dev/null +++ b/test/test_db.py @@ -0,0 +1,257 @@ +# coding=utf-8 +"""Tests for the new database functionality. + +TODO: Most of these tests assume functionality tested in other tests. This is +enough to get everything working (and is better than nothing), but best +practice would probably be not to do that.""" +from __future__ import unicode_literals +from __future__ import absolute_import + +import json +import os +import sqlite3 +import sys +import tempfile + +import pytest + +from sopel.db import SopelDB +from sopel.test_tools import MockConfig +from sopel.tools import Identifier + +db_filename = tempfile.mkstemp()[1] +if sys.version_info.major >= 3: + unicode = str + basestring = str + iteritems = dict.items + itervalues = dict.values + iterkeys = dict.keys +else: + iteritems = dict.iteritems + itervalues = dict.itervalues + iterkeys = dict.iterkeys + + +@pytest.fixture +def db(): + config = MockConfig() + config.core.db_filename = db_filename + db = SopelDB(config) + # TODO add tests to ensure db creation works properly, too. + return db + + +def teardown_function(function): + os.remove(db_filename) + + +def test_get_nick_id(db): + conn = sqlite3.connect(db_filename) + tests = [ + [None, 'embolalia', Identifier('Embolalia')], + # Ensures case conversion is handled properly + [None, '[][]', Identifier('[]{}')], + # Unicode, just in case + [None, 'embölaliå', Identifier('EmbölaliÅ')], + ] + + for test in tests: + test[0] = db.get_nick_id(test[2]) + nick_id, slug, nick = test + with conn: + cursor = conn.cursor() + registered = cursor.execute( + 'SELECT nick_id, slug, canonical FROM nicknames WHERE canonical IS ?', [nick] + ).fetchall() + assert len(registered) == 1 + assert registered[0][1] == slug and registered[0][2] == nick + + # Check that each nick ended up with a different id + assert len(set(test[0] for test in tests)) == len(tests) + + # Check that the retrieval actually is idempotent + for test in tests: + nick_id = test[0] + new_id = db.get_nick_id(test[2]) + assert nick_id == new_id + + # Even if the case is different + for test in tests: + nick_id = test[0] + new_id = db.get_nick_id(Identifier(test[2].upper())) + assert nick_id == new_id + + +def test_alias_nick(db): + nick = 'Embolalia' + aliases = ['EmbölaliÅ', 'Embo`work', 'Embo'] + + nick_id = db.get_nick_id(nick) + for alias in aliases: + db.alias_nick(nick, alias) + + for alias in aliases: + assert db.get_nick_id(alias) == nick_id + + db.alias_nick('both', 'arenew') # Shouldn't fail. + + with pytest.raises(ValueError): + db.alias_nick('Eve', nick) + + with pytest.raises(ValueError): + db.alias_nick(nick, nick) + + +def test_set_nick_value(db): + conn = sqlite3.connect(db_filename) + cursor = conn.cursor() + nick = 'Embolalia' + nick_id = db.get_nick_id(nick) + data = { + 'key': 'value', + 'number_key': 1234, + 'unicode': 'EmbölaliÅ', + } + + def check(): + for key, value in iteritems(data): + db.set_nick_value(nick, key, value) + + for key, value in iteritems(data): + found_value = cursor.execute( + 'SELECT value FROM nick_values WHERE nick_id = ? AND key = ?', + [nick_id, key] + ).fetchone()[0] + assert json.loads(unicode(found_value)) == value + check() + + # Test updates + data['number_key'] = 'not a number anymore!' + data['unicode'] = 'This is different toö!' + check() + + +def test_get_nick_value(db): + conn = sqlite3.connect(db_filename) + cursor = conn.cursor() + nick = 'Embolalia' + nick_id = db.get_nick_id(nick) + data = { + 'key': 'value', + 'number_key': 1234, + 'unicode': 'EmbölaliÅ', + } + + for key, value in iteritems(data): + cursor.execute('INSERT INTO nick_values VALUES (?, ?, ?)', + [nick_id, key, json.dumps(value, ensure_ascii=False)]) + conn.commit() + + for key, value in iteritems(data): + found_value = db.get_nick_value(nick, key) + assert found_value == value + + +def test_unalias_nick(db): + conn = sqlite3.connect(db_filename) + nick = 'Embolalia' + nick_id = 42 + conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', + [nick_id, Identifier(nick).lower(), nick]) + aliases = ['EmbölaliÅ', 'Embo`work', 'Embo'] + for alias in aliases: + conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', + [nick_id, Identifier(alias).lower(), alias]) + conn.commit() + + for alias in aliases: + db.unalias_nick(alias) + + for alias in aliases: + found = conn.execute( + 'SELECT * FROM nicknames WHERE nick_id = ?', + [nick_id]).fetchall() + assert len(found) == 1 + + +def test_delete_nick_group(db): + conn = sqlite3.connect(db_filename) + aliases = ['Embolalia', 'Embo'] + nick_id = 42 + for alias in aliases: + conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', + [nick_id, Identifier(alias).lower(), alias]) + conn.commit() + + db.set_nick_value(aliases[0], 'foo', 'bar') + db.set_nick_value(aliases[1], 'spam', 'eggs') + + db.delete_nick_group(aliases[0]) + + # Nothing else has created values, so we know the tables are empty + nicks = conn.execute('SELECT * FROM nicknames').fetchall() + assert len(nicks) == 0 + data = conn.execute('SELECT * FROM nick_values').fetchone() + assert data is None + + +def test_merge_nick_groups(db): + conn = sqlite3.connect(db_filename) + aliases = ['Embolalia', 'Embo'] + for nick_id, alias in enumerate(aliases): + conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', + [nick_id, Identifier(alias).lower(), alias]) + conn.commit() + + finals = (('foo', 'bar'), ('bar', 'blue'), ('spam', 'eggs')) + + db.set_nick_value(aliases[0], finals[0][0], finals[0][1]) + db.set_nick_value(aliases[0], finals[1][0], finals[1][1]) + db.set_nick_value(aliases[1], 'foo', 'baz') + db.set_nick_value(aliases[1], finals[2][0], finals[2][1]) + + db.merge_nick_groups(aliases[0], aliases[1]) + + nick_ids = conn.execute('SELECT nick_id FROM nicknames') + nick_id = nick_ids.fetchone()[0] + alias_id = nick_ids.fetchone()[0] + assert nick_id == alias_id + + for key, value in finals: + found = conn.execute( + 'SELECT value FROM nick_values WHERE nick_id = ? AND key = ?', + [nick_id, key]).fetchone()[0] + assert json.loads(unicode(found)) == value + + +def test_set_channel_value(db): + conn = sqlite3.connect(db_filename) + db.set_channel_value('#asdf', 'qwer', 'zxcv') + result = conn.execute( + 'SELECT value FROM channel_values WHERE channel = ? and key = ?', + ['#asdf', 'qwer']).fetchone()[0] + assert result == '"zxcv"' + + +def test_get_channel_value(db): + conn = sqlite3.connect(db_filename) + conn.execute("INSERT INTO channel_values VALUES ('#asdf', 'qwer', '\"zxcv\"')") + conn.commit() + result = db.get_channel_value('#asdf', 'qwer') + assert result == 'zxcv' + + +def test_get_nick_or_channel_value(db): + db.set_nick_value('asdf', 'qwer', 'poiu') + db.set_channel_value('#asdf', 'qwer', '/.,m') + assert db.get_nick_or_channel_value('asdf', 'qwer') == 'poiu' + assert db.get_nick_or_channel_value('#asdf', 'qwer') == '/.,m' + + +def test_get_preferred_value(db): + db.set_nick_value('asdf', 'qwer', 'poiu') + db.set_channel_value('#asdf', 'qwer', '/.,m') + db.set_channel_value('#asdf', 'lkjh', '1234') + names = ['asdf', '#asdf'] + assert db.get_preferred_value(names, 'qwer') == 'poiu' + assert db.get_preferred_value(names, 'lkjh') == '1234' diff --git a/test/test_formatting.py b/test/test_formatting.py new file mode 100644 index 0000000..f870882 --- /dev/null +++ b/test/test_formatting.py @@ -0,0 +1,26 @@ +# coding=utf-8 +"""Tests for message formatting""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import pytest + +from sopel.formatting import colors, color, bold, underline + + +def test_color(): + text = 'Hello World' + assert color(text) == text + assert color(text, colors.PINK) == '\x0313' + text + '\x03' + assert color(text, colors.PINK, colors.TEAL) == '\x0313,10' + text + '\x03' + pytest.raises(ValueError, color, text, 100) + pytest.raises(ValueError, color, text, 'INVALID') + + +def test_bold(): + text = 'Hello World' + assert bold(text) == '\x02' + text + '\x02' + + +def test_underline(): + text = 'Hello World' + assert underline(text) == '\x1f' + text + '\x1f' diff --git a/test/test_irc.py b/test/test_irc.py new file mode 100644 index 0000000..44c22c1 --- /dev/null +++ b/test/test_irc.py @@ -0,0 +1,147 @@ +# coding=utf8 +"""Tests for message formatting""" +from __future__ import unicode_literals + +import pytest + +import asynchat +import os +import shutil +import socket +import select +import tempfile +import threading +import time +import asyncore + +from sopel import irc +from sopel.tools import stderr, Identifier +import sopel.config as conf + + +HOST = '127.0.0.1' +SERVER_QUIT = 'QUIT' + + +class BasicServer(asyncore.dispatcher): + def __init__(self, address, handler): + asyncore.dispatcher.__init__(self) + self.response_handler = handler + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.bind(address) + self.address = self.socket.getsockname() + self.listen(1) + return + + def handle_accept(self): + # Called when a client connects to our socket + client_info = self.accept() + BasicHandler(sock=client_info[0], handler=self.response_handler) + self.handle_close() + return + + def handle_close(self): + self.close() + +class BasicHandler(asynchat.async_chat): + ac_in_buffer_size = 512 + ac_out_buffer_size = 512 + + def __init__(self, sock, handler): + self.received_data = [] + asynchat.async_chat.__init__(self, sock) + self.handler_function = handler + self.set_terminator(b'\n') + return + + def collect_incoming_data(self, data): + self.received_data.append(data.decode('utf-8')) + + def found_terminator(self): + self._process_command() + + def _process_command(self): + command = ''.join(self.received_data) + response = self.handler_function(self, command) + self.push(':fake.server {}\n'.format(response).encode()) + self.received_data = [] + + +def start_server(rpl_function=None): + def rpl_func(msg): + print(msg) + return msg + + if rpl_function is None: + rpl_function = rpl_func + + address = ('localhost', 0) # let the kernel give us a port + server = BasicServer(address, rpl_function) + return server + + +@pytest.fixture +def bot(request): + cfg_dir = tempfile.mkdtemp() + print(cfg_dir) + filename = tempfile.mkstemp(dir=cfg_dir)[1] + os.mkdir(os.path.join(cfg_dir, 'modules')) + def fin(): + print('teardown config file') + shutil.rmtree(cfg_dir) + request.addfinalizer(fin) + + def gen(data): + with open(filename, 'w') as fileo: + fileo.write(data) + cfg = conf.Config(filename) + irc_bot = irc.Bot(cfg) + irc_bot.config = cfg + return irc_bot + + return gen + + +def test_bot_init(bot): + test_bot = bot( + '[core]\n' + 'owner=Baz\n' + 'nick=Foo\n' + 'user=Bar\n' + 'name=Sopel\n' + ) + assert test_bot.nick == Identifier('Foo') + assert test_bot.user == 'Bar' + assert test_bot.name == 'Sopel' + + +def basic_irc_replies(server, msg): + if msg.startswith('NICK'): + return '001 Foo :Hello' + elif msg.startswith('USER'): + # Quit here because good enough + server.close() + elif msg.startswith('PING'): + return 'PONG{}'.format(msg.replace('PING','',1)) + elif msg.startswith('CAP'): + return 'CAP * :' + elif msg.startswith('QUIT'): + server.close() + else: + return '421 {} :Unknown command'.format(msg) + + +def test_bot_connect(bot): + test_bot = bot( + '[core]\n' + 'owner=Baz\n' + 'nick=Foo\n' + 'user=Bar\n' + 'name=Sopel\n' + 'host=127.0.0.1\n' + 'timeout=10\n' + ) + s = start_server(basic_irc_replies) + + # Do main run + test_bot.run(HOST, s.address[1]) diff --git a/test/test_module.py b/test/test_module.py new file mode 100644 index 0000000..5214d75 --- /dev/null +++ b/test/test_module.py @@ -0,0 +1,210 @@ +# coding=utf-8 +"""Tests for message formatting""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import pytest + +from sopel.trigger import PreTrigger, Trigger +from sopel.test_tools import MockSopel, MockSopelWrapper +from sopel.tools import Identifier +from sopel import module + + +@pytest.fixture +def sopel(): + bot = MockSopel('Sopel') + bot.config.core.owner = 'Bar' + return bot + + +@pytest.fixture +def bot(sopel, pretrigger): + bot = MockSopelWrapper(sopel, pretrigger) + bot.privileges = dict() + bot.privileges[Identifier('#Sopel')] = dict() + bot.privileges[Identifier('#Sopel')][Identifier('Foo')] = module.VOICE + return bot + + +@pytest.fixture +def pretrigger(): + line = ':Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + return PreTrigger(Identifier('Foo'), line) + + +@pytest.fixture +def pretrigger_pm(): + line = ':Foo!foo@example.com PRIVMSG Sopel :Hello, world' + return PreTrigger(Identifier('Foo'), line) + + +@pytest.fixture +def trigger_owner(bot): + line = ':Bar!bar@example.com PRIVMSG #Sopel :Hello, world' + return Trigger(bot.config, PreTrigger(Identifier('Bar'), line), None) + + +@pytest.fixture +def trigger(bot, pretrigger): + return Trigger(bot.config, pretrigger, None) + + +@pytest.fixture +def trigger_pm(bot, pretrigger_pm): + return Trigger(bot.config, pretrigger_pm, None) + + +def test_unblockable(): + @module.unblockable + def mock(bot, trigger, match): + return True + assert mock.unblockable is True + + +def test_interval(): + @module.interval(5) + def mock(bot, trigger, match): + return True + assert mock.interval == [5] + + +def test_rule(): + @module.rule('.*') + def mock(bot, trigger, match): + return True + assert mock.rule == ['.*'] + + +def test_thread(): + @module.thread(True) + def mock(bot, trigger, match): + return True + assert mock.thread is True + + +def test_commands(): + @module.commands('sopel') + def mock(bot, trigger, match): + return True + assert mock.commands == ['sopel'] + + +def test_nick_commands(): + @module.nickname_commands('sopel') + def mock(bot, trigger, match): + return True + assert mock.rule == [""" + ^ + $nickname[:,]? # Nickname. + \s+(sopel) # Command as group 1. + (?:\s+ # Whitespace to end command. + ( # Rest of the line as group 2. + (?:(\S+))? # Parameters 1-4 as groups 3-6. + (?:\s+(\S+))? + (?:\s+(\S+))? + (?:\s+(\S+))? + .* # Accept anything after the parameters. Leave it up to + # the module to parse the line. + ))? # Group 1 must be None, if there are no parameters. + $ # EoL, so there are no partial matches. + """] + + +def test_priority(): + @module.priority('high') + def mock(bot, trigger, match): + return True + assert mock.priority == 'high' + + +def test_event(): + @module.event('301') + def mock(bot, trigger, match): + return True + assert mock.event == ['301'] + + +def test_intent(): + @module.intent('ACTION') + def mock(bot, trigger, match): + return True + assert mock.intents == ['ACTION'] + + +def test_rate(): + @module.rate(5) + def mock(bot, trigger, match): + return True + assert mock.rate == 5 + + +def test_require_privmsg(bot, trigger, trigger_pm): + @module.require_privmsg('Try again in a PM') + def mock(bot, trigger, match=None): + return True + assert mock(bot, trigger) is not True + assert mock(bot, trigger_pm) is True + + @module.require_privmsg + def mock_(bot, trigger, match=None): + return True + assert mock_(bot, trigger) is not True + assert mock_(bot, trigger_pm) is True + + +def test_require_chanmsg(bot, trigger, trigger_pm): + @module.require_chanmsg('Try again in a channel') + def mock(bot, trigger, match=None): + return True + assert mock(bot, trigger) is True + assert mock(bot, trigger_pm) is not True + + @module.require_chanmsg + def mock_(bot, trigger, match=None): + return True + assert mock(bot, trigger) is True + assert mock(bot, trigger_pm) is not True + + +def test_require_privilege(bot, trigger): + @module.require_privilege(module.VOICE) + def mock_v(bot, trigger, match=None): + return True + assert mock_v(bot, trigger) is True + + @module.require_privilege(module.OP, 'You must be at least opped!') + def mock_o(bot, trigger, match=None): + return True + assert mock_o(bot, trigger) is not True + + +def test_require_admin(bot, trigger, trigger_owner): + @module.require_admin('You must be an admin') + def mock(bot, trigger, match=None): + return True + assert mock(bot, trigger) is not True + + @module.require_admin + def mock_(bot, trigger, match=None): + return True + assert mock_(bot, trigger_owner) is True + + +def test_require_owner(bot, trigger, trigger_owner): + @module.require_owner('You must be an owner') + def mock(bot, trigger, match=None): + return True + assert mock(bot, trigger) is not True + + @module.require_owner + def mock_(bot, trigger, match=None): + return True + assert mock_(bot, trigger_owner) is True + + +def test_example(bot, trigger): + @module.commands('mock') + @module.example('.mock', 'True') + def mock(bot, trigger, match=None): + return True + assert mock(bot, trigger) is True diff --git a/test/test_trigger.py b/test/test_trigger.py new file mode 100644 index 0000000..c81fcd2 --- /dev/null +++ b/test/test_trigger.py @@ -0,0 +1,245 @@ +# coding=utf-8 +"""Tests for message parsing""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +import pytest +import datetime + +from sopel.test_tools import MockConfig +from sopel.trigger import PreTrigger, Trigger +from sopel.tools import Identifier + + +@pytest.fixture +def nick(): + return Identifier('Sopel') + + +def test_basic_pretrigger(nick): + line = ':Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['#Sopel', 'Hello, world'] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == '#Sopel' + + +def test_pm_pretrigger(nick): + line = ':Foo!foo@example.com PRIVMSG Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['Sopel', 'Hello, world'] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == Identifier('Foo') + + +def test_quit_pretrigger(nick): + line = ':Foo!foo@example.com QUIT :quit message text' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['quit message text'] + assert pretrigger.event == 'QUIT' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender is None + + +def test_join_pretrigger(nick): + line = ':Foo!foo@example.com JOIN #Sopel' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['#Sopel'] + assert pretrigger.event == 'JOIN' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == Identifier('#Sopel') + + +def test_tags_pretrigger(nick): + line = '@foo=bar;baz;sopel.chat/special=value :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {'baz': None, + 'foo': 'bar', + 'sopel.chat/special': 'value'} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['#Sopel', 'Hello, world'] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == '#Sopel' + + +def test_intents_pretrigger(nick): + line = '@intent=ACTION :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {'intent': 'ACTION'} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['#Sopel', 'Hello, world'] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == '#Sopel' + + +def test_unusual_pretrigger(nick): + line = 'PING' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {} + assert pretrigger.hostmask is None + assert pretrigger.line == line + assert pretrigger.args == [] + assert pretrigger.event == 'PING' + + +def test_ctcp_intent_pretrigger(nick): + line = ':Foo!foo@example.com PRIVMSG Sopel :\x01VERSION\x01' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {'intent': 'VERSION'} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['Sopel', ''] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == Identifier('Foo') + + +def test_ctcp_data_pretrigger(nick): + line = ':Foo!foo@example.com PRIVMSG Sopel :\x01PING 1123321\x01' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {'intent': 'PING'} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['Sopel', '1123321'] + assert pretrigger.event == 'PRIVMSG' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == Identifier('Foo') + + +def test_ircv3_extended_join_pretrigger(nick): + line = ':Foo!foo@example.com JOIN #Sopel bar :Real Name' + pretrigger = PreTrigger(nick, line) + assert pretrigger.tags == {'account': 'bar'} + assert pretrigger.hostmask == 'Foo!foo@example.com' + assert pretrigger.line == line + assert pretrigger.args == ['#Sopel', 'bar', 'Real Name'] + assert pretrigger.event == 'JOIN' + assert pretrigger.nick == Identifier('Foo') + assert pretrigger.user == 'foo' + assert pretrigger.host == 'example.com' + assert pretrigger.sender == Identifier('#Sopel') + + +def test_ircv3_extended_join_trigger(nick): + line = ':Foo!foo@example.com JOIN #Sopel bar :Real Name' + pretrigger = PreTrigger(nick, line) + + config = MockConfig() + config.core.owner_account = 'bar' + + fakematch = re.match('.*', line) + + trigger = Trigger(config, pretrigger, fakematch) + assert trigger.sender == '#Sopel' + assert trigger.raw == line + assert trigger.is_privmsg is False + assert trigger.hostmask == 'Foo!foo@example.com' + assert trigger.user == 'foo' + assert trigger.nick == Identifier('Foo') + assert trigger.host == 'example.com' + assert trigger.event == 'JOIN' + assert trigger.match == fakematch + assert trigger.group == fakematch.group + assert trigger.groups == fakematch.groups + assert trigger.args == ['#Sopel', 'bar', 'Real Name'] + assert trigger.account == 'bar' + assert trigger.tags == {'account': 'bar'} + assert trigger.owner is True + assert trigger.admin is True + + +def test_ircv3_intents_trigger(nick): + line = '@intent=ACTION :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + + config = MockConfig() + config.core.owner = 'Foo' + config.core.admins = ['Bar'] + + fakematch = re.match('.*', line) + + trigger = Trigger(config, pretrigger, fakematch) + assert trigger.sender == '#Sopel' + assert trigger.raw == line + assert trigger.is_privmsg is False + assert trigger.hostmask == 'Foo!foo@example.com' + assert trigger.user == 'foo' + assert trigger.nick == Identifier('Foo') + assert trigger.host == 'example.com' + assert trigger.event == 'PRIVMSG' + assert trigger.match == fakematch + assert trigger.group == fakematch.group + assert trigger.groups == fakematch.groups + assert trigger.groupdict == fakematch.groupdict + assert trigger.args == ['#Sopel', 'Hello, world'] + assert trigger.tags == {'intent': 'ACTION'} + assert trigger.admin is True + assert trigger.owner is True + + +def test_ircv3_account_tag_trigger(nick): + line = '@account=Foo :Nick_Is_Not_Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + + config = MockConfig() + config.core.owner_account = 'Foo' + config.core.admins = ['Bar'] + + fakematch = re.match('.*', line) + + trigger = Trigger(config, pretrigger, fakematch) + assert trigger.admin is True + assert trigger.owner is True + + +def test_ircv3_server_time_trigger(nick): + line = '@time=2016-01-09T03:15:42.000Z :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + + config = MockConfig() + config.core.owner = 'Foo' + config.core.admins = ['Bar'] + + fakematch = re.match('.*', line) + + trigger = Trigger(config, pretrigger, fakematch) + assert trigger.time == datetime.datetime(2016, 1, 9, 3, 15, 42, 0) + + # Spec-breaking string + line = '@time=2016-01-09T04:20 :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' + pretrigger = PreTrigger(nick, line) + assert pretrigger.time is not None diff --git a/test_tools.py b/test_tools.py new file mode 100755 index 0000000..633332d --- /dev/null +++ b/test_tools.py @@ -0,0 +1,183 @@ +# coding=utf-8 +"""This module has classes and functions that can help in writing tests. + +test_tools.py - Sopel misc tools +Copyright 2013, Ari Koivula, +Licensed under the Eiffel Forum License 2. + +https://sopel.chat +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import os +import re +import sys +import tempfile + +try: + import ConfigParser +except ImportError: + import configparser as ConfigParser + +import config +import config.core_section +import tools +import trigger + + +class MockConfig(config.Config): + def __init__(self): + self.filename = tempfile.mkstemp()[1] + #self._homedir = tempfile.mkdtemp() + #self.filename = os.path.join(self._homedir, 'test.cfg') + self.parser = ConfigParser.RawConfigParser(allow_no_value=True) + self.parser.add_section('core') + self.parser.set('core', 'owner', 'Embolalia') + self.define_section('core', sopel.config.core_section.CoreSection) + self.get = self.parser.get + + def define_section(self, name, cls_): + if not self.parser.has_section(name): + self.parser.add_section(name) + setattr(self, name, cls_(self, name)) + + +class MockSopel(object): + def __init__(self, nick, admin=False, owner=False): + self.nick = nick + self.user = "sopel" + + self.channels = ["#channel"] + + self.memory = sopel.tools.SopelMemory() + + self.ops = {} + self.halfplus = {} + self.voices = {} + + self.config = MockConfig() + self._init_config() + + if admin: + self.config.core.admins = [self.nick] + if owner: + self.config.core.owner = self.nick + + def _init_config(self): + cfg = self.config + cfg.parser.set('core', 'admins', '') + cfg.parser.set('core', 'owner', '') + home_dir = os.path.join(os.path.expanduser('~'), '.sopel') + if not os.path.exists(home_dir): + os.mkdir(home_dir) + cfg.parser.set('core', 'homedir', home_dir) + + +class MockSopelWrapper(object): + def __init__(self, bot, pretrigger): + self.bot = bot + self.pretrigger = pretrigger + self.output = [] + + def _store(self, string, recipent=None): + self.output.append(string.strip()) + + say = reply = action = _store + + def __getattr__(self, attr): + return getattr(self.bot, attr) + + +def get_example_test(tested_func, msg, results, privmsg, admin, + owner, repeat, use_regexp, ignore=[]): + """Get a function that calls tested_func with fake wrapper and trigger. + + Args: + tested_func - A sopel callable that accepts SopelWrapper and Trigger. + msg - Message that is supposed to trigger the command. + results - Expected output from the callable. + privmsg - If true, make the message appear to have sent in a private + message to the bot. If false, make it appear to have come from a + channel. + admin - If true, make the message appear to have come from an admin. + owner - If true, make the message appear to have come from an owner. + repeat - How many times to repeat the test. Usefull for tests that + return random stuff. + use_regexp = Bool. If true, results is in regexp format. + ignore - List of strings to ignore. + + """ + def test(): + bot = MockSopel("NickName", admin=admin, owner=owner) + + match = None + if hasattr(tested_func, "commands"): + for command in tested_func.commands: + regexp = sopel.tools.get_command_regexp(".", command) + match = regexp.match(msg) + if match: + break + assert match, "Example did not match any command." + + sender = bot.nick if privmsg else "#channel" + hostmask = "%s!%s@%s " % (bot.nick, "UserName", "example.com") + # TODO enable message tags + full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg) + + pretrigger = sopel.trigger.PreTrigger(bot.nick, full_message) + trigger = sopel.trigger.Trigger(bot.config, pretrigger, match) + + module = sys.modules[tested_func.__module__] + if hasattr(module, 'setup'): + module.setup(bot) + + def isnt_ignored(value): + """Return True if value doesn't match any re in ignore list.""" + for ignored_line in ignore: + if re.match(ignored_line, value): + return False + return True + + for _i in range(repeat): + wrapper = MockSopelWrapper(bot, trigger) + tested_func(wrapper, trigger) + wrapper.output = list(filter(isnt_ignored, wrapper.output)) + assert len(wrapper.output) == len(results) + for result, output in zip(results, wrapper.output): + if type(output) is bytes: + output = output.decode('utf-8') + if use_regexp: + if not re.match(result, output): + assert result == output + else: + assert result == output + + return test + + +def insert_into_module(func, module_name, base_name, prefix): + """Add a function into a module.""" + func.__module__ = module_name + module = sys.modules[module_name] + # Make sure the func method does not overwrite anything. + for i in range(1000): + func.__name__ = str("%s_%s_%s" % (prefix, base_name, i)) + if not hasattr(module, func.__name__): + break + setattr(module, func.__name__, func) + + +def run_example_tests(filename, tb='native', multithread=False, verbose=False): + # These are only required when running tests, so import them here rather + # than at the module level. + import pytest + from multiprocessing import cpu_count + + args = [filename, "-s"] + args.extend(['--tb', tb]) + if verbose: + args.extend(['-v']) + if multithread and cpu_count() > 1: + args.extend(["-n", str(cpu_count())]) + + pytest.main(args) diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100755 index 0000000..f40352f --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,352 @@ +# coding=utf-8 +"""Useful miscellaneous tools and shortcuts for Sopel modules + +*Availability: 3+* +""" + +# tools.py - Sopel misc tools +# Copyright 2008, Sean B. Palmer, inamidst.com +# Copyright © 2012, Elad Alfassa +# Copyright 2012, Elsie Powell, embolalia.com +# Licensed under the Eiffel Forum License 2. + +# https://sopel.chat + +from __future__ import unicode_literals, absolute_import, print_function, division + +import sys +import os +import re +import threading +import codecs +import traceback +from collections import defaultdict + +from tools._events import events # NOQA + +if sys.version_info.major >= 3: + raw_input = input + unicode = str + iteritems = dict.items + itervalues = dict.values + iterkeys = dict.keys +else: + iteritems = dict.iteritems + itervalues = dict.itervalues + iterkeys = dict.iterkeys + +_channel_prefixes = ('#', '&', '+', '!') + + +def get_input(prompt): + """Get decoded input from the terminal (equivalent to python 3's ``input``). + """ + if sys.version_info.major >= 3: + return input(prompt) + else: + return raw_input(prompt).decode('utf8') + + +def get_raising_file_and_line(tb=None): + """Return the file and line number of the statement that raised the tb. + + Returns: (filename, lineno) tuple + + """ + if not tb: + tb = sys.exc_info()[2] + + filename, lineno, _context, _line = traceback.extract_tb(tb)[-1] + + return filename, lineno + + +def get_command_regexp(prefix, command): + """Return a compiled regexp object that implements the command.""" + # Escape all whitespace with a single backslash. This ensures that regexp + # in the prefix is treated as it was before the actual regexp was changed + # to use the verbose syntax. + prefix = re.sub(r"(\s)", r"\\\1", prefix) + + # This regexp match equivalently and produce the same + # groups 1 and 2 as the old regexp: r'^%s(%s)(?: +(.*))?$' + # The only differences should be handling all whitespace + # like spaces and the addition of groups 3-6. + pattern = r""" + (?:{prefix})({command}) # Command as group 1. + (?:\s+ # Whitespace to end command. + ( # Rest of the line as group 2. + (?:(\S+))? # Parameters 1-4 as groups 3-6. + (?:\s+(\S+))? + (?:\s+(\S+))? + (?:\s+(\S+))? + .* # Accept anything after the parameters. + # Leave it up to the module to parse + # the line. + ))? # Group 2 must be None, if there are no + # parameters. + $ # EoL, so there are no partial matches. + """.format(prefix=prefix, command=command) + return re.compile(pattern, re.IGNORECASE | re.VERBOSE) + + +def deprecated(old): + def new(*args, **kwargs): + print('Function %s is deprecated.' % old.__name__, file=sys.stderr) + trace = traceback.extract_stack() + for line in traceback.format_list(trace[:-1]): + stderr(line[:-1]) + return old(*args, **kwargs) + new.__doc__ = old.__doc__ + new.__name__ = old.__name__ + return new + + +# from +# http://parand.com/say/index.php/2007/07/13/simple-multi-dimensional-dictionaries-in-python/ +# A simple class to make mutli dimensional dict easy to use +class Ddict(dict): + + """Class for multi-dimensional ``dict``. + + A simple helper class to ease the creation of multi-dimensional ``dict``\s. + + """ + + def __init__(self, default=None): + self.default = default + + def __getitem__(self, key): + if key not in self: + self[key] = self.default() + return dict.__getitem__(self, key) + + +class Identifier(unicode): + """A `unicode` subclass which acts appropriately for IRC identifiers. + + When used as normal `unicode` objects, case will be preserved. + However, when comparing two Identifier objects, or comparing a Identifier + object with a `unicode` object, the comparison will be case insensitive. + This case insensitivity includes the case convention conventions regarding + ``[]``, ``{}``, ``|``, ``\\``, ``^`` and ``~`` described in RFC 2812. + """ + + def __new__(cls, identifier): + # According to RFC2812, identifiers have to be in the ASCII range. + # However, I think it's best to let the IRCd determine that, and we'll + # just assume unicode. It won't hurt anything, and is more internally + # consistent. And who knows, maybe there's another use case for this + # weird case convention. + s = unicode.__new__(cls, identifier) + s._lowered = Identifier._lower(identifier) + return s + + def lower(self): + """Return the identifier converted to lower-case per RFC 2812.""" + return self._lowered + + @staticmethod + def _lower(identifier): + """Returns `identifier` in lower case per RFC 2812.""" + # The tilde replacement isn't needed for identifiers, but is for + # channels, which may be useful at some point in the future. + low = identifier.lower().replace('{', '[').replace('}', ']') + low = low.replace('|', '\\').replace('^', '~') + return low + + def __repr__(self): + return "%s(%r)" % ( + self.__class__.__name__, + self.__str__() + ) + + def __hash__(self): + return self._lowered.__hash__() + + def __lt__(self, other): + if isinstance(other, Identifier): + return self._lowered < other._lowered + return self._lowered < Identifier._lower(other) + + def __le__(self, other): + if isinstance(other, Identifier): + return self._lowered <= other._lowered + return self._lowered <= Identifier._lower(other) + + def __gt__(self, other): + if isinstance(other, Identifier): + return self._lowered > other._lowered + return self._lowered > Identifier._lower(other) + + def __ge__(self, other): + if isinstance(other, Identifier): + return self._lowered >= other._lowered + return self._lowered >= Identifier._lower(other) + + def __eq__(self, other): + if isinstance(other, Identifier): + return self._lowered == other._lowered + return self._lowered == Identifier._lower(other) + + def __ne__(self, other): + return not (self == other) + + def is_nick(self): + """Returns True if the Identifier is a nickname (as opposed to channel) + """ + return self and not self.startswith(_channel_prefixes) + + +class OutputRedirect(object): + + """Redirect te output to the terminal and a log file. + + A simplified object used to write to both the terminal and a log file. + + """ + + def __init__(self, logpath, stderr=False, quiet=False): + """Create an object which will to to a file and the terminal. + + Create an object which will log to the file at ``logpath`` as well as + the terminal. + If ``stderr`` is given and true, it will write to stderr rather than + stdout. + If ``quiet`` is given and True, data will be written to the log file + only, but not the terminal. + + """ + self.logpath = logpath + self.stderr = stderr + self.quiet = quiet + + def write(self, string): + """Write the given ``string`` to the logfile and terminal.""" + if not self.quiet: + try: + if self.stderr: + sys.__stderr__.write(string) + else: + sys.__stdout__.write(string) + except: + pass + + with codecs.open(self.logpath, 'ab', encoding="utf8", + errors='xmlcharrefreplace') as logfile: + try: + logfile.write(string) + except UnicodeDecodeError: + # we got an invalid string, safely encode it to utf-8 + logfile.write(unicode(string, 'utf8', errors="replace")) + + def flush(self): + if self.stderr: + sys.__stderr__.flush() + else: + sys.__stdout__.flush() + + +# These seems to trace back to when we thought we needed a try/except on prints, +# because it looked like that was why we were having problems. We'll drop it in +# 4.0^H^H^H5.0^H^H^H6.0^H^H^Hsome version when someone can be bothered. +@deprecated +def stdout(string): + print(string) + + +def stderr(string): + """Print the given ``string`` to stderr. + + This is equivalent to ``print >> sys.stderr, string`` + + """ + print(string, file=sys.stderr) + + +def check_pid(pid): + """Check if a process is running with the given ``PID``. + + *Availability: Only on POSIX systems* + + Return ``True`` if there is a process running with the given ``PID``. + + """ + try: + os.kill(pid, 0) + except OSError: + return False + else: + return True + + +def get_hostmask_regex(mask): + """Return a compiled `re.RegexObject` for an IRC hostmask""" + mask = re.escape(mask) + mask = mask.replace(r'\*', '.*') + return re.compile(mask + '$', re.I) + + +class SopelMemory(dict): + + """A simple thread-safe dict implementation. + + *Availability: 4.0; available as ``Sopel.SopelMemory`` in 3.1.0 - 3.2.0* + + In order to prevent exceptions when iterating over the values and changing + them at the same time from different threads, we use a blocking lock on + ``__setitem__`` and ``contains``. + + """ + def __init__(self, *args): + dict.__init__(self, *args) + self.lock = threading.Lock() + + def __setitem__(self, key, value): + self.lock.acquire() + result = dict.__setitem__(self, key, value) + self.lock.release() + return result + + def __contains__(self, key): + """Check if a key is in the dict. + + It locks it for writes when doing so. + + """ + self.lock.acquire() + result = dict.__contains__(self, key) + self.lock.release() + return result + + def contains(self, key): + """Backwards compatability with 3.x, use `in` operator instead.""" + return self.__contains__(key) + + +class SopelMemoryWithDefault(defaultdict): + """Same as SopelMemory, but subclasses from collections.defaultdict.""" + def __init__(self, *args): + defaultdict.__init__(self, *args) + self.lock = threading.Lock() + + def __setitem__(self, key, value): + self.lock.acquire() + result = defaultdict.__setitem__(self, key, value) + self.lock.release() + return result + + def __contains__(self, key): + """Check if a key is in the dict. + + It locks it for writes when doing so. + + """ + self.lock.acquire() + result = defaultdict.__contains__(self, key) + self.lock.release() + return result + + def contains(self, key): + """Backwards compatability with 3.x, use `in` operator instead.""" + return self.__contains__(key) diff --git a/tools/_events.py b/tools/_events.py new file mode 100755 index 0000000..b805f06 --- /dev/null +++ b/tools/_events.py @@ -0,0 +1,203 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + + +class events(object): + """An enumeration of all the standardized and notable IRC numeric events + + This allows you to do, for example, @module.event(events.RPL_WELCOME) + rather than @module.event('001') + """ + # ###################################################### Non-RFC / Non-IRCv3 + # Only add things here if they're actually in common use across multiple + # ircds. + RPL_ISUPPORT = '005' + RPL_WHOSPCRPL = '354' + + # ################################################################### IRC v3 + # ## 3.1 + # CAP + ERR_INVALIDCAPCMD = '410' + # SASL + RPL_LOGGEDIN = '900' + RPL_LOGGEDOUT = '901' + ERR_NICKLOCKED = '902' + RPL_SASLSUCCESS = '903' + ERR_SASLFAIL = '904' + ERR_SASLTOOLONG = '905' + ERR_SASLABORTED = '906' + ERR_SASLALREADY = '907' + RPL_SASLMECHS = '908' + # TLS + RPL_STARTTLS = '670' + ERR_STARTTLS = '691' + # ## 3.2 + # Metadata + RPL_WHOISKEYVALUE = '760' + RPL_KEYVALUE = '761' + RPL_METADATAEND = '762' + ERR_METADATALIMIT = '764' + ERR_TARGETINVALID = '765' + ERR_NOMATCHINGKEY = '766' + ERR_KEYINVALID = '767' + ERR_KEYNOTSET = '768' + ERR_KEYNOPERMISSION = '769' + # Monitor + RPL_MONONLINE = '730' + RPL_MONOFFLINE = '731' + RPL_MONLIST = '732' + RPL_ENDOFMONLIST = '733' + ERR_MONLISTFULL = '734' + + # ################################################################# RFC 1459 + # ## 6.1 Error Replies. + ERR_NOSUCHNICK = '401' + ERR_NOSUCHSERVER = '402' + ERR_NOSUCHCHANNEL = '403' + ERR_CANNOTSENDTOCHAN = '404' + ERR_TOOMANYCHANNELS = '405' + ERR_WASNOSUCHNICK = '406' + ERR_TOOMANYTARGETS = '407' + ERR_NOORIGIN = '409' + ERR_NORECIPIENT = '411' + ERR_NOTEXTTOSEND = '412' + ERR_NOTOPLEVEL = '413' + ERR_WILDTOPLEVEL = '414' + ERR_UNKNOWNCOMMAND = '421' + ERR_NOMOTD = '422' + ERR_NOADMININFO = '423' + ERR_FILEERROR = '424' + ERR_NONICKNAMEGIVEN = '431' + ERR_ERRONEUSNICKNAME = '432' + ERR_NICKNAMEINUSE = '433' + ERR_NICKCOLLISION = '436' + ERR_USERNOTINCHANNEL = '441' + ERR_NOTONCHANNEL = '442' + ERR_USERONCHANNEL = '443' + ERR_NOLOGIN = '444' + ERR_SUMMONDISABLED = '445' + ERR_USERSDISABLED = '446' + ERR_NOTREGISTERED = '451' + ERR_NEEDMOREPARAMS = '461' + ERR_ALREADYREGISTRED = '462' + ERR_NOPERMFORHOST = '463' + ERR_PASSWDMISMATCH = '464' + ERR_YOUREBANNEDCREEP = '465' + ERR_KEYSET = '467' + ERR_CHANNELISFULL = '471' + ERR_UNKNOWNMODE = '472' + ERR_INVITEONLYCHAN = '473' + ERR_BANNEDFROMCHAN = '474' + ERR_BADCHANNELKEY = '475' + ERR_NOPRIVILEGES = '481' + ERR_CHANOPRIVSNEEDED = '482' + ERR_CANTKILLSERVER = '483' + ERR_NOOPERHOST = '491' + ERR_UMODEUNKNOWNFLAG = '501' + ERR_USERSDONTMATCH = '502' + # ## 6.2 Command responses. + RPL_NONE = '300' + RPL_USERHOST = '302' + RPL_ISON = '303' + RPL_AWAY = '301' + RPL_UNAWAY = '305' + RPL_NOWAWAY = '306' + RPL_WHOISUSER = '311' + RPL_WHOISSERVER = '312' + RPL_WHOISOPERATOR = '313' + RPL_WHOISIDLE = '317' + RPL_ENDOFWHOIS = '318' + RPL_WHOISCHANNELS = '319' + RPL_WHOWASUSER = '314' + RPL_ENDOFWHOWAS = '369' + RPL_LISTSTART = '321' + RPL_LIST = '322' + RPL_LISTEND = '323' + RPL_CHANNELMODEIS = '324' + RPL_NOTOPIC = '331' + RPL_TOPIC = '332' + RPL_INVITING = '341' + RPL_SUMMONING = '342' + RPL_VERSION = '351' + RPL_WHOREPLY = '352' + RPL_ENDOFWHO = '315' + RPL_NAMREPLY = '353' + RPL_ENDOFNAMES = '366' + RPL_LINKS = '364' + RPL_ENDOFLINKS = '365' + RPL_BANLIST = '367' + RPL_ENDOFBANLIST = '368' + RPL_INFO = '371' + RPL_ENDOFINFO = '374' + RPL_MOTDSTART = '375' + RPL_MOTD = '372' + RPL_ENDOFMOTD = '376' + RPL_YOUREOPER = '381' + RPL_REHASHING = '382' + RPL_TIME = '391' + RPL_USERSSTART = '392' + RPL_USERS = '393' + RPL_ENDOFUSERS = '394' + RPL_NOUSERS = '395' + RPL_TRACELINK = '200' + RPL_TRACECONNECTING = '201' + RPL_TRACEHANDSHAKE = '202' + RPL_TRACEUNKNOWN = '203' + RPL_TRACEOPERATOR = '204' + RPL_TRACEUSER = '205' + RPL_TRACESERVER = '206' + RPL_TRACENEWTYPE = '208' + RPL_TRACELOG = '261' + RPL_STATSLINKINFO = '211' + RPL_STATSCOMMANDS = '212' + RPL_STATSCLINE = '213' + RPL_STATSNLINE = '214' + RPL_STATSILINE = '215' + RPL_STATSKLINE = '216' + RPL_STATSYLINE = '218' + RPL_ENDOFSTATS = '219' + RPL_STATSLLINE = '241' + RPL_STATSUPTIME = '242' + RPL_STATSOLINE = '243' + RPL_STATSHLINE = '244' + RPL_UMODEIS = '221' + RPL_LUSERCLIENT = '251' + RPL_LUSEROP = '252' + RPL_LUSERUNKNOWN = '253' + RPL_LUSERCHANNELS = '254' + RPL_LUSERME = '255' + RPL_ADMINME = '256' + RPL_ADMINLOC1 = '257' + RPL_ADMINLOC2 = '258' + RPL_ADMINEMAIL = '259' + + # ################################################################# RFC 2812 + # ## 5.1 Command responses + RPL_WELCOME = '001' + RPL_YOURHOST = '002' + RPL_CREATED = '003' + RPL_MYINFO = '004' + RPL_BOUNCE = '005' + RPL_UNIQOPIS = '325' + RPL_INVITELIST = '346' + RPL_ENDOFINVITELIST = '347' + RPL_EXCEPTLIST = '348' + RPL_ENDOFEXCEPTLIST = '349' + RPL_YOURESERVICE = '383' + RPL_TRACESERVICE = '207' + RPL_TRACECLASS = '209' + RPL_TRACERECONNECT = '210' + RPL_TRACEEND = '262' + RPL_SERVLIST = '234' + RPL_SERVLISTEND = '235' + RPL_TRYAGAIN = '263' + # ## 5.2 Error Replies + ERR_NOSUCHSERVICE = '408' + ERR_BADMASK = '415' + ERR_UNAVAILRESOURCE = '437' + ERR_YOUWILLBEBANNED = '466' + ERR_BADCHANMASK = '476' + ERR_NOCHANMODES = '477' + ERR_BANLISTFULL = '478' + ERR_RESTRICTED = '484' + ERR_UNIQOPPRIVSNEEDED = '485' diff --git a/tools/calculation.py b/tools/calculation.py new file mode 100755 index 0000000..bf4ab8b --- /dev/null +++ b/tools/calculation.py @@ -0,0 +1,195 @@ +# coding=utf-8 +"""Tools to help safely do calculations from user input""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import time +import numbers +import operator +import ast + +__all__ = ['eval_equation'] + + +class ExpressionEvaluator: + """A generic class for evaluating limited forms of Python expressions. + + Instances can overwrite binary_ops and unary_ops attributes with dicts of + the form {ast.Node, function}. When the ast.Node being used as key is + found, it will be evaluated using the given function. + + """ + + class Error(Exception): + pass + + def __init__(self, bin_ops=None, unary_ops=None): + self.binary_ops = bin_ops or {} + self.unary_ops = unary_ops or {} + + def __call__(self, expression_str, timeout=5.0): + """Evaluate a python expression and return the result. + + Raises: + SyntaxError: If the given expression_str is not a valid python + statement. + ExpressionEvaluator.Error: If the instance of ExpressionEvaluator + does not have a handler for the ast.Node. + + """ + ast_expression = ast.parse(expression_str, mode='eval') + return self._eval_node(ast_expression.body, time.time() + timeout) + + def _eval_node(self, node, timeout): + """Recursively evaluate the given ast.Node. + + Uses self.binary_ops and self.unary_ops for the implementation. + + A subclass could overwrite this to handle more nodes, calling it only + for nodes it does not implement it self. + + Raises: + ExpressionEvaluator.Error: If it can't handle the ast.Node. + + """ + if isinstance(node, ast.Num): + return node.n + + elif (isinstance(node, ast.BinOp) and + type(node.op) in self.binary_ops): + left = self._eval_node(node.left, timeout) + right = self._eval_node(node.right, timeout) + if time.time() > timeout: + raise ExpressionEvaluator.Error( + "Time for evaluating expression ran out.") + return self.binary_ops[type(node.op)](left, right) + + elif (isinstance(node, ast.UnaryOp) and + type(node.op) in self.unary_ops): + operand = self._eval_node(node.operand, timeout) + if time.time() > timeout: + raise ExpressionEvaluator.Error( + "Time for evaluating expression ran out.") + return self.unary_ops[type(node.op)](operand) + + raise ExpressionEvaluator.Error( + "Ast.Node '%s' not implemented." % (type(node).__name__,)) + + +def guarded_mul(left, right): + """Decorate a function to raise an error for values > limit.""" + # Only handle ints because floats will overflow anyway. + if not isinstance(left, numbers.Integral): + pass + elif not isinstance(right, numbers.Integral): + pass + elif left in (0, 1) or right in (0, 1): + # Ignore trivial cases. + pass + elif left.bit_length() + right.bit_length() > 664386: + # 664386 is the number of bits (10**100000)**2 has, which is instant on + # my laptop, while (10**1000000)**2 has a noticeable delay. It could + # certainly be improved. + raise ValueError( + "Value is too large to be handled in limited time and memory.") + + return operator.mul(left, right) + + +def pow_complexity(num, exp): + """Estimate the worst case time pow(num, exp) takes to calculate. + + This function is based on experimetal data from the time it takes to + calculate "num**exp" on laptop with i7-2670QM processor on a 32 bit + CPython 2.7.6 interpreter on Windows. + + It tries to implement this surface: x=exp, y=num + 1e5 2e5 3e5 4e5 5e5 6e5 7e5 8e5 9e5 + e1 0.03 0.09 0.16 0.25 0.35 0.46 0.60 0.73 0.88 + e2 0.08 0.24 0.46 0.73 1.03 1.40 1.80 2.21 2.63 + e3 0.15 0.46 0.87 1.39 1.99 2.63 3.35 4.18 5.15 + e4 0.24 0.73 1.39 2.20 3.11 4.18 5.39 6.59 7.88 + e5 0.34 1.03 2.00 3.12 4.48 5.97 7.56 9.37 11.34 + e6 0.46 1.39 2.62 4.16 5.97 7.86 10.09 12.56 15.39 + e7 0.60 1.79 3.34 5.39 7.60 10.16 13.00 16.23 19.44 + e8 0.73 2.20 4.18 6.60 9.37 12.60 16.26 19.83 23.70 + e9 0.87 2.62 5.15 7.93 11.34 15.44 19.40 23.66 28.58 + + For powers of 2 it tries to implement this surface: + 1e7 2e7 3e7 4e7 5e7 6e7 7e7 8e7 9e7 + 1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + 2 0.21 0.44 0.71 0.92 1.20 1.49 1.66 1.95 2.23 + 4 0.43 0.91 1.49 1.96 2.50 3.13 3.54 4.10 4.77 + 8 0.70 1.50 2.24 3.16 3.83 4.66 5.58 6.56 7.67 + + The function number were selected by starting with the theoretical + complexity of exp * log2(num)**2 and fiddling with the exponents + untill it more or less matched with the table. + + Because this function is based on a limited set of data it might + not give accurate results outside these boundaries. The results + derived from large num and exp were quite accurate for small num + and very large exp though, except when num was a power of 2. + """ + if num in (0, 1) or exp in (0, 1): + return 0 + elif (num & (num - 1)) == 0: + # For powers of 2 the scaling is a bit different. + return exp ** 1.092 * num.bit_length() ** 1.65 / 623212911.121 + else: + return exp ** 1.590 * num.bit_length() ** 1.73 / 36864057619.3 + + +def guarded_pow(left, right): + # Only handle ints because floats will overflow anyway. + if not isinstance(left, numbers.Integral): + pass + elif not isinstance(right, numbers.Integral): + pass + elif pow_complexity(left, right) < 0.5: + # Value 0.5 is arbitary and based on a estimated runtime of 0.5s + # on a fairly decent laptop processor. + pass + else: + raise ValueError("Pow expression too complex to calculate.") + + return operator.pow(left, right) + + +class EquationEvaluator(ExpressionEvaluator): + __bin_ops = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: guarded_mul, + ast.Div: operator.truediv, + ast.Pow: guarded_pow, + ast.Mod: operator.mod, + ast.FloorDiv: operator.floordiv, + ast.BitXor: guarded_pow + } + __unary_ops = { + ast.USub: operator.neg, + ast.UAdd: operator.pos, + } + + def __init__(self): + ExpressionEvaluator.__init__( + self, + bin_ops=self.__bin_ops, + unary_ops=self.__unary_ops + ) + + def __call__(self, expression_str): + result = ExpressionEvaluator.__call__(self, expression_str) + + # This wrapper is here so additional sanity checks could be done + # on the result of the eval, but currently none are done. + + return result + + +eval_equation = EquationEvaluator() +"""Evaluates a Python equation expression and returns the result. + +Supports addition (+), subtraction (-), multiplication (*), division (/), +power (**) and modulo (%). +""" diff --git a/tools/jobs.py b/tools/jobs.py new file mode 100755 index 0000000..f1be421 --- /dev/null +++ b/tools/jobs.py @@ -0,0 +1,233 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import copy +import datetime +import sys +import threading +import time + +if sys.version_info.major >= 3: + unicode = str + basestring = str + py3 = True +else: + py3 = False + +try: + import Queue +except ImportError: + import queue as Queue + + +class released(object): + """A context manager that releases a lock temporarily.""" + def __init__(self, lock): + self.lock = lock + + def __enter__(self): + self.lock.release() + + def __exit__(self, _type, _value, _traceback): + self.lock.acquire() + + +class PriorityQueue(Queue.PriorityQueue): + """A priority queue with a peek method.""" + def peek(self): + """Return a copy of the first element without removing it.""" + self.not_empty.acquire() + try: + while not self._qsize(): + self.not_empty.wait() + # Return a copy to avoid corrupting the heap. This is important + # for thread safety if the object is mutable. + return copy.deepcopy(self.queue[0]) + finally: + self.not_empty.release() + + +class JobScheduler(threading.Thread): + + """Calls jobs assigned to it in steady intervals. + + JobScheduler is a thread that keeps track of Jobs and calls them every + X seconds, where X is a property of the Job. It maintains jobs in a + priority queue, where the next job to be called is always the first + item. + Thread safety is maintained with a mutex that is released during long + operations, so methods add_job and clear_jobs can be safely called from + the main thread. + + """ + + min_reaction_time = 30.0 # seconds + """How often should scheduler checks for changes in the job list.""" + + def __init__(self, bot): + """Requires bot as argument for logging.""" + threading.Thread.__init__(self) + self.bot = bot + self._jobs = PriorityQueue() + # While PriorityQueue it self is thread safe, this mutex is needed + # to stop old jobs being put into new queue after clearing the + # queue. + self._mutex = threading.Lock() + # self.cleared is used for more fine grained locking. + self._cleared = False + + def add_job(self, job): + """Add a Job to the current job queue.""" + self._jobs.put(job) + + def clear_jobs(self): + """Clear current Job queue and start fresh.""" + if self._jobs.empty(): + # Guards against getting stuck waiting for self._mutex when + # thread is waiting for self._jobs to not be empty. + return + with self._mutex: + self._cleared = True + self._jobs = PriorityQueue() + + def run(self): + """Run forever.""" + while True: + try: + self._do_next_job() + except Exception: + # Modules exceptions are caught earlier, so this is a bit + # more serious. Options are to either stop the main thread + # or continue this thread and hope that it won't happen + # again. + self.bot.error() + # Sleep a bit to guard against busy-looping and filling + # the log with useless error messages. + time.sleep(10.0) # seconds + + def _do_next_job(self): + """Wait until there is a job and do it.""" + with self._mutex: + # Wait until the next job should be executed. + # This has to be a loop, because signals stop time.sleep(). + while True: + job = self._jobs.peek() + difference = job.next_time - time.time() + duration = min(difference, self.min_reaction_time) + if duration <= 0: + break + with released(self._mutex): + time.sleep(duration) + + self._cleared = False + job = self._jobs.get() + with released(self._mutex): + if job.func.thread: + t = threading.Thread( + target=self._call, args=(job.func,) + ) + t.start() + else: + self._call(job.func) + job.next() + # If jobs were cleared during the call, don't put an old job + # into the new job queue. + if not self._cleared: + self._jobs.put(job) + + def _call(self, func): + """Wrapper for collecting errors from modules.""" + # Sopel.bot.call is way too specialized to be used instead. + try: + func(self.bot) + except Exception: + self.bot.error() + + +class Job(object): + + """Hold information about when a function should be called next. + + Job is a simple structure that hold information about when a function + should be called next. + They can be put in a priority queue, in which case the Job that should + be executed next is returned. + + Calling the method next modifies the Job object for the next time it + should be executed. Current time is used to decide when the job should + be executed next so it should only be called right after the function + was called. + + """ + + max_catchup = 5 + """ + This governs how much the scheduling of jobs is allowed + to get behind before they are simply thrown out to avoid + calling the same function too many times at once. + """ + + def __init__(self, interval, func): + """Initialize Job. + + Args: + interval: number of seconds between calls to func + func: function to be called + + """ + self.next_time = time.time() + interval + self.interval = interval + self.func = func + + def next(self): + """Update self.next_time with the assumption func was just called. + + Returns: A modified job object. + + """ + last_time = self.next_time + current_time = time.time() + delta = last_time + self.interval - current_time + + if last_time > current_time + self.interval: + # Clock appears to have moved backwards. Reset + # the timer to avoid waiting for the clock to + # catch up to whatever time it was previously. + self.next_time = current_time + self.interval + elif delta < 0 and abs(delta) > self.interval * self.max_catchup: + # Execution of jobs is too far behind. Give up on + # trying to catch up and reset the time, so that + # will only be repeated a maximum of + # self.max_catchup times. + self.next_time = current_time - \ + self.interval * self.max_catchup + else: + self.next_time = last_time + self.interval + + return self + + def __cmp__(self, other): + """Compare Job objects according to attribute next_time.""" + return self.next_time - other.next_time + + if py3: + def __lt__(self, other): + return self.next_time < other.next_time + + def __gt__(self, other): + return self.next_time > other.next_time + + def __str__(self): + """Return a string representation of the Job object. + + Example result: + )> + + """ + iso_time = str(datetime.fromtimestamp(self.next_time)) + return "" % \ + (iso_time, self.interval, self.func) + + def __iter__(self): + """This is an iterator. Never stops though.""" + return self diff --git a/tools/target.py b/tools/target.py new file mode 100755 index 0000000..4ce2249 --- /dev/null +++ b/tools/target.py @@ -0,0 +1,90 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import functools +from tools import Identifier + + +@functools.total_ordering +class User(object): + """A representation of a user Sopel is aware of.""" + def __init__(self, nick, user, host): + assert isinstance(nick, Identifier) + self.nick = nick + """The user's nickname.""" + self.user = user + """The user's local username.""" + self.host = host + """The user's hostname.""" + self.channels = {} + """The channels the user is in. + + This maps channel name ``Identifier``\s to ``Channel`` objects.""" + self.account = None + """The IRC services account of the user. + + This relies on IRCv3 account tracking being enabled.""" + self.away = None + """Whether the user is marked as away.""" + + hostmask = property(lambda self: '{}!{}@{}'.format(self.nick, self.user, + self.host)) + """The user's full hostmask.""" + + def __eq__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.nick == other.nick + + def __lt__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.nick < other.nick + + +@functools.total_ordering +class Channel(object): + """A representation of a channel Sopel is in.""" + def __init__(self, name): + assert isinstance(name, Identifier) + self.name = name + """The name of the channel.""" + self.users = {} + """The users in the channel. + + This maps username ``Identifier``\s to channel objects.""" + self.privileges = {} + """The permissions of the users in the channel. + + This maps username ``Identifier``s to bitwise integer values. This can + be compared to appropriate constants from ``sopel.module``.""" + self.topic = '' + """The topic of the channel.""" + + def clear_user(self, nick): + user = self.users.pop(nick, None) + self.privileges.pop(nick, None) + if user != None: + user.channels.pop(self.name, None) + + def add_user(self, user): + assert isinstance(user, User) + self.users[user.nick] = user + self.privileges[user.nick] = 0 + user.channels[self.name] = self + + def rename_user(self, old, new): + if old in self.users: + self.users[new] = self.users.pop(old) + if old in self.privileges: + self.privileges[new] = self.privileges.pop(old) + + def __eq__(self, other): + if not isinstance(other, Channel): + return NotImplemented + return self.name == other.name + + def __lt__(self, other): + if not isinstance(other, Channel): + return NotImplemented + return self.name < other.name diff --git a/tools/time.py b/tools/time.py new file mode 100755 index 0000000..de76127 --- /dev/null +++ b/tools/time.py @@ -0,0 +1,190 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Tools for getting and displaying the time.""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import datetime +try: + import pytz +except: + pytz = False + + +def validate_timezone(zone): + """Return an IETF timezone from the given IETF zone or common abbreviation. + + If the length of the zone is 4 or less, it will be upper-cased before being + looked up; otherwise it will be title-cased. This is the expected + case-insensitivity behavior in the majority of cases. For example, ``'edt'`` + and ``'america/new_york'`` will both return ``'America/New_York'``. + + If the zone is not valid, ``ValueError`` will be raised. If ``pytz`` is not + available, and the given zone is anything other than ``'UTC'``, + ``ValueError`` will be raised. + """ + if zone is None: + return None + if not pytz: + if zone.upper() != 'UTC': + raise ValueError('Only UTC available, since pytz is not installed.') + else: + return zone + + zone = '/'.join(reversed(zone.split(', '))).replace(' ', '_') + if len(zone) <= 4: + zone = zone.upper() + else: + zone = zone.title() + if zone in pytz.all_timezones: + return zone + else: + raise ValueError("Invalid time zone.") + + +def validate_format(tformat): + """Returns the format, if valid, else None""" + try: + time = datetime.datetime.utcnow() + time.strftime(tformat) + except: + raise ValueError('Invalid time format') + return tformat + + +def get_timezone(db=None, config=None, zone=None, nick=None, channel=None): + """Find, and return, the approriate timezone + + Time zone is pulled in the following priority: + 1. `zone`, if it is valid + 2. The timezone for the channel or nick `zone` in `db` if one is set and + valid. + 3. The timezone for the nick `nick` in `db`, if one is set and valid. + 4. The timezone for the channel `channel` in `db`, if one is set and valid. + 5. The default timezone in `config`, if one is set and valid. + + If `db` is not given, or given but not set up, steps 2 and 3 will be + skipped. If `config` is not given, step 4 will be skipped. If no step + yeilds a valid timezone, `None` is returned. + + Valid timezones are those present in the IANA Time Zone Database. Prior to + checking timezones, two translations are made to make the zone names more + human-friendly. First, the string is split on `', '`, the pieces reversed, + and then joined with `'/'`. Next, remaining spaces are replaced with `'_'`. + Finally, strings longer than 4 characters are made title-case, and those 4 + characters and shorter are made upper-case. This means "new york, america" + becomes "America/New_York", and "utc" becomes "UTC". + + This function relies on `pytz` being available. If it is not available, + `None` will always be returned. + """ + def _check(zone): + try: + return validate_timezone(zone) + except ValueError: + return None + + if not pytz: + return None + tz = None + + if zone: + tz = _check(zone) + if not tz: + tz = _check( + db.get_nick_or_channel_value(zone, 'timezone')) + if not tz and nick: + tz = _check(db.get_nick_value(nick, 'timezone')) + if not tz and channel: + tz = _check(db.get_channel_value(channel, 'timezone')) + if not tz and config and config.core.default_timezone: + tz = _check(config.core.default_timezone) + return tz + + +def format_time(db=None, config=None, zone=None, nick=None, channel=None, + time=None): + """Return a formatted string of the given time in the given zone. + + `time`, if given, should be a naive `datetime.datetime` object and will be + treated as being in the UTC timezone. If it is not given, the current time + will be used. If `zone` is given and `pytz` is available, `zone` must be + present in the IANA Time Zone Database; `get_timezone` can be helpful for + this. If `zone` is not given or `pytz` is not available, UTC will be + assumed. + + The format for the string is chosen in the following order: + + 1. The format for the nick `nick` in `db`, if one is set and valid. + 2. The format for the channel `channel` in `db`, if one is set and valid. + 3. The default format in `config`, if one is set and valid. + 4. ISO-8601 + + If `db` is not given or is not set up, steps 1 and 2 are skipped. If config + is not given, step 3 will be skipped.""" + tformat = None + if db: + if nick: + tformat = db.get_nick_value(nick, 'time_format') + if not tformat and channel: + tformat = db.get_channel_value(channel, 'time_format') + if not tformat and config and config.core.default_time_format: + tformat = config.core.default_time_format + if not tformat: + tformat = '%Y-%m-%d - %T%Z' + + if not time: + time = datetime.datetime.utcnow() + + if not pytz or not zone: + return time.strftime(tformat) + else: + if not time.tzinfo: + utc = pytz.timezone('UTC') + time = utc.localize(time) + zone = pytz.timezone(zone) + return time.astimezone(zone).strftime(tformat) + + +def relativeTime(bot, nick, telldate): + """ + Takes a datetime object and returns a string containing the relative time + difference between that datetime-string and now. + It takes a string. Not a datetime object. Fix that sometime. + """ + try: + telldatetime = datetime.datetime.strptime(telldate, bot.config.core.default_time_format) + except ValueError: + return("Unable to parse relative time.") + tz = get_timezone(bot.db, bot.config, None, nick) + timenow = format_time(bot.db, bot.config, tz, nick) + try: + nowdatetime = datetime.datetime.strptime(timenow, bot.config.core.default_time_format) + except ValueError: + return("Unable to parse relative time.") + + timediff = nowdatetime - telldatetime + reltime = [] + if timediff.days: + if timediff.days // 365: + reltime.append( str(timediff.days // 365) + " year" ) + if timediff.days % 365 // 31: + reltime.append( str(timediff.days % 365 // 31) + " month") + if timediff.days % 365 % 31: + reltime.append( str(timediff.days % 365 % 31) + " day") + + else: + if timediff.seconds // 3600: + reltime.append( str(timediff.seconds // 3600) + " hour" ) + if timediff.seconds % 3600 // 60: + reltime.append( str(timediff.seconds % 3600 // 60) + " minute" ) + + for item in reltime: + if item.split(' ')[0] != '1': + reltime[reltime.index(item)] += 's' + + if timediff.days == 0 and timediff.seconds < 60: + reltime = ["less than a minute"] + + if not reltime: + return("Unable to parse relative time.") + return ', '.join(reltime) + ' ago' diff --git a/tools/web.py b/tools/web.py new file mode 100755 index 0000000..98a4802 --- /dev/null +++ b/tools/web.py @@ -0,0 +1,21 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Some functions for making web interactions easier. +""" +import requests, os +from urllib.parse import urlparse + +def secCheck(bot, url): + """ Checks to see if the given link is malicious or not. """ + if not urlparse(url).scheme: + hostname = urlparse("//"+url).hostname + else: + hostname = urlparse(url).hostname + + # Since bot.memory['safety_cache'] apparently doesn't work. + with open(os.path.join(bot.config.homedir, 'malwaredomains.txt'), 'r') as file: + if hostname in file.read().split(): + return None + + return url diff --git a/trigger.py b/trigger.py new file mode 100755 index 0000000..88bc660 --- /dev/null +++ b/trigger.py @@ -0,0 +1,187 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import re +import sys +import datetime + +import tools + +if sys.version_info.major >= 3: + unicode = str + basestring = str + + +class PreTrigger(object): + """A parsed message from the server, which has not been matched against + any rules.""" + component_regex = re.compile(r'([^!]*)!?([^@]*)@?(.*)') + intent_regex = re.compile('\x01(\\S+) ?(.*)\x01') + + def __init__(self, own_nick, line): + """own_nick is the bot's nick, needed to correctly parse sender. + line is the full line from the server.""" + line = line.strip('\r') + self.line = line + + # Break off IRCv3 message tags, if present + self.tags = {} + if line.startswith('@'): + tagstring, line = line.split(' ', 1) + for tag in tagstring[1:].split(';'): + tag = tag.split('=', 1) + if len(tag) > 1: + self.tags[tag[0]] = tag[1] + else: + self.tags[tag[0]] = None + + self.time = datetime.datetime.utcnow() + if 'time' in self.tags: + try: + self.time = datetime.datetime.strptime(self.tags['time'], '%Y-%m-%dT%H:%M:%S.%fZ') + except ValueError: + pass # Server isn't conforming to spec, ignore the server-time + + # TODO note what this is doing and why + if line.startswith(':'): + self.hostmask, line = line[1:].split(' ', 1) + else: + self.hostmask = None + + # TODO note what this is doing and why + if ' :' in line: + argstr, text = line.split(' :', 1) + self.args = argstr.split(' ') + self.args.append(text) + else: + self.args = line.split(' ') + self.text = self.args[-1] + + self.event = self.args[0] + self.args = self.args[1:] + components = PreTrigger.component_regex.match(self.hostmask or '').groups() + self.nick, self.user, self.host = components + self.nick = tools.Identifier(self.nick) + + # If we have arguments, the first one is the sender + # Unless it's a QUIT event + if self.args and self.event != 'QUIT': + target = tools.Identifier(self.args[0]) + else: + target = None + + # Unless we're messaging the bot directly, in which case that second + # arg will be our bot's name. + if target and target.lower() == own_nick.lower(): + target = self.nick + self.sender = target + + # Parse CTCP into a form consistent with IRCv3 intents + if self.event == 'PRIVMSG' or self.event == 'NOTICE': + intent_match = PreTrigger.intent_regex.match(self.args[-1]) + if intent_match: + intent, message = intent_match.groups() + self.tags['intent'] = intent + self.args[-1] = message or '' + + # Populate account from extended-join messages + if self.event == 'JOIN' and len(self.args) == 3: + # Account is the second arg `...JOIN #Sopel account :realname` + self.tags['account'] = self.args[1] + + +class Trigger(unicode): + """A line from the server, which has matched a callable's rules. + + Note that CTCP messages (`PRIVMSG`es and `NOTICE`es which start and end + with `'\\x01'`) will have the `'\\x01'` bytes stripped, and the command + (e.g. `ACTION`) placed mapped to the `'intent'` key in `Trigger.tags`. + """ + sender = property(lambda self: self._pretrigger.sender) + """The channel from which the message was sent. + + In a private message, this is the nick that sent the message.""" + time = property(lambda self: self._pretrigger.time) + """A datetime object at which the message was received by the IRC server. + + If the server does not support server-time, then `time` will be the time + that the message was received by Sopel""" + raw = property(lambda self: self._pretrigger.line) + """The entire message, as sent from the server. This includes the CTCP + \\x01 bytes and command, if they were included.""" + is_privmsg = property(lambda self: self._is_privmsg) + """True if the trigger is from a user, False if it's from a channel.""" + hostmask = property(lambda self: self._pretrigger.hostmask) + """Hostmask of the person who sent the message as !@""" + user = property(lambda self: self._pretrigger.user) + """Local username of the person who sent the message""" + nick = property(lambda self: self._pretrigger.nick) + """The :class:`sopel.tools.Identifier` of the person who sent the message. + """ + host = property(lambda self: self._pretrigger.host) + """The hostname of the person who sent the message""" + event = property(lambda self: self._pretrigger.event) + """The IRC event (e.g. ``PRIVMSG`` or ``MODE``) which triggered the + message.""" + match = property(lambda self: self._match) + """The regular expression :class:`re.MatchObject` for the triggering line. + """ + group = property(lambda self: self._match.group) + """The ``group`` function of the ``match`` attribute. + + See Python :mod:`re` documentation for details.""" + groups = property(lambda self: self._match.groups) + """The ``groups`` function of the ``match`` attribute. + + See Python :mod:`re` documentation for details.""" + groupdict = property(lambda self: self._match.groupdict) + """The ``groupdict`` function of the ``match`` attribute. + + See Python :mod:`re` documentation for details.""" + args = property(lambda self: self._pretrigger.args) + """ + A tuple containing each of the arguments to an event. These are the + strings passed between the event name and the colon. For example, + setting ``mode -m`` on the channel ``#example``, args would be + ``('#example', '-m')`` + """ + tags = property(lambda self: self._pretrigger.tags) + """A map of the IRCv3 message tags on the message.""" + admin = property(lambda self: self._admin) + """True if the nick which triggered the command is one of the bot's admins. + """ + owner = property(lambda self: self._owner) + """True if the nick which triggered the command is the bot's owner.""" + account = property(lambda self: self.tags.get('account') or self._account) + """The account name of the user sending the message. + + This is only available if either the account-tag or the account-notify and + extended-join capabilites are available. If this isn't the case, or the user + sending the message isn't logged in, this will be None. + """ + + def __new__(cls, config, message, match, account=None): + self = unicode.__new__(cls, message.args[-1] if message.args else '') + self._account = account + self._pretrigger = message + self._match = match + self._is_privmsg = message.sender and message.sender.is_nick() + + def match_host_or_nick(pattern): + pattern = tools.get_hostmask_regex(pattern) + return bool( + pattern.match(self.nick) or + pattern.match('@'.join((self.nick, self.host))) + ) + + if config.core.owner_account: + self._owner = config.core.owner_account == self.account + else: + self._owner = match_host_or_nick(config.core.owner) + self._admin = ( + self._owner or + self.account in config.core.admin_accounts or + any(match_host_or_nick(item) for item in config.core.admins) + ) + + return self