first commit

This commit is contained in:
iou1name 2017-11-22 19:26:40 -05:00
commit 7059d64231
82 changed files with 12025 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
__pycache__/
*/__pycache__/
logs/
modules_old/
default.cfg
*.db
*.pid
*.dat
*.txt
tourettes.py

4
README.md Normal file
View File

@ -0,0 +1,4 @@
NIGGER DICKS
NIGGER DICKS
NIGGER DICKS
NIGGER DICKS

101
__init__.py Executable file
View File

@ -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 <elad@fedoraproject.org>
#
# 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)

635
bot.py Executable file
View File

@ -0,0 +1,635 @@
# coding=utf-8
# Copyright 2008, Sean B. Palmer, inamidst.com
# Copyright © 2012, Elad Alfassa <elad@fedoraproject.org>
# 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

263
config/__init__.py Executable file
View File

@ -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 <elad@fedoraproject.org>
# 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!")

200
config/core_section.py Executable file
View File

@ -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."""

351
config/types.py Executable file
View File

@ -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

719
coretasks.py Executable file
View File

@ -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 <elad@fedoraproject.org>
# 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> *( ( "-" / "+" ) *<modes> *<modeparams> )
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]

247
db.py Executable file
View File

@ -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

107
formatting.py Executable file
View File

@ -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])

411
irc.py Executable file
View File

@ -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 <elad@fedoraproject.org>
#
# 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

227
loader.py Executable file
View File

@ -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

55
logger.py Normal file
View File

@ -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')

460
module.py Executable file
View File

@ -0,0 +1,460 @@
# coding=utf-8
"""This contains decorators and tools for creating callable plugin functions.
"""
# Copyright 2013, Ari Koivula, <ari@koivu.la>
# Copyright © 2013, Elad Alfassa <elad@fedoraproject.org>
# Copyright 2013, Lior Ramati <firerogue517@gmail.com>
# 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 Sopels 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

21
modules/8ball.py Executable file
View File

@ -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)

229
modules/admin.py Executable file
View File

@ -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, <elad@fedoraproject.org>
Copyright 2013, Ari Koivula <ari@koivu.la>
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()

276
modules/adminchannel.py Executable file
View File

@ -0,0 +1,276 @@
# coding=utf-8
# Copyright 2010-2011, Michael Yanovich, Alek Rollyson, and Elsie Powell
# Copyright © 2012, Elad Alfassa <elad@fedoraproject.org>
# 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)

22
modules/announce.py Executable file
View File

@ -0,0 +1,22 @@
# coding=utf-8
"""
announce.py - Send a message to all channels
Copyright © 2013, Elad Alfassa, <elad@fedoraproject.org>
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.')

192
modules/ascii.py Executable file
View File

@ -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)

56
modules/away.py Executable file
View File

@ -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)

49
modules/banhe.py Executable file
View File

@ -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")

20
modules/bq.py Executable file
View File

@ -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)

97
modules/bugzilla.py Executable file
View File

@ -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)

66
modules/calc.py Executable file
View File

@ -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__)

276
modules/clock.py Executable file
View File

@ -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)

39
modules/countdown.py Executable file
View File

@ -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 <year> <month> <day> - 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

51
modules/currency.py Executable file
View File

@ -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'] + ')')

259
modules/dice.py Executable file
View File

@ -0,0 +1,259 @@
# coding=utf-8
"""
dice.py - Dice Module
Copyright 2010-2013, Dimitri "Tyrope" Molenaars, TyRope.nl
Copyright 2013, Ari Koivula, <ari@koivu.la>
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<dice_num>-?\d*)
d
(?P<dice_type>-?\d+)
(v(?P<drop_lowest>-?\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__)

13
modules/echo.py Executable file
View File

@ -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))

97
modules/etymology.py Executable file
View File

@ -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)<dd[^>]*>.*?</dd>')
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)(?:\.(?= [A-Z0-9]|\Z)|\Z)'
r_sentence = re.compile(t_sentence % ')(?<!'.join(abbrs))
def unescape(s):
s = s.replace('&gt;', '>')
s = s.replace('&lt;', '<')
s = s.replace('&amp;', '&')
return s
def text(html):
html = r_tag.sub('', html)
html = r_whitespace.sub(' ', html)
return unescape(html).strip()
def etymology(word):
# @@ <nsh> 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

139
modules/find.py Executable file
View File

@ -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)

92
modules/hangman.py Executable file
View File

@ -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())

51
modules/help.py Executable file
View File

@ -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, <elad@fedoraproject.org>
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)

136
modules/ip.py Executable file
View File

@ -0,0 +1,136 @@
# coding=utf-8
"""GeoIP lookup module"""
# Copyright 2011, Dimitri Molenaars, TyRope.nl,
# Copyright © 2013, Elad Alfassa <elad@fedoraproject.org>
# 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__)

78
modules/ipython.py Executable file
View File

@ -0,0 +1,78 @@
# coding=utf-8
"""
ipython.py - sopel ipython console!
Copyright © 2014, Elad Alfassa <elad@fedoraproject.org>
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

