first commit
This commit is contained in:
commit
7059d64231
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
__pycache__/
|
||||
*/__pycache__/
|
||||
logs/
|
||||
modules_old/
|
||||
default.cfg
|
||||
*.db
|
||||
*.pid
|
||||
*.dat
|
||||
*.txt
|
||||
tourettes.py
|
||||
|
4
README.md
Normal file
4
README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
NIGGER DICKS
|
||||
NIGGER DICKS
|
||||
NIGGER DICKS
|
||||
NIGGER DICKS
|
101
__init__.py
Executable file
101
__init__.py
Executable 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
635
bot.py
Executable 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
263
config/__init__.py
Executable 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
200
config/core_section.py
Executable 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
351
config/types.py
Executable 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
719
coretasks.py
Executable 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
247
db.py
Executable 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
107
formatting.py
Executable 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
411
irc.py
Executable 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
227
loader.py
Executable 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
55
logger.py
Normal 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
460
module.py
Executable 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 Sopel’s configuration are exempted from rate
|
||||
limits.
|
||||
|
||||
Rate-limited functions that use scheduled future commands should import
|
||||
threading.Timer() instead of sched, or rate limiting will not work properly.
|
||||
"""
|
||||
def add_attribute(function):
|
||||
function.rate = user
|
||||
function.channel_rate = channel
|
||||
function.global_rate = server
|
||||
return function
|
||||
return add_attribute
|
||||
|
||||
|
||||
def require_privmsg(message=None):
|
||||
"""Decorate a function to only be triggerable from a private message.
|
||||
|
||||
If it is triggered in a channel message, `message` will be said if given.
|
||||
"""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def _nop(*args, **kwargs):
|
||||
# Assign trigger and bot for easy access later
|
||||
bot, trigger = args[0:2]
|
||||
if trigger.is_privmsg:
|
||||
return function(*args, **kwargs)
|
||||
else:
|
||||
if message and not callable(message):
|
||||
bot.say(message)
|
||||
return _nop
|
||||
# Hack to allow decorator without parens
|
||||
if callable(message):
|
||||
return actual_decorator(message)
|
||||
return actual_decorator
|
||||
|
||||
|
||||
def require_chanmsg(message=None):
|
||||
"""Decorate a function to only be triggerable from a channel message.
|
||||
|
||||
If it is triggered in a private message, `message` will be said if given.
|
||||
"""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def _nop(*args, **kwargs):
|
||||
# Assign trigger and bot for easy access later
|
||||
bot, trigger = args[0:2]
|
||||
if not trigger.is_privmsg:
|
||||
return function(*args, **kwargs)
|
||||
else:
|
||||
if message and not callable(message):
|
||||
bot.say(message)
|
||||
return _nop
|
||||
# Hack to allow decorator without parens
|
||||
if callable(message):
|
||||
return actual_decorator(message)
|
||||
return actual_decorator
|
||||
|
||||
|
||||
def require_privilege(level, message=None):
|
||||
"""Decorate a function to require at least the given channel permission.
|
||||
|
||||
`level` can be one of the privilege levels defined in this module. If the
|
||||
user does not have the privilege, `message` will be said if given. If it is
|
||||
a private message, no checking will be done."""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def guarded(bot, trigger, *args, **kwargs):
|
||||
# If this is a privmsg, ignore privilege requirements
|
||||
if trigger.is_privmsg:
|
||||
return function(bot, trigger, *args, **kwargs)
|
||||
channel_privs = bot.privileges[trigger.sender]
|
||||
allowed = channel_privs.get(trigger.nick, 0) >= level
|
||||
if not trigger.is_privmsg and not allowed:
|
||||
if message and not callable(message):
|
||||
bot.say(message)
|
||||
else:
|
||||
return function(bot, trigger, *args, **kwargs)
|
||||
return guarded
|
||||
return actual_decorator
|
||||
|
||||
|
||||
def require_admin(message=None):
|
||||
"""Decorate a function to require the triggering user to be a bot admin.
|
||||
|
||||
If they are not, `message` will be said if given."""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def guarded(bot, trigger, *args, **kwargs):
|
||||
if not trigger.admin:
|
||||
if message and not callable(message):
|
||||
bot.say(message)
|
||||
else:
|
||||
return function(bot, trigger, *args, **kwargs)
|
||||
return guarded
|
||||
# Hack to allow decorator without parens
|
||||
if callable(message):
|
||||
return actual_decorator(message)
|
||||
return actual_decorator
|
||||
|
||||
|
||||
def require_owner(message=None):
|
||||
"""Decorate a function to require the triggering user to be the bot owner.
|
||||
|
||||
If they are not, `message` will be said if given."""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def guarded(bot, trigger, *args, **kwargs):
|
||||
if not trigger.owner:
|
||||
if message and not callable(message):
|
||||
bot.say(message)
|
||||
else:
|
||||
return function(bot, trigger, *args, **kwargs)
|
||||
return guarded
|
||||
# Hack to allow decorator without parens
|
||||
if callable(message):
|
||||
return actual_decorator(message)
|
||||
return actual_decorator
|
||||
|
||||
|
||||
def url(url_rule):
|
||||
"""Decorate a function to handle URLs.
|
||||
|
||||
This decorator takes a regex string that will be matched against URLs in a
|
||||
message. The function it decorates, in addition to the bot and trigger,
|
||||
must take a third argument ``match``, which is the regular expression match
|
||||
of the url. This should be used rather than the matching in trigger, in
|
||||
order to support e.g. the ``.title`` command.
|
||||
"""
|
||||
def actual_decorator(function):
|
||||
@functools.wraps(function)
|
||||
def helper(bot, trigger, match=None):
|
||||
match = match or trigger
|
||||
return function(bot, trigger, match)
|
||||
helper.url_regex = re.compile(url_rule)
|
||||
return helper
|
||||
return actual_decorator
|
||||
|
||||
|
||||
class example(object):
|
||||
"""Decorate a function with an example.
|
||||
|
||||
Add an example attribute into a function and generate a test.
|
||||
"""
|
||||
# TODO dat doc doe >_<
|
||||
def __init__(self, msg, result=None, privmsg=False, admin=False,
|
||||
owner=False, repeat=1, re=False, ignore=None):
|
||||
"""Accepts arguments for the decorator.
|
||||
|
||||
Args:
|
||||
msg - The example message to give to the function as input.
|
||||
result - Resulting output from calling the function with msg.
|
||||
privmsg - If true, make the message appear to have sent in a
|
||||
private message to the bot. If false, make it appear to have
|
||||
come from a channel.
|
||||
admin - Bool. Make the message appear to have come from an admin.
|
||||
owner - Bool. Make the message appear to have come from an owner.
|
||||
repeat - How many times to repeat the test. Usefull for tests that
|
||||
return random stuff.
|
||||
re - Bool. If true, result is interpreted as a regular expression.
|
||||
ignore - a list of outputs to ignore.
|
||||
|
||||
"""
|
||||
# Wrap result into a list for get_example_test
|
||||
if isinstance(result, list):
|
||||
self.result = result
|
||||
elif result is not None:
|
||||
self.result = [result]
|
||||
else:
|
||||
self.result = None
|
||||
self.use_re = re
|
||||
self.msg = msg
|
||||
self.privmsg = privmsg
|
||||
self.admin = admin
|
||||
self.owner = owner
|
||||
self.repeat = repeat
|
||||
|
||||
if isinstance(ignore, list):
|
||||
self.ignore = ignore
|
||||
elif ignore is not None:
|
||||
self.ignore = [ignore]
|
||||
else:
|
||||
self.ignore = []
|
||||
|
||||
def __call__(self, func):
|
||||
if not hasattr(func, "example"):
|
||||
func.example = []
|
||||
|
||||
if self.result:
|
||||
test = test_tools.get_example_test(
|
||||
func, self.msg, self.result, self.privmsg, self.admin,
|
||||
self.owner, self.repeat, self.use_re, self.ignore
|
||||
)
|
||||
test_tools.insert_into_module(
|
||||
test, func.__module__, func.__name__, 'test_example'
|
||||
)
|
||||
|
||||
record = {
|
||||
"example": self.msg,
|
||||
"result": self.result,
|
||||
"privmsg": self.privmsg,
|
||||
"admin": self.admin,
|
||||
}
|
||||
func.example.append(record)
|
||||
return func
|
21
modules/8ball.py
Executable file
21
modules/8ball.py
Executable 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
229
modules/admin.py
Executable 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
276
modules/adminchannel.py
Executable 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
22
modules/announce.py
Executable 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
192
modules/ascii.py
Executable 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
56
modules/away.py
Executable 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
49
modules/banhe.py
Executable 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
20
modules/bq.py
Executable 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
97
modules/bugzilla.py
Executable 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
66
modules/calc.py
Executable 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
276
modules/clock.py
Executable 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
39
modules/countdown.py
Executable 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
51
modules/currency.py
Executable 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
259
modules/dice.py
Executable 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
13
modules/echo.py
Executable 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
97
modules/etymology.py
Executable 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('>', '>')
|
||||
s = s.replace('<', '<')
|
||||
s = s.replace('&', '&')
|
||||
return s
|
||||
|
||||
|
||||
def text(html):
|
||||
html = r_tag.sub('', html)
|
||||
html = r_whitespace.sub(' ', html)
|
||||
return unescape(html).strip()
|
||||
|
||||
|
||||
def etymology(word):
|
||||
# @@ <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
139
modules/find.py
Executable 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
92
modules/hangman.py
Executable 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
51
modules/help.py
Executable 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
136
modules/ip.py
Executable 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
78
modules/ipython.py
Executable 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
46
modules/isup.py
Executable 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
41
modules/light.py
Executable 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
18
modules/lmgtfy.py
Executable 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
432
modules/meetbot.py
Executable 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
161
modules/movie.py
Executable 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
28
modules/ping.py
Executable 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
17
modules/pingall.py
Executable 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
48
modules/rand.py
Executable 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
110
modules/reload.py
Executable 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
229
modules/remind.py
Executable 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
46
modules/resistor.py
Executable 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
198
modules/safety.py
Executable 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
79
modules/scramble.py
Executable 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
128
modules/search.py
Executable 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
50
modules/seen.py
Executable 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
53
modules/spellcheck.py
Executable 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
174
modules/tell.py
Executable 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
69
modules/tld.py
Executable 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] == " ":
|
||||
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
208
modules/translate.py
Executable 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(''', "'")
|
||||
msg = '"%s" (%s to %s, translate.google.com)' % (msg, in_lang, out_lang)
|
||||
else:
|
||||
msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (in_lang, out_lang)
|
||||
|
||||
bot.reply(msg)
|
||||
else:
|
||||
bot.reply('Language guessing failed, so try suggesting one!')
|
||||
|
||||
|
||||
@commands('translate', 'tr')
|
||||
@example('.tr :en :fr my dog', '"mon chien" (en to fr, translate.google.com)')
|
||||
@example('.tr היי', '"Hey" (iw to en, translate.google.com)')
|
||||
@example('.tr mon chien', '"my dog" (fr to en, translate.google.com)')
|
||||
def tr2(bot, trigger):
|
||||
"""Translates a phrase, with an optional language hint."""
|
||||
command = trigger.group(2)
|
||||
|
||||
if not command:
|
||||
return bot.reply('You did not give me anything to translate')
|
||||
|
||||
def langcode(p):
|
||||
return p.startswith(':') and (2 < len(p) < 10) and p[1:].isalpha()
|
||||
|
||||
args = ['auto', 'en']
|
||||
|
||||
for i in range(2):
|
||||
if ' ' not in command:
|
||||
break
|
||||
prefix, cmd = command.split(' ', 1)
|
||||
if langcode(prefix):
|
||||
args[i] = prefix[1:]
|
||||
command = cmd
|
||||
phrase = command
|
||||
|
||||
if (len(phrase) > 350) and (not trigger.admin):
|
||||
return bot.reply('Phrase must be under 350 characters.')
|
||||
|
||||
if phrase.strip() == '':
|
||||
return bot.reply('You need to specify a string for me to translate!')
|
||||
|
||||
src, dest = args
|
||||
if src != dest:
|
||||
msg, src = translate(phrase, src, dest,
|
||||
verify_ssl=bot.config.core.verify_ssl)
|
||||
if sys.version_info.major < 3 and isinstance(msg, str):
|
||||
msg = msg.decode('utf-8')
|
||||
if msg:
|
||||
msg = web.decode(msg) # msg.replace(''', "'")
|
||||
msg = '"%s" (%s to %s, translate.google.com)' % (msg, src, dest)
|
||||
else:
|
||||
msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (src, dest)
|
||||
|
||||
bot.reply(msg)
|
||||
else:
|
||||
bot.reply('Language guessing failed, so try suggesting one!')
|
||||
|
||||
|
||||
def get_random_lang(long_list, short_list):
|
||||
random_index = random.randint(0, len(long_list) - 1)
|
||||
random_lang = long_list[random_index]
|
||||
if random_lang not in short_list:
|
||||
short_list.append(random_lang)
|
||||
else:
|
||||
return get_random_lang(long_list, short_list)
|
||||
return short_list
|
||||
|
||||
|
||||
@commands('mangle', 'mangle2')
|
||||
def mangle(bot, trigger):
|
||||
"""Repeatedly translate the input until it makes absolutely no sense."""
|
||||
verify_ssl = bot.config.core.verify_ssl
|
||||
global mangle_lines
|
||||
long_lang_list = ['fr', 'de', 'es', 'it', 'no', 'he', 'la', 'ja', 'cy', 'ar', 'yi', 'zh', 'nl', 'ru', 'fi', 'hi', 'af', 'jw', 'mr', 'ceb', 'cs', 'ga', 'sv', 'eo', 'el', 'ms', 'lv']
|
||||
lang_list = []
|
||||
for __ in range(0, 8):
|
||||
lang_list = get_random_lang(long_lang_list, lang_list)
|
||||
random.shuffle(lang_list)
|
||||
if trigger.group(2) is None:
|
||||
try:
|
||||
phrase = (mangle_lines[trigger.sender.lower()], '')
|
||||
except:
|
||||
bot.reply("What do you want me to mangle?")
|
||||
return
|
||||
else:
|
||||
phrase = (trigger.group(2).strip(), '')
|
||||
if phrase[0] == '':
|
||||
bot.reply("What do you want me to mangle?")
|
||||
return
|
||||
for lang in lang_list:
|
||||
backup = phrase
|
||||
try:
|
||||
phrase = translate(phrase[0], 'en', lang,
|
||||
verify_ssl=verify_ssl)
|
||||
except:
|
||||
phrase = False
|
||||
if not phrase:
|
||||
phrase = backup
|
||||
break
|
||||
|
||||
try:
|
||||
phrase = translate(phrase[0], lang, 'en', verify_ssl=verify_ssl)
|
||||
except:
|
||||
phrase = backup
|
||||
continue
|
||||
|
||||
if not phrase:
|
||||
phrase = backup
|
||||
break
|
||||
bot.reply(phrase[0])
|
||||
|
||||
|
||||
@rule('(.*)')
|
||||
@priority('low')
|
||||
def collect_mangle_lines(bot, trigger):
|
||||
global mangle_lines
|
||||
mangle_lines[trigger.sender.lower()] = "%s said '%s'" % (trigger.nick, (trigger.group(0).strip()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from test_tools import run_example_tests
|
||||
run_example_tests(__file__)
|
51
modules/unicode_info.py
Executable file
51
modules/unicode_info.py
Executable 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
186
modules/units.py
Executable 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
26
modules/uptime.py
Executable 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
68
modules/url.py
Executable 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
81
modules/version.py
Executable 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
181
modules/weather.py
Executable 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
134
modules/wikipedia.py
Executable 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
101
modules/wiktionary.py
Executable 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
19
modules/willilike.py
Executable 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
78
modules/wolfram.py
Executable 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
124
modules/xkcd.py
Executable 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
205
run_script.py
Executable 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
11
sopel
Executable 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
36
test/test_config.py
Normal 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
257
test/test_db.py
Normal 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
26
test/test_formatting.py
Normal 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
147
test/test_irc.py
Normal 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
210
test/test_module.py
Normal 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
245
test/test_trigger.py
Normal 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
183
test_tools.py
Executable 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
352
tools/__init__.py
Executable 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
203
tools/_events.py
Executable 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
195
tools/calculation.py
Executable 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
233
tools/jobs.py
Executable 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
90
tools/target.py
Executable 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
190
tools/time.py
Executable 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
21
tools/web.py
Executable 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
187
trigger.py
Executable 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
|
Loading…
Reference in New Issue
Block a user