commit 7059d64231df9514b608f7cd2fcaa11b96adafeb Author: iou1name Date: Wed Nov 22 19:26:40 2017 -0500 first commit 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