46
modules/isup.py Executable file
View File

@ -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.")

41
modules/light.py Executable file
View File

@ -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)

18
modules/lmgtfy.py Executable file
View File

@ -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(' ', '+'))

432
modules/meetbot.py Executable file
View File

@ -0,0 +1,432 @@
# coding=utf-8
"""
meetbot.py - Sopel meeting logger module
Copyright © 2012, Elad Alfassa, <elad@fedoraproject.org>
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('<!doctype html>\n<html>\n<head>\n<meta charset="utf-8">\n<title>%TITLE%</title>\n</head>\n<body>\n<h1>%TITLE%</h1>\n'.replace('%TITLE%', title))
logfile.write('<h4>Meeting started by %s</h4><ul>\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('<li>' + item + '</li>\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('</ul>\n<h4>Meeting ended at %s UTC</h4>\n' % current_time)
plainlog_url = meeting_log_baseurl + channel + '/' + figure_logfile_name(channel) + '.log'
logfile.write('<a href="%s">Full log</a>' % plainlog_url)
logfile.write('\n</body>\n</html>')
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 <h3> 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('</ul><h3>' + trigger.group(2) + '</h3><ul>')
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('<span style="font-weight: bold">Meeting chairs are: </span>' + 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('<span style="font-weight: bold">Action: </span>' + 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('<span style="font-weight: bold">Agreed: </span>' + 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('<a href="%s">%s</a>' % (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> <comment to add>`
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> <comment to add>')
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.')

161
modules/movie.py Executable file
View File

@ -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__)

28
modules/ping.py Executable file
View File

@ -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 + '!')

17
modules/pingall.py Executable file
View File

@ -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())

48
modules/rand.py Executable file
View File

@ -0,0 +1,48 @@
# coding=utf-8
"""
rand.py - Rand Module
Copyright 2013, Ari Koivula, <ari@koivu.la>
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__)

110
modules/reload.py Executable file
View File

@ -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_)

229
modules/remind.py Executable file
View File

@ -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)

46
modules/resistor.py Executable file
View File

@ -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)

198
modules/safety.py Executable file
View File

@ -0,0 +1,198 @@
# coding=utf-8
"""
safety.py - Alerts about malicious URLs
Copyright © 2014, Elad Alfassa, <elad@fedoraproject.org>
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]

79
modules/scramble.py Executable file
View File

@ -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))

128
modules/search.py Executable file
View File

@ -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'<h3><a href="([^"]+)"')
def bing_search(query, lang='en-GB'):
base = 'http://www.bing.com/search?mkt=%s&q=' % lang
bytes = requests.get(base + query)
m = r_bing.search(bytes)
if m:
return m.group(1)
r_duck = re.compile(r'nofollow" class="[^"]+" href="(?!https?:\/\/r\.search\.yahoo)(.*?)">')
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.')

50
modules/seen.py Executable file
View File

@ -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 <nick> - Reports when <nick> 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)

53
modules/spellcheck.py Executable file
View File

@ -0,0 +1,53 @@
# coding=utf-8
"""
spellcheck.py - Sopel spell check Module
Copyright © 2012, Elad Alfassa, <elad@fedoraproject.org>
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)

174
modules/tell.py Executable file
View File

@ -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

69
modules/tld.py Executable file
View File

@ -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)<td><a href="\S+" title="\S+">\.{0}</a></td>\n(<td><a href=".*</a></td>\n)?<td>([A-Za-z0-9].*?)</td>\n<td>(.*)</td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\n'
search = search.format(tld)
re_country = re.compile(search)
matches = re_country.findall(page)
if not matches:
search = r'(?i)<td><a href="\S+" title="(\S+)">\.{0}</a></td>\n<td><a href=".*">(.*)</a></td>\n<td>([A-Za-z0-9].*?)</td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\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'<td><a href="\S+" title="\S+">.{0}</a></td>\n<td><span class="flagicon"><img.*?\">(.*?)</a></td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\n<td[^>]*>(.*?)</td>\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] == "&#160;":
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)

208
modules/translate.py Executable file
View File

@ -0,0 +1,208 @@
# coding=utf-8
"""
translate.py - Sopel Translation Module
Copyright 2008, Sean B. Palmer, inamidst.com
Copyright © 2013-2014, Elad Alfassa <elad@fedoraproject.org>
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('&#39;', "'")
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('&#39;', "'")
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__)

51
modules/unicode_info.py Executable file
View File

@ -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__)

186
modules/units.py Executable file
View File

@ -0,0 +1,186 @@
# coding=utf-8
"""
units.py - Unit conversion module for Sopel
Copyright © 2013, Elad Alfassa, <elad@fedoraproject.org>
Copyright © 2013, Dimitri Molenaars, <tyrope@tyrope.nl>
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__)

26
modules/uptime.py Executable file
View File

@ -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))

68
modules/url.py Executable file
View File

@ -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("<title>") == -1:
continue
title = res.text[res.text.find("<title>")+7:res.text.find("</title>")]
title = HTMLParser().unescape(title)
title = title.replace("\n","").strip()
hostname = urlparse(url).hostname
bot.say('[ \x0310%s \x03] - \x0304%s' % (title, hostname))

81
modules/version.py Executable file
View File

@ -0,0 +1,81 @@
# coding=utf-8
"""
version.py - Sopel Version Module
Copyright 2009, Silas Baronda
Copyright 2014, Dimitri Molenaars <tyrope@tyrope.nl>
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))

181
modules/weather.py Executable file
View File

@ -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))

134
modules/wikipedia.py Executable file
View File

@ -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)

101
modules/wiktionary.py Executable file
View File

@ -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)<ul>.*?</ul>')
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 ('<p>' in line):
etymology = text(line)
elif (mode is not None) and ('<li>' in line):
definitions.setdefault(mode, []).append(text(line))
if '<hr' in line:
break
return etymology, definitions
parts = ('preposition', 'particle', 'noun', 'verb',
'adjective', 'adverb', 'interjection')
def format(result, definitions, number=2):
for part in parts:
if part in definitions:
defs = definitions[part][:number]
result += u'{}: '.format(part)
n = ['%s. %s' % (i + 1, e.strip(' .')) for i, e in enumerate(defs)]
result += ', '.join(n)
return result.strip(' .,')
@commands('wt', 'define', 'dict')
@example('.wt bailiwick')
def wiktionary(bot, trigger):
"""Look up a word on Wiktionary."""
word = trigger.group(2)
if word is None:
bot.reply('You must tell me what to look up!')
return
_etymology, definitions = wikt(word)
if not definitions:
bot.say("Couldn't get any definitions for %s." % word)
return
result = format(word, definitions)
if len(result) < 150:
result = format(word, definitions, 3)
if len(result) < 150:
result = format(word, definitions, 5)
if len(result) > 300:
result = result[:295] + '[...]'
bot.say(result)

19
modules/willilike.py Executable file
View File

@ -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!")

78
modules/wolfram.py Executable file
View File

@ -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)

124
modules/xkcd.py Executable file
View File

@ -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)

205
run_script.py Executable file
View File

@ -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 <elad@fedoraproject.org>
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()

11
sopel Executable file
View File

@ -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())

36
test/test_config.py Normal file
View File

@ -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)

257
test/test_db.py Normal file
View File

@ -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'

26
test/test_formatting.py Normal file
View File

@ -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'

147
test/test_irc.py Normal file
View File

@ -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])

210
test/test_module.py Normal file
View File

@ -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

245
test/test_trigger.py Normal file
View File

@ -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

183
test_tools.py Executable file
View File

@ -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, <ari@koivu.la>
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)

352
tools/__init__.py Executable file
View File

@ -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 <elad@fedoraproject.org>
# 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)

203
tools/_events.py Executable file
View File

@ -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'

195
tools/calculation.py Executable file
View File

@ -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 (%).
"""

233
tools/jobs.py Executable file
View File

@ -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:
<Job(2013-06-14 11:01:36.884000, 20s, <function upper at 0x02386BF0>)>
"""
iso_time = str(datetime.fromtimestamp(self.next_time))
return "<Job(%s, %ss, %s)>" % \
(iso_time, self.interval, self.func)
def __iter__(self):
"""This is an iterator. Never stops though."""
return self

90
tools/target.py Executable file
View File

@ -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

190
tools/time.py Executable file
View File

@ -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'

21
tools/web.py Executable file
View File

@ -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

187
trigger.py Executable file
View File

@ -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 <nick>!<user>@<host>"""
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