├── helga ├── bin │ ├── __init__.py │ └── helga.py ├── comm │ ├── __init__.py │ ├── base.py │ └── irc.py ├── tests │ ├── __init__.py │ ├── bin │ │ ├── __init__.py │ │ └── test_helga.py │ ├── comm │ │ ├── __init__.py │ │ ├── test_slack.py │ │ └── test_irc.py │ ├── util │ │ ├── __init__.py │ │ └── test_encodings.py │ ├── plugins │ │ ├── __init__.py │ │ ├── test_ping.py │ │ ├── test_version.py │ │ ├── test_operator.py │ │ ├── test_manager.py │ │ ├── test_help.py │ │ └── test_webhooks.py │ ├── webhooks │ │ ├── __init__.py │ │ ├── test_announcements.py │ │ └── test_logger.py │ ├── test_settings.py │ ├── test_db.py │ └── test_log.py ├── util │ ├── __init__.py │ └── encodings.py ├── webhooks │ ├── __init__.py │ ├── logger │ │ ├── footer.mustache │ │ ├── index.mustache │ │ ├── channel_index.mustache │ │ ├── channel_log.mustache │ │ ├── header.mustache │ │ └── __init__.py │ └── announcements.py ├── plugins │ ├── ping.py │ ├── version.py │ ├── help.py │ ├── operator.py │ ├── manager.py │ └── webhooks.py ├── __init__.py ├── db.py ├── log.py └── settings.py ├── setup.cfg ├── .coveragerc ├── LICENSE ├── .travis.yml ├── MANIFEST.in ├── requirements.txt ├── tox.ini ├── Dockerfile ├── .gitignore ├── LICENSE-MIT ├── Vagrantfile ├── README.rst ├── setup.py ├── docs ├── source │ ├── index.rst │ ├── conf.py │ ├── api.rst │ ├── getting_started.rst │ ├── builtins.rst │ ├── configuring_helga.rst │ └── webhooks.rst └── Makefile ├── CHANGELOG.rst └── .irssi ├── config └── default.theme /helga/bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/comm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/comm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helga/tests/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs=lib 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | helga/tests/* 4 | helga/my_settings.py 5 | -------------------------------------------------------------------------------- /helga/webhooks/logger/footer.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Shaun Duncan 2 | 3 | Dual licensed under the MIT (see LICENSE-MIT) and GPL (see LICENSE-GPL) 4 | licenses. 5 | -------------------------------------------------------------------------------- /helga/webhooks/logger/index.mustache: -------------------------------------------------------------------------------- 1 | {{> header }} 2 | {{# channels }} 3 |

#{{ . }}

4 | {{/ channels }} 5 | {{> footer }} 6 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_ping.py: -------------------------------------------------------------------------------- 1 | from helga.plugins import ping 2 | 3 | 4 | def test_ping(): 5 | assert ping.ping('client', 'chan', 'nick', 'msg', 'cmd', 'args') == 'pong' 6 | -------------------------------------------------------------------------------- /helga/webhooks/logger/channel_index.mustache: -------------------------------------------------------------------------------- 1 | {{> header }} 2 | {{# dates }} 3 |

{{ . }}

4 | {{/ dates }} 5 | {{> footer }} 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install tox 4 | - pip install coveralls 5 | script: tox 6 | env: 7 | - TOXENV=py27 8 | after_success: 9 | coveralls 10 | services: 11 | - mongodb 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include tox.ini 3 | include *.md 4 | include *.rst 5 | include .travis.yml 6 | include LICENSE 7 | include LICENSE-MIT 8 | include LICENSE-GPL 9 | include Vagrantfile 10 | include *.mustache 11 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_version.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | 3 | from helga.plugins import version 4 | 5 | 6 | @patch('helga.plugins.version.helga') 7 | def test_version(helga): 8 | helga.__version__ = '1.0' 9 | assert version.version() == 'Helga version 1.0' 10 | -------------------------------------------------------------------------------- /helga/plugins/ping.py: -------------------------------------------------------------------------------- 1 | from helga.plugins import command 2 | 3 | 4 | @command('ping', help='A very simple PING plugin. Response with pong. Usage: helga ping') 5 | def ping(*args, **kwargs): 6 | """ 7 | Respond to a ping with a pong 8 | """ 9 | return u'pong' 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # System Requirements: 2 | # openssl 3 | # libssl-dev 4 | # mongodb 5 | # for cffi==1.1.0, required by pyOpenSSL>=0.14: 6 | # libffi6 7 | # libffi-dev 8 | 9 | autobahn 10 | cffi 11 | decorator==3.4.0 12 | pymongo 13 | pyOpenSSL 14 | pystache==0.5.4 15 | smokesignal 16 | Twisted 17 | requests 18 | -------------------------------------------------------------------------------- /helga/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt 2 | 3 | __title__ = 'helga' 4 | __version__ = '1.7.13' 5 | __author__ = 'Shaun Duncan' 6 | __license__ = 'MIT/GPLv3' 7 | __copyright__ = 'Copyright {0} Shaun Duncan'.format(dt.now().strftime('%Y')) 8 | __description__ = 'A full-featured chat bot for Python 2.7 with plugin support' 9 | -------------------------------------------------------------------------------- /helga/plugins/version.py: -------------------------------------------------------------------------------- 1 | import helga 2 | 3 | from helga.plugins import command 4 | 5 | 6 | @command('version', help='Responds in chat with the current version. Usage: helga version') 7 | def version(*args, **kwargs): 8 | """ 9 | Respond to a version request with the current version 10 | """ 11 | return u'Helga version {0}'.format(helga.__version__) 12 | -------------------------------------------------------------------------------- /helga/webhooks/logger/channel_log.mustache: -------------------------------------------------------------------------------- 1 | {{> header }} 2 |

3 | Download Log 4 |

5 | 6 | 7 | 8 | {{# messages }} 9 | 10 | 11 | 12 | 13 | 14 | {{/ messages }} 15 | 16 |
{{ time }}{{ nick }}
{{ message }}
17 | {{> footer }} 18 | 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27 3 | downloadcache = {toxworkdir}/_download/ 4 | 5 | [testenv] 6 | deps = 7 | -r{toxinidir}/requirements.txt 8 | pytest 9 | mock 10 | pretend 11 | freezegun 12 | pytest-cov 13 | sitepackages = False 14 | setenv = 15 | HELGA_SETTINGS= 16 | commands = 17 | py.test -q --cov helga --cov-report term-missing 18 | 19 | [testenv:docs] 20 | deps = 21 | -r{toxinidir}/requirements.txt 22 | sphinx 23 | sphinx_rtd_theme 24 | commands = 25 | sphinx-build -a -b html {toxinidir}/docs/source {toxinidir}/docs/build/html 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | EXPOSE 6667 27017 4 | 5 | RUN apt-get update -qq 6 | RUN apt-get install -qqy \ 7 | git \ 8 | mongodb \ 9 | ngircd \ 10 | openssl \ 11 | libssl-dev \ 12 | python-dev \ 13 | python-pip \ 14 | python-setuptools \ 15 | libffi6 \ 16 | libffi-dev 17 | 18 | ADD . /opt/helga 19 | WORKDIR /opt/helga 20 | 21 | RUN sed -i -s 's/^bind_ip = 127.0.0.1/#bind_ip = 127.0.0.1/' /etc/mongodb.conf && service mongodb restart 22 | 23 | RUN pip install --upgrade pip 24 | RUN pip install service_identity 25 | 26 | RUN cd /opt/helga && python setup.py install 27 | 28 | 29 | ENTRYPOINT ["/usr/local/bin/helga"] 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | /dist 10 | /build 11 | /docs/build 12 | /eggs 13 | /parts 14 | /bin 15 | /var 16 | /sdist 17 | /develop-eggs 18 | /.installed.cfg 19 | /lib 20 | /lib64 21 | /include 22 | 23 | # Databases 24 | *.sqlite3 25 | *.sqlite 26 | 27 | # Installer logs 28 | pip-log.txt 29 | 30 | # Unit test / coverage reports 31 | .coverage 32 | .tox 33 | nosetests.xml 34 | 35 | # Local settings 36 | my_settings.py 37 | .vagrant 38 | 39 | # Default helga channel logs 40 | .logs 41 | 42 | # Virtual environment and test related 43 | .cache 44 | .eggs 45 | /venv 46 | /env 47 | .env 48 | 49 | /*.log 50 | /.pytest_cache 51 | -------------------------------------------------------------------------------- /helga/webhooks/announcements.py: -------------------------------------------------------------------------------- 1 | from helga import log 2 | from helga.plugins.webhooks import authenticated, route 3 | 4 | 5 | logger = log.getLogger(__name__) 6 | 7 | 8 | @route('/announce/(?P[\w\-_]+)', methods=['POST']) 9 | @authenticated 10 | def announce(request, irc_client, channel): 11 | """ 12 | An endpoint for announcing a message on a channel. POST only, must 13 | provide a single data param 'message' 14 | """ 15 | if not channel.startswith('#'): 16 | channel = '#{0}'.format(channel) 17 | 18 | message = request.args.get('message', [''])[0] 19 | if not message: 20 | request.setResponseCode(400) 21 | return 'Param message is required' 22 | 23 | logger.info('Sending message to %s: "%s"', channel, message) 24 | irc_client.msg(channel, message) 25 | 26 | # Return accepted 27 | return 'Message Sent' 28 | -------------------------------------------------------------------------------- /helga/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import pytest 5 | 6 | from helga import settings 7 | 8 | 9 | def test_configure_execfile(tmpdir): 10 | file = tmpdir.join('foo.py') 11 | file.write('MY_CUSTOM_SETTING = "foo"') 12 | 13 | assert not hasattr(settings, 'MY_CUSTOM_SETTING') 14 | settings.configure(str(file)) 15 | assert settings.MY_CUSTOM_SETTING == 'foo' 16 | delattr(settings, 'MY_CUSTOM_SETTING') 17 | 18 | 19 | def test_configure_import(tmpdir): 20 | file = tmpdir.join('foo.py') 21 | file.write('MY_CUSTOM_SETTING = "foo"') 22 | 23 | sys.path.insert(0, str(tmpdir)) 24 | filename = os.path.basename(str(file)).replace('.py', '') 25 | 26 | assert not hasattr(settings, 'MY_CUSTOM_SETTING') 27 | settings.configure(filename) 28 | assert settings.MY_CUSTOM_SETTING == 'foo' 29 | delattr(settings, 'MY_CUSTOM_SETTING') 30 | 31 | 32 | def test_configure_import_raises(): 33 | with pytest.raises(ImportError): 34 | settings.configure('foo.bar') 35 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | MPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /helga/webhooks/logger/header.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 33 | 34 | 35 |
36 | 39 |

All Times In UTC

40 | -------------------------------------------------------------------------------- /helga/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | `pymongo`_ connection objects and utilities 3 | 4 | .. attribute:: client 5 | 6 | A `pymongo.mongo_client.MongoClient` instance, the connection client to MongoDB 7 | 8 | .. attribute:: db 9 | 10 | A `pymongo.database.Database` instance, the default MongoDB database to use 11 | 12 | 13 | .. _`pymongo`: http://api.mongodb.org/python/current/ 14 | """ 15 | import warnings 16 | 17 | 18 | from pymongo import MongoClient 19 | from pymongo.errors import ConnectionFailure 20 | 21 | from helga import settings 22 | 23 | 24 | def connect(): 25 | """ 26 | Connect to a MongoDB instance, if helga is configured to do so (see setting 27 | :data:`~helga.settings.DATABASE`). This will return the MongoDB client as well 28 | as the default database as configured. 29 | 30 | :returns: A two-tuple of (`pymongo.MongoClient`, `pymongo.database.Database`) 31 | """ 32 | db_settings = getattr(settings, 'DATABASE', {}) 33 | 34 | try: 35 | client = MongoClient(db_settings['HOST'], db_settings['PORT']) 36 | except ConnectionFailure: 37 | warnings.warn('MongoDB is not available. Some features may not work') 38 | return None, None 39 | else: 40 | db = client[db_settings['DB']] 41 | 42 | if 'USERNAME' in db_settings and 'PASSWORD' in db_settings: 43 | db.authenticate(db_settings['USERNAME'], db_settings['PASSWORD']) 44 | 45 | return client, db 46 | 47 | 48 | client, db = connect() 49 | -------------------------------------------------------------------------------- /helga/tests/test_db.py: -------------------------------------------------------------------------------- 1 | from mock import patch, Mock 2 | 3 | from helga import db 4 | from pymongo.errors import ConnectionFailure 5 | 6 | 7 | @patch('helga.db.MongoClient') 8 | @patch('helga.db.settings') 9 | def test_connect_returns_none_on_failure(settings, mongo): 10 | settings.DATABASE = { 11 | 'HOST': 'localhost', 12 | 'PORT': '1234', 13 | } 14 | 15 | mongo.side_effect = ConnectionFailure 16 | assert db.connect() == (None, None) 17 | 18 | 19 | @patch('helga.db.MongoClient') 20 | @patch('helga.db.settings') 21 | def test_connect_authenticates(settings, mongo): 22 | settings.DATABASE = { 23 | 'HOST': 'localhost', 24 | 'PORT': '1234', 25 | 'USERNAME': 'foo', 26 | 'PASSWORD': 'bar', 27 | 'DB': 'baz', 28 | } 29 | 30 | mongo.return_value = mongo 31 | 32 | database = Mock() 33 | mongo.__getitem__ = Mock() 34 | mongo.__getitem__.return_value = database 35 | 36 | db.connect() 37 | database.authenticate.assert_called_with('foo', 'bar') 38 | 39 | 40 | @patch('helga.db.MongoClient') 41 | @patch('helga.db.settings') 42 | def test_connect(settings, mongo): 43 | settings.DATABASE = { 44 | 'HOST': 'localhost', 45 | 'PORT': '1234', 46 | 'DB': 'baz', 47 | } 48 | 49 | mongo.return_value = mongo 50 | 51 | database = Mock() 52 | mongo.__getitem__ = Mock() 53 | mongo.__getitem__.return_value = database 54 | 55 | assert db.connect() == (mongo, database) 56 | mongo.__getitem__.assert_called_with('baz') 57 | -------------------------------------------------------------------------------- /helga/comm/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base implementations for comm clients 3 | """ 4 | 5 | from collections import defaultdict 6 | 7 | from helga import settings 8 | 9 | 10 | class BaseClient(object): 11 | """ 12 | A base client implementation for any arbitrary protocol. Manages keeping track of global 13 | settings needed for core functionality as well as other general client state. 14 | 15 | .. attribute:: channels 16 | :annotation: = set() 17 | 18 | A set containing all of the channels the bot is currently in 19 | 20 | .. attribute:: operators 21 | :annotation: = set() 22 | 23 | A set containing all of the configured operators (setting :data:`~helga.settings.OPERATORS`) 24 | 25 | .. attribute:: last_message 26 | :annotation: = dict() 27 | 28 | A channel keyed dictionary containing dictionaries of nick -> message of the last messages 29 | the bot has seen a user send on a given channel. For instance, if in the channel ``#foo``:: 30 | 31 | test 32 | 33 | The contents of this dictionary would be:: 34 | 35 | self.last_message['#foo']['sduncan'] = 'test' 36 | 37 | .. attribute:: channel_loggers 38 | :annotation: = dict() 39 | 40 | A dictionary of known channel loggers, keyed off the channel name 41 | """ 42 | 43 | def __init__(self): 44 | # Pre-configured helga admins 45 | self.operators = set(getattr(settings, 'OPERATORS', [])) 46 | 47 | # Things to keep track of 48 | self.channels = set() 49 | self.last_message = defaultdict(dict) # Dict of x[channel][nick] 50 | self.channel_loggers = {} 51 | 52 | # TODO: fill in the base methods so we can do appropriate tracking 53 | -------------------------------------------------------------------------------- /helga/tests/webhooks/test_announcements.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | import types 3 | 4 | from mock import Mock 5 | from unittest import TestCase 6 | 7 | from helga import settings 8 | from helga.webhooks.announcements import announce 9 | 10 | 11 | class AnnouncementTestCase(TestCase): 12 | 13 | def setUp(self): 14 | self.client = Mock() 15 | self.request = Mock(args={}) 16 | 17 | def _set_response_code(self, code): 18 | self.response_code = code 19 | 20 | self.request.setResponseCode = types.MethodType(_set_response_code, self.request) 21 | 22 | # Ensure requests are always authenticated 23 | self.request.getUser.return_value = 'user' 24 | self.request.getPassword.return_value = 'password' 25 | settings.WEBHOOKS_CREDENTIALS = [('user', 'password')] 26 | 27 | def test_requires_message_content(self): 28 | assert 'Param message is required' == announce(self.request, self.client, '#foo') 29 | assert self.request.response_code == 400 30 | 31 | def test_formatted_channel(self): 32 | self.request.args['message'] = ['bar'] 33 | assert 'Message Sent' == announce(self.request, self.client, 'foo') 34 | self.client.msg.assert_called_with('#foo', 'bar') 35 | 36 | def test_handles_unicode(self): 37 | snowman = u'☃' 38 | self.request.args['message'] = [snowman] 39 | assert 'Message Sent' == announce(self.request, self.client, '#foo') 40 | self.client.msg.assert_called_with('#foo', snowman) 41 | 42 | def test_announce(self): 43 | self.request.args['message'] = ['bar'] 44 | assert 'Message Sent' == announce(self.request, self.client, '#foo') 45 | self.client.msg.assert_called_with('#foo', 'bar') 46 | -------------------------------------------------------------------------------- /helga/tests/comm/test_slack.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | import pytest 4 | 5 | from mock import patch 6 | 7 | from helga.comm import slack 8 | 9 | 10 | @pytest.fixture(scope='module', autouse=True) 11 | def patch_api(): 12 | with patch.object(slack, 'api', return_value=None): 13 | yield 14 | 15 | 16 | @pytest.fixture 17 | def client(): 18 | c = None 19 | 20 | with patch.object(slack, 'task'): 21 | c = slack.Client({ 22 | 'self': { 23 | 'name': 'helga', 24 | }, 25 | }) 26 | 27 | yield c 28 | 29 | 30 | class TestClient(object): 31 | 32 | def test_parse_message_simple(self, client): 33 | message = '<@U1234ABC> Hi' 34 | 35 | with patch.object(client, '_get_user_name', return_value='adeza') as mock_get_user: 36 | result = client._parse_incoming_message(message) 37 | assert '@adeza Hi' == result 38 | mock_get_user.assert_called_with('U1234ABC') 39 | 40 | def test_parse_message_complex(self, client): 41 | message = '<@U1234ABC|alfredo> Hi' 42 | 43 | with patch.object(client, '_get_user_name', return_value='alfredo') as mock_get_user: 44 | result = client._parse_incoming_message(message) 45 | assert '@alfredo Hi' == result 46 | mock_get_user.assert_called_with('U1234ABC') 47 | 48 | def test_parse_message_unescape(self, client): 49 | message = '<@U1234ABC> test <reply> & more' 50 | 51 | with patch.object(client, '_get_user_name', return_value='alfredo') as mock_get_user: 52 | result = client._parse_incoming_message(message) 53 | assert '@alfredo test & more' == result 54 | mock_get_user.assert_called_with('U1234ABC') 55 | 56 | -------------------------------------------------------------------------------- /helga/plugins/help.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from helga.plugins import command, registry 4 | 5 | 6 | def format_help_string(name, *helps): 7 | return u'[{0}] {1}'.format(name, '. '.join(helps)) 8 | 9 | 10 | @command('help', aliases=['halp'], 11 | help="Show the help string for any commands. Usage: helga help []") 12 | def help(client, channel, nick, message, cmd, args): 13 | helps = defaultdict(list) 14 | default_help = u'No help string for this plugin' 15 | 16 | for plugin_name in registry.enabled_plugins[channel]: 17 | try: 18 | plugin = registry.plugins[plugin_name] 19 | except KeyError: 20 | continue 21 | 22 | # A simple object 23 | if hasattr(plugin, 'help'): 24 | helps[plugin_name].append(plugin.help or default_help) 25 | 26 | # A decorated function 27 | elif hasattr(plugin, '_plugins'): 28 | fn_helps = filter(bool, map(lambda x: getattr(x, 'help', None), plugin._plugins)) 29 | helps[plugin_name].extend(fn_helps or [default_help]) 30 | 31 | try: 32 | plugin = args[0] 33 | except IndexError: 34 | pass 35 | else: 36 | if plugin not in registry.enabled_plugins[channel]: 37 | return u"Sorry {0}, I don't know about that plugin".format(nick) 38 | elif plugin not in helps.keys(): 39 | return u"Sorry {0}, there's no help string for plugin '{1}'".format(nick, plugin) 40 | 41 | # Single plugin, it's probably ok in the public channel 42 | return format_help_string(plugin, *helps[plugin]) 43 | 44 | if channel != nick: 45 | client.me(channel, 'whispers to {0}'.format(nick)) 46 | 47 | retval = [] 48 | # Send the message to the user 49 | for key, value in helps.iteritems(): 50 | retval.append(format_help_string(key, *value)) 51 | 52 | retval.insert(0, u"{0}, here are the plugins I know about".format(nick)) 53 | client.msg(nick, u'\n'.join(retval)) 54 | -------------------------------------------------------------------------------- /helga/bin/helga.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import argparse 4 | import os 5 | 6 | import smokesignal 7 | 8 | from twisted.internet import reactor, ssl 9 | from autobahn.twisted.websocket import connectWS 10 | 11 | from helga import settings 12 | 13 | 14 | def _get_backend(name): # pragma: no cover 15 | name = name.lower() 16 | module = __import__('helga.comm', globals(), locals(), [name], 0) 17 | return getattr(module, name) 18 | 19 | 20 | def run(): 21 | """ 22 | Run the helga process 23 | """ 24 | backend = _get_backend(settings.SERVER.get('TYPE', 'irc')) 25 | smokesignal.emit('started') 26 | 27 | factory = backend.Factory() 28 | 29 | if settings.SERVER.get('TYPE', False) == 'slack': 30 | connectWS(factory=factory) 31 | elif settings.SERVER.get('SSL', False): 32 | reactor.connectSSL(settings.SERVER['HOST'], 33 | settings.SERVER['PORT'], 34 | factory, 35 | ssl.ClientContextFactory()) 36 | else: 37 | reactor.connectTCP(settings.SERVER['HOST'], 38 | settings.SERVER['PORT'], 39 | factory) 40 | reactor.run() 41 | 42 | 43 | def main(): 44 | """ 45 | Main entry point for the helga console script 46 | """ 47 | parser = argparse.ArgumentParser(description='The helga IRC bot') 48 | parser.add_argument('--settings', help=( 49 | 'Custom helga settings overrides. This should be an importable python module ' 50 | 'like "foo.bar.baz" or a path to a settings file like "path/to/settings.py". ' 51 | 'This can also be set via the HELGA_SETTINGS environment variable, however ' 52 | 'this flag takes precedence.' 53 | )) 54 | args = parser.parse_args() 55 | 56 | settings_file = os.environ.get('HELGA_SETTINGS', '') 57 | 58 | if args.settings: 59 | settings_file = args.settings 60 | 61 | settings.configure(settings_file) 62 | run() 63 | -------------------------------------------------------------------------------- /helga/tests/util/test_encodings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | from helga.util.encodings import (from_unicode, 3 | from_unicode_args, 4 | to_unicode, 5 | to_unicode_args) 6 | 7 | 8 | def test_to_unicode_with_unicode_string(): 9 | snowman = u'☃' 10 | retval = to_unicode(snowman) 11 | assert snowman == retval 12 | assert isinstance(retval, unicode) 13 | 14 | 15 | def test_to_unicode_with_byte_string(): 16 | snowman = u'☃' 17 | bytes = '\xe2\x98\x83' 18 | retval = to_unicode(bytes) 19 | assert snowman == retval 20 | assert isinstance(retval, unicode) 21 | 22 | 23 | def test_from_unicode_with_unicode_string(): 24 | snowman = u'☃' 25 | bytes = '\xe2\x98\x83' 26 | retval = from_unicode(snowman) 27 | assert bytes == retval 28 | assert isinstance(retval, str) 29 | 30 | 31 | def test_from_unicode_with_byte_string(): 32 | bytes = '\xe2\x98\x83' 33 | retval = from_unicode(bytes) 34 | assert bytes == retval 35 | assert isinstance(retval, str) 36 | 37 | 38 | def test_to_unicode_args(): 39 | @to_unicode_args 40 | def foo(arg1, arg2, arg3): 41 | return arg1, arg2, arg3 42 | 43 | snowman = u'☃' 44 | bytes = '\xe2\x98\x83' 45 | # None here to ensure bad things don't happen 46 | retval = foo(bytes, 'foo', None) 47 | 48 | assert retval[0] == snowman 49 | assert retval[1] == u'foo' 50 | assert retval[2] is None 51 | assert isinstance(retval[0], unicode) 52 | assert isinstance(retval[1], unicode) 53 | 54 | 55 | def test_from_unicode_args(): 56 | @from_unicode_args 57 | def foo(arg1, arg2, arg3): 58 | return arg1, arg2, arg3 59 | 60 | snowman = u'☃' 61 | bytes = '\xe2\x98\x83' 62 | # None here to ensure bad things don't happen 63 | retval = foo(snowman, 'foo', None) 64 | 65 | assert retval[0] == bytes 66 | assert retval[1] == 'foo' 67 | assert retval[2] is None 68 | assert isinstance(retval[0], str) 69 | assert isinstance(retval[1], str) 70 | -------------------------------------------------------------------------------- /helga/util/encodings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for working with unicode and/or byte strings 3 | """ 4 | from decorator import decorator 5 | 6 | 7 | def to_unicode(bytestr, errors='ignore'): 8 | """ 9 | Safely convert a byte string to unicode by first checking if it already is unicode before decoding. 10 | This function assumes UTF-8 for byte strings and by default will ignore any decoding errors. 11 | 12 | :param bytestr: either a byte string or unicode string 13 | :param errors: a string indicating how decoding errors should be handled 14 | (i.e. 'strict', 'ignore', 'replace') 15 | """ 16 | if isinstance(bytestr, unicode): 17 | return bytestr 18 | return bytestr.decode('utf-8', errors) 19 | 20 | 21 | def from_unicode(unistr, errors='ignore'): 22 | """ 23 | Safely convert unicode to a byte string by first checking if it already is a byte string before 24 | encoding. This function assumes UTF-8 for byte strings and by default will ignore any encoding errors. 25 | 26 | :param unistr: either unicode or a byte string 27 | :param errors: a string indicating how encoding errors should be handled 28 | (i.e. 'strict', 'ignore', 'replace') 29 | """ 30 | if not isinstance(unistr, unicode): 31 | return unistr 32 | return unistr.encode('utf-8', errors) 33 | 34 | 35 | @decorator 36 | def to_unicode_args(fn, *args, **kwargs): 37 | """ 38 | Decorator used to safely convert a function's positional arguments from byte strings to unicode 39 | """ 40 | args = list(args) 41 | for i, val in enumerate(args): 42 | if isinstance(val, str): 43 | args[i] = to_unicode(val) 44 | return fn(*args, **kwargs) 45 | 46 | 47 | @decorator 48 | def from_unicode_args(fn, *args, **kwargs): 49 | """ 50 | Decorator used to safely convert a function's positional arguments from unicode to byte strings 51 | """ 52 | args = list(args) 53 | for i, val in enumerate(args): 54 | if isinstance(val, unicode): 55 | args[i] = from_unicode(val) 56 | return fn(*args, **kwargs) 57 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! 5 | VAGRANTFILE_API_VERSION = '2'.freeze 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | config.vm.box = 'ubuntu/xenial64' 9 | 10 | # Forward keys from SSH agent rather than copypasta 11 | config.ssh.forward_agent = true 12 | 13 | # FIXME: Might not even need this much 14 | config.vm.provider 'virtualbox' do |v| 15 | v.customize ['modifyvm', :id, '--memory', '1024'] 16 | end 17 | 18 | # Forward ports for irc(6667) and mongo(27017) 19 | config.vm.network :forwarded_port, guest: 6667, host: 6667 20 | config.vm.network :forwarded_port, guest: 27017, host: 27017 21 | config.vm.network :private_network, ip: '192.168.10.101' 22 | 23 | config.vm.provision 'shell', inline: < /home/vagrant/.profile 59 | sudo -u vagrant echo "source /home/vagrant/helga_venv/bin/activate" >> /home/vagrant/.profile 60 | sudo -u vagrant echo "cd /vagrant/" >> /home/vagrant/.profile 61 | EOF 62 | end 63 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | helga 2 | ===== 3 | 4 | .. image:: https://img.shields.io/travis/shaunduncan/helga/master.svg 5 | :target: https://travis-ci.org/shaunduncan/helga 6 | 7 | .. image:: https://img.shields.io/coveralls/shaunduncan/helga/master.svg 8 | :target: https://coveralls.io/r/shaunduncan/helga?branch=master 9 | 10 | .. image:: https://img.shields.io/pypi/v/helga.svg 11 | :target: https://pypi.python.org/pypi/helga 12 | 13 | 14 | About 15 | ----- 16 | Helga is a full-featured chat bot for Python 2.7 using `Twisted`_. Helga originally started 17 | as a python fork of a perl-based IRC bot `olga`_, but has grown considerably since then. Early 18 | versions limited to support to IRC, but now include other services like XMPP and HipChat. 19 | Full documentation can be found at http://helga.readthedocs.org. 20 | 21 | 22 | Supported Backends 23 | ------------------ 24 | 25 | As of version 1.7.0, helga supports IRC, XMPP, and HipChat out of the box. Note, however, that 26 | helga originally started as an IRC bot, so much of the terminology will reflect that. The current 27 | status of XMPP and HipChat support is very limited and somewhat beta. In the future, helga may 28 | have a much more robust and pluggable backend system to allow connections to any number of chat 29 | services. 30 | 31 | 32 | Contributing 33 | ------------ 34 | Contributions are **always** welcomed, whether they be in the form of bug fixes, enhancements, 35 | or just bug reports. To report any issues, please create a ticket on `github`_. For code 36 | changes, please note that any pull request will be denied a merge if the test suite fails. 37 | 38 | If you are looking to get help with helga, join the #helgabot IRC channel on freenode. 39 | 40 | 41 | License 42 | ------- 43 | Copyright (c) 2014 Shaun Duncan 44 | 45 | Helga is open source software, dual licensed under the `MIT`_ and `GPL`_ licenses. Dual licensing 46 | was chosen for this project so that plugin authors can create plugins under their choice 47 | of license that is compatible with this project. 48 | 49 | .. _`GPL`: https://github.com/shaunduncan/helga/blob/master/LICENSE-GPL 50 | .. _`MIT`: https://github.com/shaunduncan/helga/blob/master/LICENSE-MIT 51 | .. _`Twisted`: https://twistedmatrix.com/trac/ 52 | .. _`olga`: https://github.com/thepeopleseason/olga 53 | .. _`github`: https://github.com/shaunduncan/helga/issues 54 | -------------------------------------------------------------------------------- /helga/tests/bin/test_helga.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from mock import Mock, patch 4 | 5 | from helga.bin import helga 6 | 7 | 8 | class TestRun(object): 9 | 10 | def test_tcp(self): 11 | server = { 12 | 'HOST': 'localhost', 13 | 'PORT': 6667, 14 | } 15 | 16 | with patch.multiple(helga, smokesignal=Mock(), _get_backend=Mock(), reactor=Mock()): 17 | with patch.object(helga.settings, 'SERVER', server): 18 | factory = Mock() 19 | helga._get_backend.return_value = helga._get_backend 20 | helga._get_backend.Factory.return_value = factory 21 | 22 | helga.run() 23 | 24 | helga.smokesignal.emit.assert_called_with('started') 25 | helga.reactor.connectTCP.assert_called_with('localhost', 6667, factory) 26 | assert helga.reactor.run.called 27 | 28 | def test_ssl(self): 29 | server = { 30 | 'HOST': 'localhost', 31 | 'PORT': 6667, 32 | 'SSL': True 33 | } 34 | 35 | with patch.multiple(helga, smokesignal=Mock(), _get_backend=Mock(), reactor=Mock(), ssl=Mock()): 36 | with patch.object(helga.settings, 'SERVER', server): 37 | ssl = Mock() 38 | helga.ssl.ClientContextFactory.return_value = ssl 39 | 40 | factory = Mock() 41 | helga._get_backend.return_value = helga._get_backend 42 | helga._get_backend.Factory.return_value = factory 43 | 44 | helga.run() 45 | 46 | helga.smokesignal.emit.assert_called_with('started') 47 | helga.reactor.connectSSL.assert_called_with('localhost', 6667, factory, ssl) 48 | assert helga.reactor.run.called 49 | 50 | 51 | class TestMain(object): 52 | 53 | def test_uses_settings_env_var(monkeypatch): 54 | sys.argv = ['helga'] 55 | 56 | with patch.multiple(helga, run=Mock(), settings=Mock()): 57 | with patch.dict('os.environ', {'HELGA_SETTINGS': 'foo'}): 58 | helga.main() 59 | helga.settings.configure.assert_called_with('foo') 60 | assert helga.run.called 61 | 62 | def test_settings_arg_overrides_env_var(self): 63 | sys.argv = ['helga', '--settings', 'bar'] 64 | 65 | with patch.multiple(helga, run=Mock(), settings=Mock()): 66 | with patch.dict('os.environ', {'HELGA_SETTINGS': 'foo'}): 67 | helga.main() 68 | helga.settings.configure.assert_called_with('bar') 69 | assert helga.run.called 70 | -------------------------------------------------------------------------------- /helga/plugins/operator.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import smokesignal 4 | 5 | from helga import log 6 | from helga.db import db 7 | from helga.plugins import command, registry, random_ack 8 | 9 | 10 | logger = log.getLogger(__name__) 11 | 12 | nopes = [ 13 | u"You're not the boss of me", 14 | u"Whatever I do what want", 15 | u"You can't tell me what to do", 16 | u"{nick}, this incident has been reported", 17 | u"NO. You are now on notice {nick}" 18 | ] 19 | 20 | 21 | @smokesignal.on('signon') 22 | def join_autojoined_channels(client): 23 | if db is None: # pragma: no cover 24 | logger.warning('Cannot autojoin channels. No database connection') 25 | return 26 | 27 | for channel in db.autojoin.find(): 28 | try: 29 | client.join(channel['channel']) 30 | except Exception: # pragma: no cover 31 | logger.exception('Could not autojoin %s', channel['channel']) 32 | 33 | 34 | def add_autojoin(channel): 35 | logger.info('Adding autojoin channel %s', channel) 36 | db_opts = {'channel': channel} 37 | 38 | if db.autojoin.find(db_opts).count() == 0: 39 | db.autojoin.insert(db_opts) 40 | return random_ack() 41 | else: 42 | return "I'm already doing that" 43 | 44 | 45 | def remove_autojoin(channel): 46 | logger.info('Removing autojoin %s', channel) 47 | db.autojoin.remove({'channel': channel}) 48 | return random_ack() 49 | 50 | 51 | def reload_plugin(plugin): 52 | """ 53 | Hooks into the registry and reloads a plugin without restarting 54 | """ 55 | if registry.reload(plugin): 56 | return u"Successfully reloaded plugin '{0}'".format(plugin) 57 | else: 58 | return u"Failed to reload plugin '{0}'".format(plugin) 59 | 60 | 61 | @command('operator', aliases=['oper', 'op'], 62 | help="Admin like control over helga. Must be an operator to use. " 63 | "Usage: helga (operator|oper|op) (reload |" 64 | "(join|leave|autojoin (add|remove)) )") 65 | def operator(client, channel, nick, message, cmd, args): 66 | """ 67 | Admin like control over helga. Can join/leave or add/remove autojoin channels. User asking 68 | for this command must have his or her nick listed in OPERATORS list in helga settings. 69 | """ 70 | if nick not in client.operators: 71 | return random.choice(nopes).format(nick=nick) 72 | 73 | subcmd = args[0] 74 | 75 | if subcmd in ('join', 'leave'): 76 | channel = args[1] 77 | if channel.startswith('#'): 78 | return getattr(client, subcmd)(channel) 79 | 80 | elif subcmd == 'autojoin': 81 | op, channel = args[1], args[2] 82 | if op == 'add': 83 | return add_autojoin(channel) 84 | elif op == 'remove': 85 | return remove_autojoin(channel) 86 | 87 | elif subcmd == 'nsa': 88 | # Never document this 89 | return client.msg(args[1], ' '.join(args[2:])) 90 | 91 | # Reload a plugin without restarting 92 | elif subcmd == 'reload': 93 | return reload_plugin(args[1]) 94 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | from setuptools import setup, find_packages 6 | from setuptools.command.test import test as TestCommand 7 | 8 | import helga 9 | 10 | 11 | def parse_requirements(filename): 12 | with open(filename, 'r') as f: 13 | for line in f: 14 | if line.strip().startswith('#'): 15 | continue 16 | yield line 17 | 18 | 19 | if sys.version_info[1:2] < (3, 3): 20 | extra_requires = ['backports.functools_lru_cache'] 21 | 22 | 23 | class PyTest(TestCommand): 24 | def finalize_options(self): 25 | TestCommand.finalize_options(self) 26 | self.test_args = [] 27 | self.test_suite = True 28 | 29 | def run_tests(self): 30 | return subprocess.call('tox') 31 | 32 | 33 | # Get the long description 34 | with open(os.path.join(os.path.dirname(__file__), 'README.rst'), 'r') as f: 35 | long_description = f.read() 36 | 37 | 38 | setup(name=helga.__title__, 39 | version=helga.__version__, 40 | description=helga.__description__, 41 | long_description=long_description, 42 | classifiers=[ 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Topic :: Communications :: Chat :: Internet Relay Chat', 45 | 'Framework :: Twisted', 46 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 47 | 'License :: OSI Approved :: MIT License', 48 | 'Operating System :: OS Independent', 49 | 'Programming Language :: Python', 50 | 'Programming Language :: Python :: 2', 51 | 'Programming Language :: Python :: 2.6', 52 | 'Programming Language :: Python :: 2.7', 53 | 'Topic :: Software Development :: Libraries :: Python Modules', 54 | ], 55 | keywords='helga bot irc xmpp jabber hipchat chat', 56 | author=helga.__author__, 57 | author_email='shaun.duncan@gmail.com', 58 | url='https://github.com/shaunduncan/helga', 59 | license=helga.__license__, 60 | packages=find_packages(), 61 | package_data={ 62 | 'helga': ['webhooks/logger/*.mustache'], 63 | }, 64 | install_requires=list(parse_requirements('requirements.txt')), 65 | tests_require=[ 66 | 'freezegun', 67 | 'mock', 68 | 'pretend', 69 | 'tox', 70 | 'pytest', 71 | ], 72 | cmdclass={'test': PyTest}, 73 | entry_points=dict( 74 | helga_plugins=[ 75 | 'help = helga.plugins.help:help', 76 | 'manager = helga.plugins.manager:manager', 77 | 'operator = helga.plugins.operator:operator', 78 | 'ping = helga.plugins.ping:ping', 79 | 'version = helga.plugins.version:version', 80 | 'webhooks = helga.plugins.webhooks:WebhookPlugin', 81 | ], 82 | helga_webhooks=[ 83 | 'announcements = helga.webhooks.announcements:announce', 84 | 'logger = helga.webhooks.logger:logger' 85 | ], 86 | console_scripts=[ 87 | 'helga = helga.bin.helga:main', 88 | ], 89 | ), 90 | ) 91 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. helga documentation master file, created by 2 | sphinx-quickstart on Mon Dec 22 16:42:46 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | helga 7 | ===== 8 | .. image:: https://img.shields.io/travis/shaunduncan/helga/master.svg 9 | :target: https://travis-ci.org/shaunduncan/helga 10 | 11 | .. image:: https://img.shields.io/coveralls/shaunduncan/helga/master.svg 12 | :target: https://coveralls.io/r/shaunduncan/helga?branch=master 13 | 14 | .. image:: https://img.shields.io/pypi/v/helga.svg 15 | :target: https://pypi.python.org/pypi/helga 16 | 17 | .. image:: https://img.shields.io/pypi/dm/helga.svg 18 | :target: https://pypi.python.org/pypi/helga 19 | 20 | 21 | .. _about: 22 | 23 | About 24 | ----- 25 | Helga is a full-featured chat bot for Python 2.6/2.7 using `Twisted`_. Helga originally started 26 | as a python fork of a perl-based IRC bot `olga`_, but has grown considerably since then. Early 27 | versions limited to support to IRC, but now include other services like XMPP and HipChat. 28 | 29 | 30 | .. _supported_backends: 31 | 32 | Supported Backends 33 | ------------------ 34 | 35 | As of version 1.7.0, helga supports IRC, XMPP, and HipChat out of the box. Note, however, that 36 | helga originally started as an IRC bot, so much of the terminology will reflect that. The current 37 | status of XMPP and HipChat support is very limited and somewhat beta. In the future, helga may 38 | have a much more robust and pluggable backend system to allow connections to any number of chat 39 | services. 40 | 41 | 42 | .. _features: 43 | 44 | Features 45 | -------- 46 | * A simple and robust plugin api 47 | * HTTP webhooks support and webhook plugins 48 | * Channel logging and browsable web UI 49 | * Event driven behavior for plugins 50 | * Support for IRC, XMPP, and HipChat 51 | 52 | 53 | .. _contributing: 54 | 55 | Contributing 56 | ------------ 57 | Contributions are **always** welcomed, whether they be in the form of bug fixes, enhancements, 58 | or just bug reports. To report any issues, please create a ticket on `github`_. For code 59 | changes, please note that any pull request will be denied a merge if the test suite fails. 60 | 61 | If you are looking to get help with helga, join the #helgabot IRC channel on freenode. 62 | 63 | 64 | .. _license: 65 | 66 | License 67 | ------- 68 | Copyright (c) 2014 Shaun Duncan 69 | 70 | Helga is open source software, dual licensed under the MIT and GPL licenses. Dual licensing 71 | was chosen for this project so that plugin authors can create plugins under their choice 72 | of license that is compatible with this project. 73 | 74 | 75 | Contents 76 | -------- 77 | .. toctree:: 78 | :maxdepth: 2 79 | 80 | getting_started 81 | configuring_helga 82 | plugins 83 | webhooks 84 | builtins 85 | api 86 | 87 | 88 | Indices and Tables 89 | ------------------ 90 | * :ref:`genindex` 91 | * :ref:`modindex` 92 | * :ref:`search` 93 | 94 | 95 | .. _`Twisted`: https://twistedmatrix.com/trac/ 96 | .. _`olga`: https://github.com/thepeopleseason/olga 97 | .. _`github`: https://github.com/shaunduncan/helga/issues 98 | -------------------------------------------------------------------------------- /helga/plugins/manager.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from itertools import ifilter 4 | 5 | import smokesignal 6 | 7 | from helga import log 8 | from helga.db import db 9 | from helga.plugins import command, ACKS, registry 10 | 11 | 12 | logger = log.getLogger(__name__) 13 | 14 | 15 | @smokesignal.on('signon') 16 | def auto_enable_plugins(*args): 17 | if db is None: # pragma: no cover 18 | logger.warning('Cannot auto enable plugins. No database connection') 19 | return 20 | 21 | pred = lambda rec: rec['plugin'] in registry.all_plugins 22 | 23 | for rec in ifilter(pred, db.auto_enabled_plugins.find()): 24 | for channel in rec['channels']: 25 | logger.info('Auto-enabling plugin %s on channel %s', rec['plugin'], channel) 26 | registry.enable(channel, rec['plugin']) 27 | 28 | 29 | def list_plugins(client, channel): 30 | enabled = set(registry.enabled_plugins[channel]) 31 | available = registry.all_plugins - enabled 32 | 33 | return [ 34 | u'Plugins enabled on this channel: {0}'.format(', '.join(sorted(enabled))), 35 | u'Available plugins: {0}'.format(', '.join(sorted(available))), 36 | ] 37 | 38 | 39 | def _filter_valid(channel, *plugins): 40 | return filter(lambda p: p in registry.all_plugins, plugins) 41 | 42 | 43 | def enable_plugins(client, channel, *plugins): 44 | valid_plugins = _filter_valid(channel, *plugins) 45 | if not valid_plugins: 46 | return u"Sorry, but I don't know about these plugins: {0}".format(', '.join(plugins)) 47 | 48 | registry.enable(channel, *valid_plugins) 49 | 50 | for p in valid_plugins: 51 | rec = db.auto_enabled_plugins.find_one({'plugin': p}) 52 | if rec is None: 53 | db.auto_enabled_plugins.insert({'plugin': p, 'channels': [channel]}) 54 | elif channel not in rec['channels']: 55 | rec['channels'].append(channel) 56 | db.auto_enabled_plugins.save(rec) 57 | 58 | return random.choice(ACKS) 59 | 60 | 61 | def disable_plugins(client, channel, *plugins): 62 | valid_plugins = _filter_valid(channel, *plugins) 63 | if not valid_plugins: 64 | return u"Sorry, but I don't know about these plugins: {0}".format(', '.join(plugins)) 65 | 66 | registry.disable(channel, *valid_plugins) 67 | 68 | for p in valid_plugins: 69 | rec = db.auto_enabled_plugins.find_one({'plugin': p}) 70 | if rec is None or channel not in rec['channels']: 71 | continue 72 | 73 | rec['channels'].remove(channel) 74 | db.auto_enabled_plugins.save(rec) 75 | 76 | return random.choice(ACKS) 77 | 78 | 79 | @command('plugins', help="Plugin management. Usage: helga plugins (list|(enable|disable) ( ...))") 80 | def manager(client, channel, nick, message, cmd, args): 81 | """ 82 | Manages listing plugins, or enabling and disabling them 83 | """ 84 | if len(args) < 1: 85 | subcmd = 'list' 86 | else: 87 | subcmd = args[0] 88 | 89 | if subcmd == 'list': 90 | return list_plugins(client, channel) 91 | 92 | if subcmd == 'enable': 93 | return enable_plugins(client, channel, *args[1:]) 94 | 95 | if subcmd == 'disable': 96 | return disable_plugins(client, channel, *args[1:]) 97 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # helga documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Dec 22 16:42:46 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | sys.path.insert(0, os.path.abspath('.')) 19 | 20 | import helga 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | extensions = [ 25 | 'sphinx.ext.autodoc', 26 | 'sphinx.ext.autosummary', 27 | ] 28 | templates_path = ['_templates'] 29 | source_suffix = '.rst' 30 | master_doc = 'index' 31 | 32 | project = helga.__title__ 33 | author = helga.__author__ 34 | description = helga.__description__ 35 | copyright = helga.__copyright__ 36 | 37 | version = helga.__version__ 38 | release = helga.__version__ 39 | 40 | exclude_patterns = [] 41 | show_authors = True 42 | pygments_style = 'sphinx' 43 | 44 | 45 | # -- Options for HTML output ---------------------------------------------- 46 | 47 | if os.environ.get('READTHEDOCS', None) != 'True': 48 | import sphinx_rtd_theme 49 | html_theme = 'sphinx_rtd_theme' 50 | html_theme_path = [ 51 | sphinx_rtd_theme.get_html_theme_path(), 52 | ] 53 | html_theme_options = { 54 | 'analytics_id': 'UA-57964703-1', 55 | } 56 | html_static_path = ['_static'] 57 | htmlhelp_basename = 'helga{0}'.format(release.replace('.', '_')) 58 | 59 | 60 | # -- Options for LaTeX output --------------------------------------------- 61 | 62 | latex_elements = { 63 | # The paper size ('letterpaper' or 'a4paper'). 64 | #'papersize': 'letterpaper', 65 | 66 | # The font size ('10pt', '11pt' or '12pt'). 67 | #'pointsize': '10pt', 68 | 69 | # Additional stuff for the LaTeX preamble. 70 | #'preamble': '', 71 | } 72 | 73 | # Grouping the document tree into LaTeX files. List of tuples 74 | # (source start file, target name, title, 75 | # author, documentclass [howto, manual, or own class]). 76 | latex_documents = [( 77 | 'index', 78 | '{0}.tex'.format(project), 79 | '{0} Documentation'.format(project), 80 | author, 81 | 'manual', 82 | )] 83 | 84 | 85 | # -- Options for manual page output --------------------------------------- 86 | 87 | # One entry per manual page. List of tuples 88 | # (source start file, name, description, authors, manual section). 89 | man_pages = [( 90 | 'index', 91 | project, 92 | '{0} Documentation'.format(project), 93 | [author], 94 | 1, 95 | )] 96 | 97 | # If true, show URL addresses after external links. 98 | #man_show_urls = False 99 | 100 | 101 | # -- Options for Texinfo output ------------------------------------------- 102 | 103 | # Grouping the document tree into Texinfo files. List of tuples 104 | # (source start file, target name, title, author, 105 | # dir menu entry, description, category) 106 | texinfo_documents = [( 107 | 'index', 108 | 'helga', 109 | '{0} Documentation'.format(project), 110 | author, 111 | project, 112 | description, 113 | 'Miscellaneous'), 114 | ] 115 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Documentation 4 | ================= 5 | 6 | :mod:`helga.comm.irc` 7 | --------------------- 8 | .. automodule:: helga.comm.irc 9 | :synopsis: Twisted protocol and communication implementations for IRC 10 | :members: 11 | 12 | :mod:`helga.comm.xmpp` 13 | ---------------------- 14 | .. automodule:: helga.comm.xmpp 15 | :synopsis: Twisted protocol and communication implementations for XMPP/HipChat 16 | :members: 17 | 18 | 19 | :mod:`helga.db` 20 | --------------- 21 | .. automodule:: helga.db 22 | :synopsis: pymongo connection objects and utilities 23 | :members: 24 | 25 | 26 | :mod:`helga.log` 27 | ---------------- 28 | .. automodule:: helga.log 29 | :synopsis: Logging utilities for helga 30 | :members: 31 | 32 | 33 | :mod:`helga.plugins` 34 | -------------------- 35 | .. automodule:: helga.plugins 36 | :synopsis: Core plugin library 37 | :members: 38 | 39 | .. attribute:: registry 40 | 41 | A singleton instance of :class:`helga.plugins.Registry` 42 | 43 | 44 | :mod:`helga.plugins.webhooks` 45 | ----------------------------- 46 | .. automodule:: helga.plugins.webhooks 47 | :synopsis: Webhook HTTP server plugin and core webhook API 48 | :members: 49 | 50 | 51 | :mod:`helga.settings` 52 | --------------------- 53 | .. automodule:: helga.settings 54 | :synopsis: Default settings and configuration utilities 55 | :members: configure 56 | 57 | 58 | .. _helga.settings.chat: 59 | 60 | Chat Settings 61 | """"""""""""" 62 | Settings that pertain to how helga operates with and connects to a chat server 63 | 64 | .. autodata:: NICK 65 | .. autodata:: CHANNELS 66 | .. autodata:: SERVER 67 | .. autodata:: AUTO_RECONNECT 68 | .. autodata:: AUTO_RECONNECT_DELAY 69 | .. autodata:: RATE_LIMIT 70 | 71 | 72 | .. _helga.settings.core: 73 | 74 | Core Settings 75 | """"""""""""" 76 | Settings that pertain to core helga features. 77 | 78 | .. autodata:: OPERATORS 79 | .. autodata:: DATABASE 80 | 81 | 82 | .. _helga.settings.logging: 83 | 84 | Log Settings 85 | """""""""""" 86 | .. autodata:: LOG_LEVEL 87 | .. autodata:: LOG_FILE 88 | .. autodata:: LOG_FORMAT 89 | 90 | 91 | .. _helga.settings.channel_logging: 92 | 93 | Channel Log Settings 94 | """""""""""""""""""" 95 | See :ref:`builtin.channel_logging` 96 | 97 | .. autodata:: CHANNEL_LOGGING 98 | .. autodata:: CHANNEL_LOGGING_DIR 99 | .. autodata:: CHANNEL_LOGGING_HIDE_CHANNELS 100 | 101 | 102 | .. _helga.settings.plugins_and_webhooks: 103 | 104 | Plugin and Webhook Settings 105 | """"""""""""""""""""""""""" 106 | Settings that control plugin and/or webhook behaviors. See :ref:`plugins` or :ref:`webhooks` 107 | 108 | .. autodata:: ENABLED_PLUGINS 109 | .. autodata:: DISABLED_PLUGINS 110 | .. autodata:: DEFAULT_CHANNEL_PLUGINS 111 | .. autodata:: ENABLED_WEBHOOKS 112 | .. autodata:: DISABLED_WEBHOOKS 113 | .. autodata:: PLUGIN_PRIORITY_LOW 114 | .. autodata:: PLUGIN_PRIORITY_NORMAL 115 | .. autodata:: PLUGIN_PRIORITY_HIGH 116 | .. autodata:: PLUGIN_FIRST_RESPONDER_ONLY 117 | .. autodata:: COMMAND_PREFIX_BOTNICK 118 | .. autodata:: COMMAND_PREFIX_CHAR 119 | .. autodata:: COMMAND_ARGS_SHLEX 120 | .. autodata:: WEBHOOKS_PORT 121 | .. autodata:: WEBHOOKS_CREDENTIALS 122 | 123 | 124 | :mod:`helga.util.encodings` 125 | --------------------------- 126 | .. automodule:: helga.util.encodings 127 | :synopsis: Utilities for working with unicode and/or byte strings 128 | :members: 129 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_operator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | from mock import Mock, patch, call 3 | from pretend import stub 4 | 5 | from helga.plugins import operator, ACKS 6 | 7 | 8 | def test_operator_ignores_non_oper_user(): 9 | client = stub(operators=['me']) 10 | formatted_nopes = map(lambda s: s.format(nick='sduncan'), operator.nopes) 11 | assert operator.operator(client, '#bots', 'sduncan', 'do something', '', '') in formatted_nopes 12 | 13 | 14 | def test_operator_join_calls_client_join(): 15 | client = Mock(operators=['me']) 16 | operator.operator(client, '#bots', 'me', 'do something', 'op', ['join', '#foo']) 17 | client.join.assert_called_with('#foo') 18 | 19 | 20 | def test_operator_join_ignores_invalid_channel(): 21 | client = Mock(operators=['me']) 22 | operator.operator(client, '#bots', 'me', 'do something', 'op', ['join', 'foo']) 23 | assert not client.join.called 24 | 25 | 26 | def test_operator_leave_calls_client_leave(): 27 | client = Mock(operators=['me']) 28 | operator.operator(client, '#bots', 'me', 'do something', 'op', ['leave', '#foo']) 29 | client.leave.assert_called_with('#foo') 30 | 31 | 32 | def test_operator_leave_ignores_invalid_channel(): 33 | client = Mock(operators=['me']) 34 | operator.operator(client, '#bots', 'me', 'do something', 'op', ['leave', 'foo']) 35 | assert not client.leave.called 36 | 37 | 38 | @patch('helga.plugins.operator.reload_plugin') 39 | @patch('helga.plugins.operator.remove_autojoin') 40 | @patch('helga.plugins.operator.add_autojoin') 41 | def test_operator_handles_subcmd(add_autojoin, remove_autojoin, reload_plugin): 42 | add_autojoin.return_value = 'add_autojoin' 43 | remove_autojoin.return_value = 'remove_autojoin' 44 | reload_plugin.return_value = 'reload_plugin' 45 | 46 | client = Mock(operators=['me']) 47 | args = [client, '#bots', 'me', 'message', 'operator'] 48 | 49 | # Client commands 50 | for cmd in ('join', 'leave'): 51 | client.reset_mock() 52 | operator.operator(*(args + [[cmd, '#foo']])) 53 | getattr(client, cmd).assert_called_with('#foo') 54 | 55 | # Autojoin add/remove 56 | assert 'add_autojoin' == operator.operator(*(args + [['autojoin', 'add', '#foo']])) 57 | assert 'remove_autojoin' == operator.operator(*(args + [['autojoin', 'remove', '#foo']])) 58 | 59 | # The feature that shall not be named 60 | operator.operator(*(args + [['nsa', '#other_chan', 'unicode', 'snowman', u'☃']])) 61 | client.msg.assert_called_with('#other_chan', u'unicode snowman ☃') 62 | 63 | assert 'reload_plugin' == operator.operator(*(args + [['reload', 'foo']])) 64 | 65 | 66 | @patch('helga.plugins.operator.db') 67 | def test_add_autojoin_exists(db): 68 | db.autojoin.find.return_value = db 69 | db.count.return_value = 1 70 | assert operator.add_autojoin('#foo') not in ACKS 71 | 72 | 73 | @patch('helga.plugins.operator.db') 74 | def test_add_autojoin_adds(db): 75 | db.autojoin.find.return_value = db 76 | db.count.return_value = 0 77 | operator.add_autojoin('foo') 78 | db.autojoin.insert.assert_called_with({'channel': 'foo'}) 79 | 80 | 81 | @patch('helga.plugins.operator.db') 82 | def test_remove_autojoin(db): 83 | operator.remove_autojoin('foo') 84 | db.autojoin.remove.assert_called_with({'channel': 'foo'}) 85 | 86 | 87 | @patch('helga.plugins.operator.db') 88 | def test_join_autojoined_channels(db): 89 | client = Mock() 90 | db.autojoin.find.return_value = [ 91 | {'channel': '#bots'}, 92 | {'channel': u'☃'}, 93 | ] 94 | operator.join_autojoined_channels(client) 95 | assert client.join.call_args_list == [call('#bots'), call(u'☃')] 96 | 97 | 98 | @patch('helga.plugins.operator.registry') 99 | def test_reload_plugin(plugins): 100 | plugins.reload.return_value = True 101 | assert "Successfully reloaded plugin 'foo'" == operator.reload_plugin('foo') 102 | 103 | plugins.reload.return_value = False 104 | assert "Failed to reload plugin 'foo'" == operator.reload_plugin('foo') 105 | 106 | 107 | @patch('helga.plugins.operator.registry') 108 | def test_reload_plugin_handles_unicode(plugins): 109 | snowman = u'☃' 110 | plugins.reload.return_value = True 111 | assert u"Successfully reloaded plugin '{0}'".format(snowman) == operator.reload_plugin(snowman) 112 | 113 | plugins.reload.return_value = False 114 | assert u"Failed to reload plugin '{0}'".format(snowman) == operator.reload_plugin(snowman) 115 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | from mock import call, patch, Mock 3 | 4 | from helga.plugins import manager 5 | 6 | 7 | @patch('helga.plugins.manager.db') 8 | @patch('helga.plugins.manager.registry') 9 | def test_auto_enable_plugins(plugins, db): 10 | client = Mock() 11 | rec = {'plugin': 'haiku', 'channels': ['a', 'b', 'c']} 12 | db.auto_enabled_plugins.find.return_value = [rec] 13 | plugins.all_plugins = ['haiku'] 14 | 15 | manager.auto_enable_plugins(client) 16 | assert plugins.enable.call_args_list == [ 17 | call('a', 'haiku'), 18 | call('b', 'haiku'), 19 | call('c', 'haiku'), 20 | ] 21 | 22 | 23 | @patch('helga.plugins.manager.registry') 24 | def test_list_plugins(plugins): 25 | client = Mock() 26 | plugins.all_plugins = set(['plugin1', 'plugin2', 'plugin3']) 27 | plugins.enabled_plugins = {'foo': set(['plugin2'])} 28 | 29 | resp = manager.list_plugins(client, 'foo') 30 | assert 'Plugins enabled on this channel: plugin2' in resp 31 | assert 'Available plugins: plugin1, plugin3' in resp 32 | 33 | 34 | @patch('helga.plugins.manager.registry') 35 | def test_list_plugins_handles_unicode(plugins): 36 | client = Mock() 37 | snowman = u'☃' 38 | poo = u'💩' 39 | 40 | plugins.all_plugins = set([snowman, poo]) 41 | plugins.enabled_plugins = {'foo': set([poo])} 42 | 43 | resp = manager.list_plugins(client, 'foo') 44 | assert u'Plugins enabled on this channel: {0}'.format(poo) in resp 45 | assert u'Available plugins: {0}'.format(snowman) in resp 46 | 47 | 48 | @patch('helga.plugins.manager.db') 49 | @patch('helga.plugins.manager.registry') 50 | def test_enable_plugins_inits_record(plugins, db): 51 | client = Mock() 52 | 53 | plugins.all_plugins = ['foobar'] 54 | 55 | db.auto_enabled_plugins.find_one.return_value = None 56 | manager.enable_plugins(client, '#bots', 'foobar') 57 | 58 | assert db.auto_enabled_plugins.insert.called 59 | 60 | 61 | @patch('helga.plugins.manager.db') 62 | @patch('helga.plugins.manager.registry') 63 | def test_enable_plugins_updates_record(plugins, db): 64 | client = Mock() 65 | 66 | plugins.all_plugins = ['foobar'] 67 | 68 | rec = {'plugin': 'foobar', 'channels': ['#all']} 69 | db.auto_enabled_plugins.find_one.return_value = rec 70 | manager.enable_plugins(client, '#bots', 'foobar') 71 | 72 | assert db.auto_enabled_plugins.save.called 73 | assert '#bots' in rec['channels'] 74 | 75 | 76 | @patch('helga.plugins.manager._filter_valid') 77 | def test_enable_plugins_no_plugins(filter_valid): 78 | snowman = u'☃' 79 | filter_valid.return_value = [] 80 | plugins = ['foo', 'bar', snowman] # Test unicode 81 | 82 | resp = manager.enable_plugins(None, None, *plugins) 83 | expect = u"Sorry, but I don't know about these plugins: {0}, {1}, {2}".format('foo', 'bar', snowman) 84 | assert resp == expect 85 | 86 | 87 | @patch('helga.plugins.manager._filter_valid') 88 | @patch('helga.plugins.manager.db') 89 | @patch('helga.plugins.manager.registry') 90 | def test_disable_plugins(plugins, db, filter_valid): 91 | client = Mock() 92 | plugins.all_plugins = ['foobar', 'blah', 'no_record'] 93 | filter_valid.return_value = plugins.all_plugins 94 | 95 | records = [ 96 | { 97 | # This will be removed 98 | 'plugin': 'foobar', 99 | 'channels': ['#all', '#bots'] 100 | }, 101 | { 102 | # Not enabled for the channel 103 | 'plugin': 'blah', 104 | 'channels': ['#other'], 105 | }, 106 | None # No plugin found 107 | ] 108 | 109 | db.auto_enabled_plugins.find_one.side_effect = records 110 | manager.disable_plugins(client, '#bots', *plugins.all_plugins) 111 | db.auto_enabled_plugins.save.assert_called_with(records[0]) 112 | assert '#bots' not in records[0]['channels'] 113 | 114 | 115 | @patch('helga.plugins.manager._filter_valid') 116 | def test_disable_plugins_no_plugins(filter_valid): 117 | snowman = u'☃' 118 | filter_valid.return_value = [] 119 | plugins = ['foo', 'bar', snowman] # Test unicode 120 | 121 | resp = manager.disable_plugins(None, None, *plugins) 122 | expect = u"Sorry, but I don't know about these plugins: {0}, {1}, {2}".format('foo', 'bar', snowman) 123 | assert resp == expect 124 | 125 | 126 | @patch('helga.plugins.manager.disable_plugins') 127 | @patch('helga.plugins.manager.enable_plugins') 128 | @patch('helga.plugins.manager.list_plugins') 129 | def test_manager_plugin(list, enable, disable): 130 | list.return_value = 'list' 131 | enable.return_value = 'enable' 132 | disable.return_value = 'disable' 133 | 134 | assert 'list' == manager.manager('client', '#bots', 'me', 'message', 'plugins', []) 135 | assert 'list' == manager.manager('client', '#bots', 'me', 'message', 'plugins', ['list']) 136 | assert 'enable' == manager.manager('client', '#bots', 'me', 'message', 'plugins', ['enable']) 137 | assert 'disable' == manager.manager('client', '#bots', 'me', 'message', 'plugins', ['disable']) 138 | assert manager.manager('client', '#bots', 'me', 'message', 'plugins', ['lol']) is None 139 | -------------------------------------------------------------------------------- /helga/webhooks/logger/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from collections import deque 5 | from itertools import imap 6 | from operator import methodcaller 7 | 8 | import pystache 9 | 10 | from helga import settings 11 | from helga.plugins.webhooks import HttpError, route 12 | from helga.util.encodings import to_unicode 13 | 14 | 15 | class Index(object): 16 | """ 17 | Rendered object for the logger index page meant to show the full list 18 | of logged channels. 19 | """ 20 | 21 | def title(self): 22 | return u'Channel Logs' 23 | 24 | def channels(self): 25 | log_dir = settings.CHANNEL_LOGGING_DIR 26 | lstrip = methodcaller('lstrip', '#') 27 | hidden = set(imap(lstrip, settings.CHANNEL_LOGGING_HIDE_CHANNELS)) 28 | 29 | if not os.path.isdir(log_dir): 30 | raise StopIteration 31 | 32 | for chan in imap(lstrip, sorted(os.listdir(log_dir))): 33 | if chan in hidden: 34 | continue 35 | yield chan 36 | 37 | 38 | class ChannelIndex(object): 39 | """ 40 | Rendered object for the logger channel index page meant to show the full list 41 | of log files (UTC dates) for a given IRC channel. 42 | """ 43 | 44 | def __init__(self, channel): 45 | self.channel = channel 46 | 47 | def title(self): 48 | return u'#{0} Channel Logs'.format(self.channel) 49 | 50 | def dates(self): 51 | channel = '#{0}'.format(self.channel) 52 | basedir = os.path.join(settings.CHANNEL_LOGGING_DIR, channel) 53 | 54 | if not os.path.isdir(basedir): 55 | raise HttpError(404) 56 | 57 | for date in sorted(os.listdir(basedir), reverse=True): 58 | yield date.replace('.txt', '') 59 | 60 | 61 | class ChannelLog(object): 62 | """ 63 | Rendered object for displaying the full contents of a channel log for a 64 | given channel and date. 65 | """ 66 | 67 | def __init__(self, channel, date): 68 | self.channel_name = channel 69 | self.date = date 70 | self.logfile = '{0}.txt'.format(self.date) 71 | self.channel = '#{0}'.format(self.channel_name) 72 | 73 | @property 74 | def logfile_path(self): 75 | return os.path.join(settings.CHANNEL_LOGGING_DIR, self.channel, self.logfile) 76 | 77 | def title(self): 78 | """ 79 | The page title 80 | """ 81 | return u'{0} Channel Logs for {1}'.format(self.channel, self.date) 82 | 83 | def messages(self): 84 | """ 85 | Generator for logged channel messages as a dictionary 86 | of the message time, message nick, and message contents 87 | """ 88 | if not os.path.isfile(self.logfile_path): 89 | raise HttpError(404) 90 | 91 | line_pat = re.compile(r'^(\d{2}:?){3} - \w+ - .*$') 92 | message = u'' 93 | log = deque() 94 | 95 | # XXX: This is kind of terrible. Some things will log only a single time 96 | # if the message sent over IRC has newlines. So we have to read in reverse 97 | # and construct the response list 98 | with open(self.logfile_path, 'r') as fp: 99 | for line in imap(to_unicode, reversed(fp.readlines())): 100 | if not line_pat.match(line): 101 | message = u''.join((line, message)) 102 | continue 103 | 104 | parts = line.strip().split(u' - ') 105 | time = parts.pop(0) 106 | nick = parts.pop(0) 107 | message = u'\n'.join((u' - '.join(parts), message)) 108 | log.appendleft({ 109 | 'time': time, 110 | 'nick': nick, 111 | 'message': message.rstrip(u'\n'), 112 | }) 113 | message = '' 114 | 115 | if message: 116 | log.appendleft({ 117 | 'time': '', 118 | 'nick': '', 119 | 'message': message, 120 | }) 121 | 122 | return log 123 | 124 | def download(self, request): 125 | """ 126 | Offers this logfile as a download 127 | """ 128 | request.setHeader('Content-Type', 'text/plain') 129 | request.setHeader('Content-Disposition', 130 | 'attachment; filename={0}'.format(self.logfile)) 131 | with open(self.logfile_path, 'r') as fp: 132 | return '\n'.join(line.strip() for line in fp.readlines()) 133 | 134 | 135 | @route(r'/logger/?$') 136 | @route(r'/logger/(?P[\w\-_]+)/?$') 137 | @route(r'/logger/(?P[\w\-_]+)/(?P[\w\-]+)(?P\.txt)?/?$') 138 | def logger(request, irc_client, channel=None, date=None, as_text=None): 139 | if not settings.CHANNEL_LOGGING: 140 | raise HttpError(501, 'Channel logging is not enabled') 141 | 142 | request.setHeader('Content-Type', 'text/html') 143 | renderer = pystache.renderer.Renderer( 144 | search_dirs=os.path.dirname(os.path.abspath(__file__)) 145 | ) 146 | 147 | if channel is None: 148 | page = Index() 149 | elif date is None: 150 | page = ChannelIndex(channel) 151 | else: 152 | page = ChannelLog(channel, date) 153 | if as_text is not None: 154 | return page.download(request) 155 | 156 | return renderer.render(page) 157 | -------------------------------------------------------------------------------- /helga/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging utilities for helga 3 | """ 4 | import datetime 5 | import logging 6 | import logging.handlers 7 | import os 8 | import sys 9 | 10 | from helga import settings 11 | 12 | 13 | def getLogger(name): 14 | """ 15 | Obtains a named logger and ensures that it is configured according to helga's log settings 16 | (see :ref:`helga.settings.logging`). Use of this is generally intended to mimic 17 | `logging.getLogger` with the exception that it takes care of formatters and handlers. 18 | 19 | :param name: The name of the logger to get 20 | """ 21 | level = settings.LOG_LEVEL 22 | 23 | logger = logging.getLogger(name) 24 | logger.setLevel(getattr(logging, level, logging.INFO)) 25 | logger.propagate = False 26 | 27 | # Setup the default handler 28 | if settings.LOG_FILE: 29 | handler = logging.handlers.RotatingFileHandler(filename=settings.LOG_FILE, 30 | maxBytes=50*1024*1024, 31 | backupCount=6) 32 | else: 33 | handler = logging.StreamHandler() 34 | handler.stream = sys.stdout 35 | 36 | # Setup formatting 37 | default_format = '%(asctime)-15s [%(levelname)s] [%(name)s:%(lineno)d]: %(message)s' 38 | formatter = logging.Formatter(settings.LOG_FORMAT or default_format) 39 | 40 | handler.setFormatter(formatter) 41 | logger.addHandler(handler) 42 | 43 | return logger 44 | 45 | 46 | def get_channel_logger(channel): 47 | """ 48 | Obtains a python logger configured to operate as a channel logger. 49 | 50 | :param channel: the channel name for the desired logger 51 | """ 52 | logger = logging.getLogger(u'channel_logger/{0}'.format(channel)) 53 | logger.setLevel(logging.INFO) 54 | logger.propagate = False 55 | logger.addFilter(UTCTimeLogFilter()) 56 | 57 | # Channel logs are grouped into directories by channel name 58 | log_dir = os.path.join(settings.CHANNEL_LOGGING_DIR, channel) 59 | if not os.path.exists(log_dir): 60 | os.makedirs(log_dir) 61 | 62 | # Setup a daily rotating file handler 63 | handler = ChannelLogFileHandler(log_dir) 64 | handler.setFormatter(logging.Formatter(u'%(utctime)s - %(nick)s - %(message)s')) 65 | logger.addHandler(handler) 66 | 67 | return logger 68 | 69 | 70 | class UTCTimeLogFilter(logging.Filter): 71 | """ 72 | A log record filter that will add an attribute ``utcnow`` and ``utctime`` 73 | to a log record. The former is a utcnow datetime object, the latter is 74 | the formatted time of day for utcnow. 75 | """ 76 | 77 | def filter(self, record): 78 | """ 79 | Filter the log record and add two attributes: 80 | 81 | * ``utcnow``: the value of `datetime.datetime.utcnow` 82 | * ``utctime``: the time formatted string of ``utcnow`` in the form ``HH:MM:SS`` 83 | """ 84 | record.utcnow = datetime.datetime.utcnow() 85 | record.utctime = record.utcnow.strftime('%H:%M:%S') 86 | return True 87 | 88 | 89 | class ChannelLogFileHandler(logging.handlers.BaseRotatingHandler): 90 | """ 91 | A rotating file handler implementation that will create UTC dated log files 92 | suitable for channel logging. 93 | """ 94 | 95 | def __init__(self, basedir): 96 | """ 97 | :param basedir: The base directory where logs should be stored 98 | """ 99 | self.basedir = basedir 100 | filename = os.path.join(basedir, self.current_filename()) 101 | self.next_rollover = self.compute_next_rollover() 102 | try: 103 | super(logging.handlers.BaseRotatingHandler, self).__init__(filename, 'a') 104 | except TypeError: # pragma: no cover Python >= 2.7 105 | # python 2.6 uses old-style classes for logging.Handler 106 | logging.handlers.BaseRotatingHandler.__init__(self, filename, 'a') 107 | 108 | def compute_next_rollover(self): 109 | """ 110 | Based on UTC now, computes the next rollover date, which will be 00:00:00 111 | of the following day. For example, if the current datetime is 2014-10-31 08:15:00, 112 | then the next rollover will be 2014-11-01 00:00:00. 113 | """ 114 | now = datetime.datetime.utcnow() 115 | tomorrow = now + datetime.timedelta(days=1) 116 | return datetime.datetime(tomorrow.year, tomorrow.month, tomorrow.day) 117 | 118 | def current_filename(self): 119 | """ 120 | Returns a UTC dated filename suitable as a log file. Example: 2014-12-15.txt 121 | """ 122 | return datetime.datetime.utcnow().strftime('%Y-%m-%d.txt') 123 | 124 | def shouldRollover(self, record): 125 | """ 126 | Returns True if the current UTC datetime occurs on or after the 127 | next scheduled rollover datetime. False otherwise. 128 | 129 | :param record: a python log record 130 | """ 131 | return datetime.datetime.utcnow() >= self.next_rollover 132 | 133 | def doRollover(self): 134 | """ 135 | Perform log rollover. Closes any open stream, sets a new log filename, 136 | and computes the next rollover time. 137 | """ 138 | if self.stream: 139 | self.stream.close() 140 | self.stream = None 141 | 142 | self.baseFilename = os.path.abspath(os.path.join(self.basedir, self.current_filename())) 143 | self.stream = self._open() 144 | 145 | self.next_rollover = self.compute_next_rollover() 146 | -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | .. _getting_started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | 7 | .. _getting_started.requirements: 8 | 9 | Requirements 10 | ------------ 11 | All python requirements for running helga are listed in ``requirements.txt``. Helga 12 | supports SSL connections to a chat server (currently IRC, XMPP, and HipChat); in order to compile 13 | SSL support, you will need to install both ``openssl`` and ``libssl-dev`` packages, as well as 14 | ``libffi6`` and ``libffi-dev`` (the latter are required for ``cffi``, needed by ``pyOpenSSL`` 15 | version 0.14 or later). 16 | 17 | Optionally, you can have Helga configured to connect to a MongoDB server. Although 18 | this is not strictly required, many plugins require a connection to operate, so it 19 | is highly recommended. "Why MongoDB", you ask? Since MongoDB is a document store, 20 | it is much more flexible for changing schema definitions within plugins. This completely 21 | eliminates the need for Helga to manage schema migrations for different plugin versions. 22 | 23 | 24 | .. important:: 25 | 26 | Helga is currently **only** supported and tested for Python versions 2.6 and 2.7 27 | 28 | 29 | .. _getting_started.installing: 30 | 31 | Installing 32 | ---------- 33 | Helga is hosted in PyPI. For the latest version, simply install: 34 | 35 | .. code-block::bash 36 | 37 | $ pip install helga 38 | 39 | Note, that if you follow the development instructions below and wish to install helga in a virtualenv, 40 | you will need to activate it prior to installing helga using pip. In the future, there may be a collection 41 | of .rpm or .deb packages for specific systems, but for now, pip is the only supported means of install. 42 | 43 | .. _getting_started.docker: 44 | 45 | Deploying with Docker 46 | --------------------- 47 | Helga can now be run in docker. In this you'll build the docker image yourself, then run it using the docker 48 | command. It is recommended that you only use this method if you are already familiar with docker. 49 | 50 | .. code-block:: bash 51 | 52 | $ docker build -t . 53 | $ docker run -d [opts] [opts] 54 | 55 | The opts you can choose in the run command are standard options for running docker. If you want to use a 56 | settings file that is non-standard, or a persistant datbase, you'll want to use the -v option to mount those 57 | volumes. Additionally, you may add opts to the helga command after specifying the image you're building. 58 | 59 | Some gotchas: 60 | If you're mounting a volume with -v you will need to specify the full path to the directory containing the 61 | files you want shared. 62 | 63 | If you're using an altenative settings file you'll need to add the --settings=/path/to/file.py opt to the run command. 64 | 65 | If you are using an local mongodb, you'll need to mount it like below, and make sure your settings file reflects the 66 | mounted file. 67 | 68 | .. code-block:: bash 69 | 70 | $ docker run -d -v /home/settings:/opt/settings helga:16.04 --settings=/opt/settings/my_settings.py 71 | $ docker run -d -v /path/to/mongodb:/opt/mongodb helga:16.04 72 | 73 | .. _getting_started.development: 74 | 75 | Development Setup 76 | ----------------- 77 | To setup helga for development, start by creating a virtualenv and activating it: 78 | 79 | .. code-block:: bash 80 | 81 | $ virtualenv helga 82 | $ cd helga 83 | $ source bin/activate 84 | 85 | Then grab the latest copy of the helga source: 86 | 87 | .. code-block:: bash 88 | 89 | $ git clone https://github.com/shaunduncan/helga src/helga 90 | $ cd src/helga 91 | $ python setup.py develop 92 | 93 | Installing helga this way creates a ``helga`` console script in the virtualenv's ``bin`` 94 | directory that will start the helga process. Run it like this: 95 | 96 | .. code-block:: bash 97 | 98 | $ helga 99 | 100 | 101 | .. _getting_started.vagrant: 102 | 103 | Using Vagrant 104 | ------------- 105 | Alternatively, if you would like to setup helga to run entirely in a virtual machine, 106 | there is a Vagrantfile for you: 107 | 108 | .. code-block:: bash 109 | 110 | $ git clone https://github.com/shaunduncan/helga 111 | $ cd helga 112 | $ vagrant up 113 | 114 | This will provision an ubuntu 12.04 virtual machine with helga fully installed. It will 115 | also ensure that IRC and MongoDB servers are running as well. The VM will have ports 116 | 6667 and 27017 for IRC and MongoDB respectively forwarded from the host machine, as well 117 | as private network IP 192.168.10.101. Once this VM is up and running, simply: 118 | 119 | .. code-block:: bash 120 | 121 | $ vagrant ssh 122 | $ helga 123 | 124 | The source directory includes an `irssi `_ configuration file that 125 | connects to the IRC server at localhost:6667 and auto-joins the ``#bots`` channel; to use 126 | this simply run from the git clone directory: 127 | 128 | .. code-block:: bash 129 | 130 | $ irssi --home=.irssi 131 | 132 | .. _getting_started.tests: 133 | 134 | Running Tests 135 | ------------- 136 | Helga has a full test suite for its various components. Since helga is supported for multiple 137 | python versions, tests are run using `tox`_, which can be run entirely with helga's setup.py. 138 | 139 | .. code-block:: bash 140 | 141 | $ python setup.py test 142 | 143 | Alternatively, if you would like to run tox directly: 144 | 145 | .. code-block:: bash 146 | 147 | $ pip install tox 148 | $ tox 149 | 150 | Helga uses `pytest`_ as it's test runner, so you can run individual tests if you like, 151 | but you will need to install test requirements: 152 | 153 | .. code-block:: bash 154 | 155 | $ pip install pytest mock pretend freezegun 156 | $ py.test 157 | 158 | 159 | .. _getting_started.docs: 160 | 161 | Building Docs 162 | ------------- 163 | Much like the test suite, helga's documentation is built using tox: 164 | 165 | .. code-block:: bash 166 | 167 | $ tox -e docs 168 | 169 | Or alternatively (with installing requirements): 170 | 171 | .. code-block:: bash 172 | 173 | $ pip install sphinx alabaster 174 | $ cd docs 175 | $ make html 176 | 177 | 178 | .. _`tox`: https://tox.readthedocs.org/en/latest/ 179 | .. _`pytest`: http://pytest.org/latest/ 180 | -------------------------------------------------------------------------------- /helga/tests/test_log.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | import freezegun 5 | import pytest 6 | 7 | from mock import patch, Mock 8 | 9 | from helga import log 10 | 11 | 12 | @patch('helga.log.logging') 13 | @patch('helga.log.settings') 14 | def test_getLogger(settings, logging): 15 | logger = Mock() 16 | handler = Mock() 17 | formatter = Mock() 18 | 19 | settings.LOG_LEVEL = 'INFO' 20 | settings.LOG_FILE = None 21 | settings.LOG_FORMAT = None 22 | 23 | expected_format = '%(asctime)-15s [%(levelname)s] [%(name)s:%(lineno)d]: %(message)s' 24 | 25 | logging.getLogger.return_value = logger 26 | logging.StreamHandler.return_value = handler 27 | logging.Formatter.return_value = formatter 28 | 29 | log.getLogger('foo') 30 | logging.getLogger.assert_called_with('foo') 31 | logger.setLevel.assert_called_with(logging.INFO) 32 | logger.addHandler.assert_called_with(handler) 33 | logging.Formatter.assert_called_with(expected_format) 34 | handler.setFormatter.assert_called_with(formatter) 35 | assert not logger.propagate 36 | 37 | 38 | @patch('helga.log.logging') 39 | @patch('helga.log.settings') 40 | def test_get_logger_uses_log_file(settings, logging): 41 | logger = Mock() 42 | handler = Mock() 43 | formatter = Mock() 44 | 45 | settings.LOG_LEVEL = 'INFO' 46 | settings.LOG_FILE = '/path/to/foo.log' 47 | settings.LOG_FORMAT = None 48 | 49 | logging.getLogger.return_value = logger 50 | logging.StreamHandler.return_value = handler 51 | logging.Formatter.return_value = formatter 52 | 53 | log.getLogger('foo') 54 | logging.handlers.RotatingFileHandler.assert_called_with(filename=settings.LOG_FILE, 55 | maxBytes=50*1024*1024, 56 | backupCount=6) 57 | 58 | 59 | @patch('helga.log.logging') 60 | @patch('helga.log.settings') 61 | def test_get_logger_uses_custom_formatter(settings, logging): 62 | logger = Mock() 63 | handler = Mock() 64 | formatter = Mock() 65 | 66 | settings.LOG_LEVEL = 'INFO' 67 | settings.LOG_FILE = '/path/to/foo.log' 68 | settings.LOG_FORMAT = 'blah blah blah' 69 | 70 | logging.getLogger.return_value = logger 71 | logging.StreamHandler.return_value = handler 72 | logging.Formatter.return_value = formatter 73 | 74 | log.getLogger('foo') 75 | logging.Formatter.assert_called_with(settings.LOG_FORMAT) 76 | 77 | 78 | @patch('helga.log.os') 79 | @patch('helga.log.logging') 80 | @patch('helga.log.settings') 81 | def test_get_channel_logger(settings, logging, os): 82 | logger = Mock() 83 | handler = Mock() 84 | formatter = Mock() 85 | 86 | def os_join(*args): 87 | return '/'.join(args) 88 | 89 | settings.CHANNEL_LOGGING_DIR = '/path/to/channels' 90 | settings.CHANNEL_LOGGING_DB = False 91 | os.path.exists.return_value = True 92 | os.path.join = os_join 93 | 94 | # Mocked returns 95 | logging.getLogger.return_value = logger 96 | logging.Formatter.return_value = formatter 97 | 98 | with patch.object(log, 'ChannelLogFileHandler'): 99 | log.ChannelLogFileHandler.return_value = handler 100 | log.get_channel_logger('#foo') 101 | 102 | # Gets the right logger 103 | logging.getLogger.assert_called_with('channel_logger/#foo') 104 | logger.setLevel.assert_called_with(logging.INFO) 105 | assert logger.propagate is False 106 | 107 | # Sets the handler correctly 108 | log.ChannelLogFileHandler.assert_called_with('/path/to/channels/#foo') 109 | handler.setFormatter.assert_called_with(formatter) 110 | 111 | # Sets the formatter correctly 112 | logging.Formatter.assert_called_with('%(utctime)s - %(nick)s - %(message)s') 113 | 114 | # Logger uses the handler 115 | logger.addHandler.assert_called_with(handler) 116 | 117 | 118 | @patch('helga.log.os') 119 | @patch('helga.log.logging') 120 | @patch('helga.log.settings') 121 | def test_get_channel_logger_creates_log_dirs(settings, logging, os): 122 | def os_join(*args): 123 | return '/'.join(args) 124 | 125 | settings.CHANNEL_LOGGING_DIR = '/path/to/channels' 126 | settings.CHANNEL_LOGGING_DB = False 127 | os.path.exists.return_value = False 128 | os.path.join = os_join 129 | 130 | log.get_channel_logger('#foo') 131 | 132 | os.makedirs.assert_called_with('/path/to/channels/#foo') 133 | 134 | 135 | class TestChannelLogFileHandler(object): 136 | 137 | def setup(self): 138 | self.handler = log.ChannelLogFileHandler('/tmp') 139 | 140 | def test_setup_correctly(self): 141 | assert self.handler.basedir == '/tmp' 142 | assert re.match(r'/tmp/[0-9]{4}-[0-9]{2}-[0-9]{2}.txt', 143 | self.handler.baseFilename) 144 | 145 | def test_compute_next_rollover(self): 146 | expected = datetime.datetime(2014, 11, 1) 147 | with freezegun.freeze_time('2014-10-31 08:15'): 148 | assert self.handler.compute_next_rollover() == expected 149 | 150 | @freezegun.freeze_time('2014-10-31 08:15') 151 | def test_current_filename(self): 152 | assert self.handler.current_filename() == '2014-10-31.txt' 153 | 154 | @pytest.mark.parametrize('datestr,rollover', [ 155 | ('2014-10-31 08:15', False), 156 | ('2014-11-01 00:00', True), 157 | ('2014-11-01 08:15', True), 158 | ]) 159 | def test_shouldRollover(self, datestr, rollover): 160 | self.handler.next_rollover = datetime.datetime(2014, 11, 1) 161 | with freezegun.freeze_time(datestr): 162 | assert self.handler.shouldRollover(None) is rollover 163 | 164 | def test_do_rollover(self): 165 | stream = Mock() 166 | self.handler.stream = stream 167 | 168 | old_rollover = self.handler.next_rollover 169 | old_filename = self.handler.baseFilename 170 | expected_rollover = datetime.datetime(2014, 11, 1, 0, 0, 0) 171 | 172 | assert old_rollover != expected_rollover 173 | 174 | with freezegun.freeze_time('2014-10-31 08:15'): 175 | with patch.object(self.handler, '_open'): 176 | self.handler.doRollover() 177 | assert stream.close.called 178 | assert self.handler._open.called 179 | assert self.handler.baseFilename != old_filename 180 | assert self.handler.baseFilename == '/tmp/2014-10-31.txt' 181 | assert self.handler.next_rollover == expected_rollover 182 | 183 | 184 | def test_utc_time_filter(): 185 | record = Mock() 186 | filter = log.UTCTimeLogFilter() 187 | date = datetime.datetime(2014, 10, 31, 8, 15) 188 | with freezegun.freeze_time(date): 189 | filter.filter(record) 190 | assert record.utcnow == date 191 | assert record.utctime == '08:15:00' 192 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_help.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | from collections import defaultdict 3 | from mock import MagicMock as Mock, patch 4 | from pretend import stub 5 | 6 | from helga.plugins import help 7 | 8 | 9 | @patch('helga.plugins.help.registry') 10 | def test_help_should_whisper(plugins): 11 | client = Mock() 12 | plugins.enabled_plugins = defaultdict(list) 13 | plugins.plugins = {} 14 | 15 | help.help(client, '#bots', 'me', 'foo', 'bar', []) 16 | 17 | assert client.me.called 18 | client.me.assert_called_with('#bots', 'whispers to me') 19 | 20 | 21 | @patch('helga.plugins.help.registry') 22 | def test_help_gets_object_help_attrs(plugins): 23 | snowman = u'☃' 24 | foo_plugin = stub(help='foo plugin') 25 | bar_plugin = stub() 26 | uni_plugin = stub(help=u'unicode plugin {0}'.format(snowman)) 27 | 28 | client = Mock() 29 | plugins.enabled_plugins = { 30 | '#bots': ['foo', 'bar', snowman], 31 | } 32 | plugins.plugins = { 33 | 'foo': foo_plugin, 34 | 'bar': bar_plugin, 35 | snowman: uni_plugin, 36 | } 37 | 38 | help.help(client, '#bots', 'me', 'help', 'help', []) 39 | 40 | nick, message = client.msg.call_args[0] 41 | assert nick == 'me' 42 | assert 'me, here are the plugins I know about' in message 43 | assert u'[{0}] unicode plugin {0}'.format(snowman) in message 44 | assert '[foo] foo plugin' in message 45 | 46 | 47 | @patch('helga.plugins.help.registry') 48 | def test_help_gets_decorated_help_attrs(plugins): 49 | snowman = u'☃' 50 | foo_plugin = stub(help='foo plugin') 51 | foo_fn = stub(_plugins=[foo_plugin]) 52 | 53 | uni_plugin = stub(help=u'unicode plugin {0}'.format(snowman)) 54 | uni_fn = stub(_plugins=[uni_plugin]) 55 | 56 | client = Mock() 57 | plugins.enabled_plugins = { 58 | '#bots': ['foo', snowman], 59 | } 60 | plugins.plugins = { 61 | 'foo': foo_fn, 62 | snowman: uni_fn, 63 | } 64 | 65 | help.help(client, '#bots', 'me', 'help', 'help', []) 66 | 67 | nick, message = client.msg.call_args[0] 68 | assert nick == 'me' 69 | assert 'me, here are the plugins I know about' in message 70 | assert u'[{0}] unicode plugin {0}'.format(snowman) in message 71 | assert '[foo] foo plugin' in message 72 | 73 | 74 | @patch('helga.plugins.help.registry') 75 | def test_help_gets_multi_decorated_help_attrs(plugins): 76 | foo_plugin = stub(help='foo plugin') 77 | bar_plugin = stub(help='bar plugin') 78 | uni_plugin = stub(help=u'unicode plugin ☃') 79 | foo_fn = stub(_plugins=[foo_plugin, bar_plugin, uni_plugin]) 80 | 81 | client = Mock() 82 | plugins.enabled_plugins = {'#bots': ['foo']} 83 | plugins.plugins = {'foo': foo_fn} 84 | 85 | help.help(client, '#bots', 'me', 'help', 'help', []) 86 | 87 | nick, message = client.msg.call_args[0] 88 | assert nick == 'me' 89 | assert 'me, here are the plugins I know about' in message 90 | assert u'[foo] foo plugin. bar plugin. unicode plugin ☃' in message 91 | 92 | 93 | @patch('helga.plugins.help.registry') 94 | def test_help_gets_single_plugin(plugins): 95 | snowman = u'☃' 96 | foo_plugin = stub(help='foo plugin') 97 | bar_plugin = stub(help='bar plugin') 98 | uni_plugin = stub(help=u'unicode plugin {0}'.format(snowman)) 99 | 100 | client = Mock() 101 | plugins.enabled_plugins = { 102 | '#bots': ['foo', 'bar', snowman], 103 | } 104 | plugins.plugins = { 105 | 'foo': foo_plugin, 106 | 'bar': bar_plugin, 107 | snowman: uni_plugin, 108 | } 109 | 110 | message = help.help(client, '#bots', 'me', 'help', 'help', ['bar']) 111 | assert '[bar] bar plugin' in message 112 | assert '[foo] foo plugin' not in message 113 | assert u'[{0}] unicode plugin {0}'.format(snowman) not in message 114 | 115 | message = help.help(client, '#bots', 'me', 'help', 'help', [snowman]) 116 | assert '[bar] bar plugin' not in message 117 | assert '[foo] foo plugin' not in message 118 | assert u'[{0}] unicode plugin {0}'.format(snowman) in message 119 | 120 | 121 | @patch('helga.plugins.help.registry') 122 | def test_help_single_returns_unknown(plugins): 123 | foo_plugin = stub(help='foo plugin') 124 | bar_plugin = stub(help='bar plugin') 125 | 126 | client = Mock() 127 | plugins.enabled_plugins = { 128 | '#bots': ['foo', 'bar'], 129 | } 130 | plugins.plugins = { 131 | 'foo': foo_plugin, 132 | 'bar': bar_plugin, 133 | } 134 | 135 | resp = help.help(client, '#bots', 'me', 'help', 'help', ['baz']) 136 | assert resp == "Sorry me, I don't know about that plugin" 137 | assert not client.msg.called 138 | 139 | # Can handle unicode 140 | resp = help.help(client, '#bots', 'me', 'help', 'help', [u'☃']) 141 | assert resp == "Sorry me, I don't know about that plugin" 142 | assert not client.msg.called 143 | 144 | 145 | @patch('helga.plugins.help.registry') 146 | def test_help_with_unloaded_plugin(plugins): 147 | snowman = u'☃' 148 | foo_plugin = stub(help='foo plugin') 149 | 150 | client = Mock() 151 | plugins.enabled_plugins = {'#bots': ['foo', snowman]} 152 | plugins.plugins = {'foo': foo_plugin} 153 | 154 | resp = help.help(client, '#bots', 'me', 'help', 'help', [snowman]) 155 | assert resp == u"Sorry me, there's no help string for plugin '{0}'".format(snowman) 156 | assert not client.msg.called 157 | 158 | 159 | @patch('helga.plugins.help.registry') 160 | def test_help_object_with_empty_help_attr(plugins): 161 | foo_plugin = stub(help='') 162 | uni_plugin = stub(help='') 163 | 164 | client = Mock() 165 | plugins.enabled_plugins = {'#bots': ['foo', u'☃']} 166 | plugins.plugins = {'foo': foo_plugin, u'☃': uni_plugin} 167 | 168 | help.help(client, '#bots', 'me', 'help', 'help', []) 169 | 170 | _, message = client.msg.call_args[0] 171 | assert '[foo] No help string for this plugin' in message 172 | assert u'[☃] No help string for this plugin' in message 173 | 174 | 175 | @patch('helga.plugins.help.registry') 176 | def test_help_decorated_with_empty_help_attr(plugins): 177 | foo_fn = stub(_plugins=[stub(help='')]) 178 | uni_fn = stub(_plugins=[stub(help='')]) 179 | 180 | client = Mock() 181 | plugins.enabled_plugins = {'#bots': ['foo', u'☃']} 182 | plugins.plugins = {'foo': foo_fn, u'☃': uni_fn} 183 | 184 | help.help(client, '#bots', 'me', 'help', 'help', []) 185 | 186 | _, message = client.msg.call_args[0] 187 | assert '[foo] No help string for this plugin' in message 188 | assert u'[☃] No help string for this plugin' in message 189 | 190 | 191 | def test_format_help_string(): 192 | unistr = u'[☃] snowman. ☃' 193 | string = '[foo] just foo. bar' 194 | 195 | assert unistr == help.format_help_string(u'☃', 'snowman', u'☃') 196 | assert string == help.format_help_string('foo', 'just foo', 'bar') 197 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/helga.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/helga.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/helga" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/helga" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 1.7.13 5 | ------ 6 | - Bug fixes for slack html escaping 7 | 8 | 1.7.12 9 | ------ 10 | - Slack reconnect and bug fixes 11 | 12 | 1.7.11 13 | ------ 14 | - Minor adjustments to slack beta after more testing 15 | 16 | 1.7.10 17 | ------ 18 | - Updated packaging metadata to present a more informative landing page on PyPI 19 | 20 | 1.7.9 21 | ----- 22 | - Introduced BETA rollout of native slack integration (Many, many thanks to @ktdreyer!) 23 | 24 | 1.7.8 25 | ----- 26 | - Upgraded development Vagrantfile to Xenial 27 | - Fixed some tests to get CI feedback working again 28 | 29 | 1.7.7 30 | ----- 31 | - Added support for IRC SASL authentication 32 | 33 | 1.7.6 34 | ----- 35 | - Added support for IRC NAMES with a signal callback: names_reply 36 | 37 | 38 | 1.7.2 39 | ----- 40 | - Merged #147: Track /me messages sent over chat 41 | - Merged #149: Allow "!plugins" with no argument to list known plugins 42 | - Merged #151: Upgrade OpenSSL dependency 43 | 44 | 1.7.1 45 | ----- 46 | - Fixed #145: XMPP client doesn't have operators attribute 47 | - Added a simple version check plugin that responds with the current helga version 48 | 49 | 50 | 1.7.0 51 | ----- 52 | - Fixed #118: Removed hash attribute of reminders and used the full ObjectId 53 | - Fixed #136: improve plugin whitelist/blacklist functionality and clarity 54 | - Fixed #142: Split out non-essential plugins into their own repos 55 | - Removed dubstep plugin. Now at https://github.com/shaunduncan/helga-dubstep (pypi helga-dubstep) 56 | - Removed facts plugin. Now at https://github.com/shaunduncan/helga-facts (pypi helga-facts) 57 | - Removed giphy plugin. Now at https://github.com/shaunduncan/helga-giphy (pypi helga-giphy) 58 | - Removed icanhazascii plugin. Now at https://github.com/shaunduncan/helga-icanhazascii (pypi helga-icanhazascii) 59 | - Removed jira plugin. Now at https://github.com/shaunduncan/helga-jira (pypi helga-jira) 60 | - Removed loljava plugin. Now at https://github.com/shaunduncan/helga-loljava (pypi helga-loljava) 61 | - Removed meant_to_say plugin. Now at https://github.com/shaunduncan/helga-meant-to-say (pypi helga-meant-to-say) 62 | - Removed no_more_olga plugin. Now at https://github.com/shaunduncan/helga-no-more-olga (pypi helga-no-more-olga) 63 | - Removed oneliner plugin. Now at https://github.com/shaunduncan/helga-oneliner (pypi helga-onliner) 64 | - Removed poems plugin. Now at https://github.com/shaunduncan/helga-poems (pypi helga-poems) 65 | - Removed reminders plugin. Now at https://github.com/shaunduncan/helga-reminders (pypi helga-reminders) 66 | - Removed reviewboard plugin. Now at https://github.com/shaunduncan/helga-reviewboard (pypi helga-reviewboard) 67 | - Removed stfu plugin. Now at https://github.com/shaunduncan/helga-stfu (pypi helga-stfu) 68 | - Removed wiki_whois plugin. Now at https://github.com/shaunduncan/helga-wiki-whois (pypi helga-wiki-whois) 69 | - Added beta version of XMPP/HipChat support and updated documentation 70 | - Added a simple ping command plugin that responds with 'pong' 71 | 72 | 73 | 1.6.8 74 | ----- 75 | - Merge #141 - Unpin pytz to fix broken dependency 76 | - Fix broken "at" reminder code with pytz update 77 | 78 | 79 | 1.6.7 80 | ----- 81 | - Fixed #140 - Allow simple string channel names for CHANNELS setting 82 | - Merged PR #138 - Fix shell oneliner response 83 | 84 | 85 | 1.6.6 86 | ----- 87 | - Fixed #137 - Chicken/egg situation with @route in the same module as a command or match 88 | 89 | 90 | 1.6.5 91 | ----- 92 | - Fixed #134 - Missing __init__.py in helga.bin causing console script issues 93 | 94 | 95 | 1.6.4 96 | ----- 97 | - Fixed #133 - custom settings are not properly overriding client settings 98 | 99 | 100 | 1.6.3 101 | ----- 102 | - Added full documentation and updated many docstrings. 103 | - Removed package helga.plugins.core contents and placed in helga.plugins.__init__ 104 | - Updated setup.py to support pip >= 0.7 105 | - Fixed #20 - Added case-insensitive command support. 106 | - Fixed #131 - ResponseNotReady does not honor PLUGIN_FIRST_RESPONDER_ONLY = False 107 | 108 | 109 | 1.6.2 110 | ----- 111 | - Fix UnicodeDecodeError for channel log web UI 112 | 113 | 114 | 1.6.1 115 | ----- 116 | - Fix broken packaging that did not include channel log web UI mustache templates. 117 | 118 | 119 | 1.6.0 120 | ----- 121 | - Added a new channel logger to log conversations to UTC dated text files. Also features a 122 | web UI for log browsing. 123 | - Fixed #68 - Custom settings overrides can be supplied via command line argument --settings. 124 | The old env var is still supported. Either option can be an import string 'foo.bar.baz' or 125 | a path on the filesystem 'foo/bar/baz.py' 126 | - Fixed #77 - Allow custom plugin priority weights to be set in settings overrides 127 | - Fixed #83 - The JIRA plugin no longer uses BeautifulSoup as a fallback 128 | - Fixed #107 - Set erroneousNickFallback for default IRC client 129 | - Fixed #111 - Better README docs on SERVER settings 130 | - Fixed #120 - Operator plugin doesn't format responses properly 131 | - Fixed #123 - Changed PyPI classifier to Production/Stable 132 | - Fixed #126 - JIRA plugin exception when JIRA_PATTERNS is empty 133 | - Fixed #127 - Allow optional setting to use shlex for comman arg string parsing instead of 134 | naive whitespace splitting (see README for COMMAND_ARGS_SHLEX). This can also be a command 135 | decorator argument like @command('foo', shlex=True). 136 | 137 | 138 | 1.5.2 139 | ----- 140 | - Merged PR #119 - Adding replace command for facts plugin 141 | - Merged PR #117 - Fix oneliner regex to not be noisy for gfycat links 142 | 143 | 144 | 1.5.1 145 | ----- 146 | - Added AUTO_RECONNECT support for failed connections (in addition to lost connections) 147 | - Added AUTO_RECONNECT_DELAY to have a sensible wait time before connect retries 148 | 149 | 150 | 1.5.0 151 | ----- 152 | - Fix The Unicode Problem(TM) (Issue 86) 153 | - Vastly improved test suite. Now with 100% test coverage 154 | 155 | 156 | 1.4.6 157 | ----- 158 | - Fixed regex bug in command parsing that looks for a space after a command/alias 159 | 160 | 161 | 1.4.5 162 | ----- 163 | - Fixed a bug where the WebHook root object doesn't get the current IRC client 164 | on signon. (Issue #89) 165 | 166 | 167 | 1.4.4 168 | ----- 169 | - Signals are now sent when a user joins or leaves a channel. Sending args 170 | (client, nick, channel) 171 | 172 | 173 | 1.4.3 174 | ----- 175 | - Changed markdown documents to reStructuredText 176 | 177 | 178 | 1.4.2 179 | ----- 180 | - Fix a quirk in command alias ordering where shorter commands would override 181 | the longer variants (i.e. 't' vs 'thanks') 182 | 183 | 184 | 1.4.1 185 | ----- 186 | - Minor adjustment to operator plugin docstring 187 | 188 | 189 | 1.4.0 190 | ----- 191 | - Merged pull requests #59 and #62 192 | - Changed license from MIT to dual MIT/GPLv3 193 | - Switched to semantic versioning 194 | 195 | 196 | 1.3 197 | --- 198 | - Refactored simple announcement service into an extensible webhook plugin system 199 | 200 | 201 | 1.2 202 | --- 203 | - Added a very simple announcement HTTP service 204 | 205 | 206 | 1.1 207 | --- 208 | - Included ability for operators to reload installed plugins without restarting 209 | - Haiku/Tanka tweets now run via ``reactor.callLater`` 210 | - Any plugin that raises ``ResponseNotReady`` when helga is set to allow first 211 | response only will prevent other plugins from running 212 | 213 | 214 | 1.0 215 | --- 216 | - Completely refactored the internal plugin API to be simpler and easier to use 217 | - All plugins use setuptools entry_points now 218 | -------------------------------------------------------------------------------- /docs/source/builtins.rst: -------------------------------------------------------------------------------- 1 | _builtin: 2 | 3 | 4 | Builtin Features 5 | ================ 6 | Helga comes with many builtin plugins, webhooks, and features. 7 | 8 | 9 | .. _builtin.supported_backends: 10 | 11 | Supported Backends 12 | ------------------ 13 | 14 | As of version 1.7.0, helga supports IRC, XMPP, and HipChat out of the box. Note, however, that 15 | helga originally started as an IRC bot, so much of the terminology will reflect that. The current 16 | status of XMPP and HipChat support is very limited and somewhat beta. In the future, helga may 17 | have a much more robust and pluggable backend system to allow connections to any number of chat 18 | services. 19 | 20 | The default configuration assumes that you wish to connect to an IRC server. However, if you wish 21 | to connect to an XMPP or HipChat server, see :ref:`config.xmpp`. 22 | 23 | 24 | .. _builtin.plugins: 25 | 26 | Builtin Plugins 27 | --------------- 28 | Helga comes with several builtin plugins. Generally speaking, it is better to have independently maintained 29 | plugins rather than modifying helga core. In fact, many of the plugins listed here may be retired as 30 | core plugins and moved to externally maintained locations. This is mainly due to the fact that some are 31 | either not useful as core plugins or would require more maintenance for helga core than should be needed. 32 | 33 | .. important:: 34 | 35 | Some builtin plugins may be deprecated and removed in a future version of helga. They will be 36 | moved and maintained elsewhere as independent plugins. 37 | 38 | 39 | .. _builtin.plugins.help: 40 | 41 | help 42 | ^^^^ 43 | A command plugin to show help strings for any installed command plugin. Usage:: 44 | 45 | helga (help|halp) [] 46 | 47 | With no arguments, all command plugin help strings are returned to the requesting user in a private message. 48 | 49 | 50 | .. _builtin.plugins.manager: 51 | 52 | manager 53 | ^^^^^^^ 54 | .. important:: 55 | 56 | This plugin requires database access for some features 57 | 58 | A command plugin that acts as an IRC-based plugin manager. Usage:: 59 | 60 | helga plugins (list|(enable|disable) ( ...)) 61 | 62 | The 'list' subcommand will list out both enabled and disabled plugins for the current channel. For example:: 63 | 64 | !plugins list 65 | Enabled plugins: foo, bar 66 | Disabled plugins: baz 67 | 68 | Both enable and disable will respectively move a plugin between enabled and disabled status 69 | on the current channel. If a database connection is configured, both enable and disable will record 70 | plugins as either automatically enabled for the current channel or not. For example:: 71 | 72 | !plugins enable baz 73 | !plugins list 74 | Enabled plugins: foo, bar, baz 75 | !plugins disable baz 76 | Enabled plugins: foo, bar 77 | Disabled plugins: baz 78 | 79 | 80 | .. _builtin.plugins.operator: 81 | 82 | operator 83 | ^^^^^^^^ 84 | .. important:: 85 | 86 | This plugin requires database access for some features 87 | 88 | A command plugin that exposes some capabilities exclusively for helga operators. Operators are nicks 89 | with elevated privileges configured via the ``OPERATORS`` setting (see :ref:`helga.settings.core`). 90 | Usage:: 91 | 92 | helga (operator|oper|op) (reload |(join|leave|autojoin (add|remove)) ). 93 | 94 | Each subcommand acts as follows: 95 | 96 | ``reload `` 97 | Experimental. Given a plugin name, perform a call to the python builtin ``reload()`` of the 98 | loaded module. Useful for seeing plugin code changes without restarting the process. 99 | 100 | ``(join|leave) `` 101 | Join or leave a specified channel 102 | 103 | ``autojoin (add|remove) `` 104 | Add or remove a channel from a set of autojoin channels. This features requries database access. 105 | 106 | 107 | .. _builtin.plugins.ping: 108 | 109 | ping 110 | ^^^^ 111 | A simple command plugin to ping the bot, which will always respond with 'pong'. Usage:: 112 | 113 | helga ping 114 | 115 | 116 | .. _builtin.plugins.webhooks: 117 | 118 | webhooks 119 | ^^^^^^^^ 120 | A special type of command plugin that enables webhook support (see :ref:`webhooks`). This command 121 | is more of a high-level manager of the webhook system. Usage:: 122 | 123 | helga webhooks (start|stop|routes) 124 | 125 | Both ``start`` and ``stop`` are privileged actions and can start and stop the HTTP listener for 126 | webhooks respectively. To use them, a user must be configured as an operator. The ``routes`` 127 | subcommand will list all of the URL routes known to the webhook listener. 128 | 129 | 130 | .. _builtin.webhooks: 131 | 132 | Builtin Webhooks 133 | ---------------- 134 | Helga also includes some builtin webhooks for use out of the box. 135 | 136 | 137 | .. _builtin.webhooks.announcements: 138 | 139 | announcements 140 | ^^^^^^^^^^^^^ 141 | The announcements webhook exposes a single HTTP endpoint for allowing the ability to 142 | post a message in an IRC channel via an HTTP request. This webhook **only** supports 143 | POST requests and requires HTTP basic authentication (see :ref:`webhooks.authentication`). 144 | Requests must be made to a URL path ``/announce/`` such as ``/announce/bots`` 145 | and made with a POST parameter ``message`` containing the IRC message contents. The 146 | endpoint will respond with 'Message Sent' on a successful message send. 147 | 148 | 149 | .. _builtin.webhooks.logger: 150 | 151 | logger 152 | ^^^^^^ 153 | The logger webhook is a browsable web frontend for helga's builtin channel logger (see 154 | :ref:`builtin.channel_logging`). This webhook is enabled by default but requires that channel 155 | logging is enabled for it to be of any use. Logs are shown in a dated order, grouped by 156 | channel. 157 | 158 | Without any configuration, this web frontend will allow browsing all channels in which the 159 | bot resides or has resided. This behavior can be changed with the setting 160 | :data:`~helga.settings.CHANNEL_LOGGING_HIDE_CHANNELS` which should be a list of channel names 161 | that should be hidden from the browsable web UI. NOTE: they can still be accessed directly. 162 | 163 | This webhook exposes a root ``/logger`` URL endpoint that serves as a channel listing. The 164 | webhook will support any url of the form ``/logger//YYYY-MM-DD`` such as 165 | ``/logger/foo/2014-12-31``. 166 | 167 | 168 | .. _builtin.channel_logging: 169 | 170 | Channel Logging 171 | --------------- 172 | As of the 1.6 release, helga includes support for a simple channel logger, which may be useful for 173 | those wanting to helga to, in addition to any installed plugins, monitor and save conversations that 174 | occur on any channel in which the bot resides. This is a helga core feature and not managed by a plugin, 175 | mostly to ensure that channel logging *always* happens with some level of confidence that no 176 | preprocess plugins have modified the message. Channel logging feature can be either enabled or 177 | disabled via the setting :data:`~helga.settings.CHANNEL_LOGGING`. 178 | 179 | Channel logs are kept in UTC time and stored in dated logfiles that are rotated automatically. These 180 | log files are written to disk in a configurable location indicated by :data:`~helga.settings.CHANNEL_LOGGING_DIR` 181 | and are organized by channel name. For example, message that occurred on Dec 31 2014 on channel #foo 182 | would be written to a file ``/path/to/logs/#foo/2014-12-31.txt`` 183 | 184 | The channel logger also includes a web frontend for browsing any logs on disk, documented as the builtin 185 | webhook :ref:`builtin.webhooks.logger`. 186 | 187 | .. note:: 188 | 189 | Non-public channels (i.e. those not beginning with a '#') will be ignored by helga's channel 190 | logger. No conversations via private messages will be logged. 191 | -------------------------------------------------------------------------------- /docs/source/configuring_helga.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: helga.settings 2 | 3 | .. _config: 4 | 5 | Configuring Helga 6 | ================= 7 | As mentioned in :ref:`getting_started`, when you install helga, a ``helga`` console script is 8 | created that will run the bot process. This is the simplest way to run helga, however, it will 9 | assume various default settings like assuming that both an IRC and MongoDB server to which you 10 | wish to connect run on your local machine. This may not be ideal for running helga in a production 11 | environment. For this reason you may wish to create your own configuration for helga. 12 | 13 | 14 | .. _config.custom: 15 | 16 | Custom Settings 17 | --------------- 18 | Helga settings files are essentially executable python files. If you have ever worked with django 19 | settings files, helga settings will feel very similar. Helga does assume some configuration defaults, 20 | but you can (and should) use a custom settings file. The behavior of any custom settings file you use 21 | is to overwrite any default configuration helga uses. For this reason, you do not need to apply 22 | all of the configuration settings (listed below) known. For example, a simple settings file to connect 23 | to an IRC server at ``example.com`` on port ``6667`` would be:: 24 | 25 | SERVER = { 26 | 'HOST': 'example.com', 27 | 'PORT': 6667, 28 | } 29 | 30 | There are two ways in which you can use a custom settings file. First, you could export a ``HELGA_SETTINGS`` 31 | environment variable. Alternatively, you can indicate this via a ``--settings`` argument to the ``helga`` 32 | console script. For example: 33 | 34 | .. code-block:: bash 35 | 36 | $ export HELGA_SETTINGS=foo.settings 37 | $ helga 38 | 39 | Or: 40 | 41 | .. code-block:: bash 42 | 43 | $ helga --settings=/etc/helga/settings.py 44 | 45 | In either case, this value should be an absolute filesystem path to a python file like ``/path/to/foo.py``, 46 | or a python module string available on ``$PYTHONPATH`` like ``path.to.foo``. 47 | 48 | 49 | 50 | .. _config.default: 51 | 52 | Default Configuration 53 | --------------------- 54 | Running the ``helga`` console script with no arguments will run helga using a default configuration, which 55 | assumes that you are wishing to connect to an IRC server. For a full list of the included default settings, 56 | see :mod:`helga.settings`. 57 | 58 | 59 | 60 | .. _config.xmpp: 61 | 62 | XMPP Configuration 63 | ------------------ 64 | Helga was originally written as an IRC bot, but now includes XMPP support as well. Since its background 65 | as an IRC bot, much of the language in the documentation and API are geared towards that. For instance, 66 | multi user chat rooms are referred to as 'channels' and users are referred to by a 'nick'. The default 67 | helga configuration will assume that you want to connect to an IRC server. To enable XMPP connections, 68 | you must specify a ``TYPE`` value of ``xmpp`` in your ``SERVER`` settings:: 69 | 70 | SERVER = { 71 | 'HOST': 'example.com', 72 | 'PORT': 5222, 73 | 'TYPE': 'xmpp', 74 | 'USERNAME': 'helga', 75 | 'PASSWORD': 'hunter2', 76 | } 77 | 78 | Note above that you also **must** specify a value for ``USERNAME`` and ``PASSWORD``, which will result 79 | in a Jabber ID (JID) of something like ``helga@example.com``. The also assumes that the multi user chat 80 | (MUC) domain for your xmpp server is ``conference.example.com``. This might not always be desirable. 81 | For this reason, you can also specify specific JID and MUC values using the keys ``JID`` and ``MUC_HOST`` 82 | respectively. In this instance, the specific JID is used to authenticate and username is not required:: 83 | 84 | SERVER = { 85 | 'HOST': 'example.com', 86 | 'PORT': 5222, 87 | 'TYPE': 'xmpp', 88 | 'PASSWORD': 'hunter2', 89 | 'JID': 'someone@example.com', 90 | 'MUC_HOST': 'chat.example.com', 91 | } 92 | 93 | Also, just like IRC support, helga can automatically join chat rooms configured in the setting 94 | :data:`~helga.settings.CHANNELS`. You can configure this a couple of different ways, the easiest 95 | being a shorthand version of the room name, prefixed with a '#'. For example, given a room with 96 | a JID of ``bots@conf.example.com``, the setting might look like:: 97 | 98 | CHANNELS = [ 99 | '#bots', 100 | ] 101 | 102 | Alternatively, you *can* specify the full JID:: 103 | 104 | CHANNELS = [ 105 | 'bots@conf.example.com', 106 | ] 107 | 108 | Just like IRC, you can specify a room password using a two-tuple:: 109 | 110 | CHANNELS = [ 111 | ('#bots', 'room_password'), 112 | ] 113 | 114 | 115 | .. _config.xmpp.hipchat: 116 | 117 | HipChat Support 118 | ^^^^^^^^^^^^^^^ 119 | `HipChat`_ allows for clients to connect to its service using XMPP. If you are intending to use helga 120 | as a HipChat bot, you will first need to take note of the settings needed to connect (see 121 | `HipChat XMPP Settings`_). This also applies to anyone using the self-hosted HipChat server. A server 122 | configuration for connecting to HipChat might look like:: 123 | 124 | SERVER = { 125 | 'HOST': 'chat.hipchat.com', 126 | 'PORT': 5222, 127 | 'JID': '00000_00000@chat.hipchat.com', 128 | 'PASSWORD': 'hunter2', 129 | 'MUC_HOST': 'conf.hipchat.com', 130 | 'TYPE': 'xmpp', 131 | } 132 | 133 | HipChat makes a few assumtions that are different from standard XMPP clients. First, you **must** 134 | specify the :data:`~helga.settings.NICK` setting as the user's first name and last name:: 135 | 136 | NICK = 'Helga Bot' 137 | 138 | Also, if you want @ mentions to work with command plugins so that this will work:: 139 | 140 | @HelgaBot do something 141 | 142 | Set :data:`~helga.settings.COMMAND_PREFIX_BOTNICK` as the string '@?' + the @ mention name of the user. 143 | For example, if the @ mention name is 'HelgaBot':: 144 | 145 | COMMAND_PREFIX_BOTNICK = '@?HelgaBot' 146 | 147 | Finally, HipChat does not require that room members have unique JID values. Considering a user in a room 148 | might have a JID of ``room@host/user_nick``, the default XMPP backend assumes that ``user_nick`` is unique. 149 | HipChat does something a little different and assumes that the resource portion of the JID is the user's 150 | full name like ``room@host/Jane Smith``, which may not be unique. This means that replies from the bot 151 | that include a nick will say 'Jane Smith' rather than an @ mention like '@JaneSmith'. To enable @ mentions 152 | for bot replies, you should install the `hipchat_nicks`_ plugin and add ``HIPCHAT_API_TOKEN`` to your settings file: 153 | 154 | .. code-block:: bash 155 | 156 | $ pip install helga-hipchat-nicks 157 | $ echo 'HIPCHAT_API_TOKEN = "your_token"' >> path/to/your/settings.py 158 | 159 | 160 | .. _config.slack: 161 | 162 | Slack Support 163 | ^^^^^^^^^^^^^ 164 | `Slack`_ supports rich formatting for messaging, and Helga includes a connector 165 | for Slack's APIs. A configuration for connecting to Slack might look like:: 166 | 167 | SERVER = { 168 | 'TYPE': 'slack', 169 | 'API_KEY': 'xoxb-12345678901-A1b2C3deFgHiJkLmNoPqRsTu', 170 | } 171 | 172 | When you set up a new bot API key, Slack will prompt you to configure the bot's 173 | username. This will be Helga's nickname, and Helga will figure it out 174 | automatically, so do not specify :data:`~helga.settings.NICK` in your 175 | configuration. Similarly, Slack uses the "@ mentions" syntax for addressing 176 | nicks, and the connector has support for this, so you should not set 177 | :data:`~helga.settings.COMMAND_PREFIX_BOTNICK` in your configuration. 178 | 179 | .. _`HipChat`: https://www.hipchat.com/ 180 | .. _`HipChat XMPP Settings`: https://hipchat.com/account/xmpp 181 | .. _`hipchat_nicks`: https://github.com/shaunduncan/helga-hipchat-nicks 182 | .. _`Slack`: https://www.slack.com/ 183 | -------------------------------------------------------------------------------- /docs/source/webhooks.rst: -------------------------------------------------------------------------------- 1 | .. _webhooks: 2 | 3 | Webhooks 4 | ======== 5 | As of helga version 1.3, helga includes support for pluggable webhooks that can interact 6 | with the running bot or communicate via IRC. The webhook architecture is extensible much 7 | in the way that plugins work, allowing you to create new or custom HTTP services. 8 | 9 | 10 | .. _webhooks.overview: 11 | 12 | Overview 13 | -------- 14 | The webhooks system has two important aspects and core concepts: the HTTP server and routes. 15 | 16 | 17 | .. _webhooks.overview.server: 18 | 19 | HTTP Server 20 | ^^^^^^^^^^^ 21 | The webhooks system consists of an HTTP server that is managed by a command plugin named 22 | :ref:`webooks `. This plugin is enabled by default and handles starting the 23 | HTTP server is started when helga successfully signs on to IRC. The server process is configured to 24 | listen on a port specified by the setting :data:`~helga.settings.WEBHOOKS_PORT`. 25 | 26 | The actual implementation of this HTTP server is a combination of a TCP listner using the Twisted 27 | reactor, and ``twisted.web.server.Site`` with a single root resource (see 28 | :class:`~helga.plugins.webhooks.WebhookRoot`) that manages each registered URL route. 29 | 30 | .. note:: 31 | 32 | This server is managed via a plugin only so it can be controlled via IRC. 33 | 34 | 35 | .. _webhooks.overview.routes: 36 | 37 | Routes 38 | ^^^^^^ 39 | Routes are the plugins of the webhook system. They are essentially registered URL paths that have 40 | some programmed behavior. For example, ``http://localhost:8080/github``, or ``/github`` specifically, 41 | might be the registered route for a webhook that announces github code pushes on an IRC channel. 42 | Routes are declared using a decorator (see :ref:`webhooks.creating`), which will feel familiar 43 | to anyone with `flask`_ experience. At this time, routes also support HTTP basic authentication, 44 | which is configurable with a setting :data:`~helga.settings.WEBHOOKS_CREDENTIALS`. 45 | 46 | 47 | .. _webhooks.creating: 48 | 49 | The ``@route`` Decorator 50 | ------------------------ 51 | Much like the plugin system, webhook routes are created using an easy to use decorator API. At 52 | the core of this API is a single decorator :func:`@route `, which 53 | will feel familiar to anyone with `flask`_ experience: 54 | 55 | .. autofunction:: helga.plugins.webhooks.route 56 | :noindex: 57 | 58 | For example:: 59 | 60 | from helga.plugins.webhooks import route 61 | 62 | @route(r'/foo') 63 | def foo(request, client): 64 | client.msg('#foo', 'someone hit the /foo endpoint') 65 | return 'message sent' 66 | 67 | Routes can be configured to also support URL parameters, which act similarly to `django`_'s URL 68 | routing mechanisms. By introducing named pattern groups in the regular expression string. These 69 | will be passed as keyword arguments to the decorated route handler:: 70 | 71 | from helga.plugins.webhooks import route 72 | 73 | @route(r'/foo/(?P[0-9]+)') 74 | def foo(request, client, bar): 75 | client.msg('#foo', 'someone hit the /foo endpoint with bar {0}'.format(bar)) 76 | return 'message sent' 77 | 78 | 79 | .. _webhooks.authentication: 80 | 81 | Authenticated Routes 82 | -------------------- 83 | The webhooks system includes mechanisms for restricting routes to authenticated users. Note, 84 | that this is only supported to handle HTTP basic authentication. Auth credentials are currently 85 | limited to hard-coded username and password pairs configured as a list of two-tuples, the setting 86 | :data:`~helga.settings.WEBHOOKS_CREDENTIALS`. Routes are declared as requiring authentication 87 | using the :func:`@authenticated ` decorator: 88 | 89 | .. autofunction: helga.plugins.webhooks.authenticated 90 | :noindex: 91 | 92 | For example:: 93 | 94 | from helga.plugins.webhooks import authenticated, route 95 | 96 | @route(r'/foo') 97 | @authenticated 98 | def foo(request, client): 99 | client.msg('#foo', 'someone hit the /foo endpoint') 100 | return 'message sent' 101 | 102 | .. important:: 103 | 104 | The :func:`@authenticated ` decorator **must** be the 105 | first decorator used for a route handler, otherwise the authentication check will not happen 106 | prior to a route being handled. This requirement may change in the future. 107 | 108 | 109 | .. _webhooks.http_status: 110 | 111 | Sending Non-200 Responses 112 | ------------------------- 113 | By default, route handlers will send a 200 response to any incoming request. However, in some 114 | cases it may be necessary to explicitly return a non-200 response. In order to accomplish this, 115 | a route handler can manually set the response status code on the request object:: 116 | 117 | from helga.plugins.webhooks import route 118 | 119 | @route(r'/foo') 120 | def foo(request, client): 121 | request.setResponseCode(404) 122 | return 'foo is always 404' 123 | 124 | In addition to this, route handlers can also raise :exc:`helga.plugins.webhooks.HttpError`:: 125 | 126 | from helga.plugins.webhooks import route, HttpError 127 | 128 | @route(r'/foo') 129 | def foo(request, client): 130 | raise HttpError(404, 'foo is always 404') 131 | 132 | 133 | .. _webhooks.templates: 134 | 135 | Using Templates 136 | --------------- 137 | When installed, helga will have `pystache`_ installed as well, which can be used for templating 138 | webhooks that produce HTML responses. It is important though that any webhooks be packaged so that 139 | any external ``.mustache`` templates are packaged as well, which can be done by adding to a 140 | ``MANIFEST.in`` file (see :ref:`webhooks.packaging`):: 141 | 142 | recursive-include . *.mustache 143 | 144 | 145 | .. _webhooks.unicode: 146 | 147 | Handling Unicode 148 | ---------------- 149 | Handling unicode for webhooks is not as strict as with plugins, but the same guidelines should follow. 150 | For example, webhooks should return unicode, but know that unicode strings are explicitly encoded as 151 | UTF-8 byte strings. See the plugin documentation :ref:`plugins.unicode`. 152 | 153 | 154 | .. _webhooks.database: 155 | 156 | Accessing The Database 157 | ---------------------- 158 | Database access for webhooks follows the same rules as for plugins. See the plugin documentation 159 | :ref:`plugins.database` 160 | 161 | 162 | .. _webhooks.settings: 163 | 164 | Requiring Settings 165 | ------------------ 166 | Requiring settings for webhooks follows the same rules as for plugins. See the plugin documentation 167 | :ref:`plugins.settings` 168 | 169 | 170 | .. _webhooks.packaging: 171 | 172 | Packaging and Distribution 173 | -------------------------- 174 | Much like plugins, webhooks are also installable python modules. For that reason, the rules for 175 | packaging and distributing webhooks are the same as with plugins (see plugin :ref:`plugins.packaging`). 176 | However, there is one minor difference with respect to declaring the webhook entry point. Rather 177 | than indicating the webhook as a ``helga_plugins`` entry point, it should be placed in an entry 178 | point section named ``helga_webhooks``. For example:: 179 | 180 | setup( 181 | entry_points=dict( 182 | helga_webhooks=[ 183 | 'api = myapi:decorated_route' 184 | ] 185 | ) 186 | ) 187 | 188 | 189 | .. _webhooks.installing: 190 | 191 | Installing Webhooks 192 | ------------------- 193 | Webhooks are installed in the same manner that plugins are installed (see plugin :ref:`plugins.installing`). 194 | And much like plugins, there are settings to control both a whitelist and blacklist for loading webhook 195 | routes (see :data:`~helga.settings.ENABLED_PLUGINS` and :data:`~helga.settings.DISABLED_PLUGINS`). To 196 | explicitly whitelist webhook routes to be loaded, use :data:`~helga.settings.ENABLED_WEBHOOKS`. To 197 | explicitly blacklist webhook routes from being loaded, use :data:`~helga.settings.DISABLED_WEBHOOKS`. 198 | 199 | 200 | .. _`flask`: http://flask.pocoo.org/ 201 | .. _`django`: https://www.djangoproject.com/ 202 | .. _`pystache`: https://github.com/defunkt/pystache 203 | -------------------------------------------------------------------------------- /helga/tests/webhooks/test_logger.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | 4 | from mock import Mock 5 | 6 | from helga.webhooks import logger 7 | 8 | 9 | class TestIndexView(object): 10 | 11 | def setup(self): 12 | self.view = logger.Index() 13 | 14 | def test_title(self): 15 | assert self.view.title() == 'Channel Logs' 16 | 17 | def test_channels(self, monkeypatch): 18 | monkeypatch.setattr(logger, 'os', Mock()) 19 | logger.os.listdir.return_value = ['#foo', '#bar', '#baz'] 20 | 21 | assert list(self.view.channels()) == ['bar', 'baz', 'foo'] 22 | 23 | def test_channels_empty_for_no_logs(self, monkeypatch): 24 | monkeypatch.setattr(logger, 'os', Mock()) 25 | logger.os.path.isdir.return_value = False 26 | logger.os.listdir.side_effect = OSError 27 | 28 | try: 29 | retval = list(self.view.channels()) 30 | except Exception: 31 | pytest.fail('Should not have raised an Exception') 32 | else: 33 | assert retval == [] 34 | 35 | def test_channels_hides_blacklist(self, monkeypatch): 36 | monkeypatch.setattr(logger, 'os', Mock()) 37 | 38 | # Should handle with or without leading '#' 39 | monkeypatch.setattr(logger, 'settings', Mock( 40 | CHANNEL_LOGGING_HIDE_CHANNELS=['#foo', 'bar'] 41 | )) 42 | 43 | logger.os.listdir.return_value = ['#foo', '#bar', '#baz'] 44 | 45 | assert list(self.view.channels()) == ['baz'] 46 | 47 | 48 | class TestChannelIndexView(object): 49 | 50 | def setup(self): 51 | self.view = logger.ChannelIndex('foo') 52 | 53 | def test_title(self): 54 | assert self.view.title() == '#foo Channel Logs' 55 | 56 | def test_dates(self, monkeypatch): 57 | monkeypatch.setattr(logger, 'os', Mock()) 58 | logger.os.path.isdir.return_value = True 59 | logger.os.listdir.return_value = [ 60 | '2010-12-01.txt', 61 | '2011-12-01.txt', 62 | '2012-12-01.txt', 63 | '2012-10-31.txt', 64 | ] 65 | 66 | assert list(self.view.dates()) == [ 67 | '2012-12-01', 68 | '2012-10-31', 69 | '2011-12-01', 70 | '2010-12-01', 71 | ] 72 | 73 | def test_dates_404(self, monkeypatch): 74 | monkeypatch.setattr(logger, 'os', Mock()) 75 | logger.os.path.isdir.return_value = False 76 | 77 | with pytest.raises(logger.HttpError): 78 | list(self.view.dates()) 79 | 80 | 81 | class TestChannelLogView(object): 82 | 83 | def setup(self): 84 | self.view = logger.ChannelLog('foo', '2014-12-01') 85 | 86 | def test_title(self): 87 | assert self.view.title() == '#foo Channel Logs for 2014-12-01' 88 | 89 | def test_messages_404(self, monkeypatch): 90 | monkeypatch.setattr(logger, 'os', Mock()) 91 | logger.os.path.isfile.return_value = False 92 | 93 | with pytest.raises(logger.HttpError): 94 | list(self.view.messages()) 95 | 96 | def test_messages(self, tmpdir): 97 | logger.settings.CHANNEL_LOGGING_DIR = str(tmpdir) 98 | 99 | # Create tmp file 100 | file = tmpdir.mkdir('#foo').join('2014-12-01.txt') 101 | file.write('\n'.join([ 102 | '00:00:00 - foo - this is what i said', 103 | '12:01:35 - bar - another thing i said', 104 | '16:17:18 - baz - this - has - delimiters', 105 | u'21:22:23 - qux - ☃'.encode('utf-8') 106 | ]), mode='wb') 107 | 108 | assert list(self.view.messages()) == [ 109 | { 110 | 'time': '00:00:00', 111 | 'nick': 'foo', 112 | 'message': 'this is what i said', 113 | }, 114 | { 115 | 'time': '12:01:35', 116 | 'nick': 'bar', 117 | 'message': 'another thing i said', 118 | }, 119 | { 120 | 'time': '16:17:18', 121 | 'nick': 'baz', 122 | 'message': 'this - has - delimiters', 123 | }, 124 | { 125 | 'time': '21:22:23', 126 | 'nick': 'qux', 127 | 'message': u'☃', 128 | }, 129 | ] 130 | 131 | def test_messages_with_multiline_content(self, tmpdir): 132 | logger.settings.CHANNEL_LOGGING_DIR = str(tmpdir) 133 | 134 | # Create tmp file 135 | file = tmpdir.mkdir('#foo').join('2014-12-01.txt') 136 | file.write('\n'.join([ 137 | '00:00:00 - foo - this is what i said', 138 | '...and here', 139 | '...and here again', 140 | u'...☃'.encode('utf-8') 141 | ]), mode='wb') 142 | 143 | assert list(self.view.messages()) == [ 144 | { 145 | 'time': '00:00:00', 146 | 'nick': 'foo', 147 | 'message': u'this is what i said\n...and here\n...and here again\n...☃', 148 | } 149 | ] 150 | 151 | def test_messages_with_unhandled_content(self, tmpdir): 152 | logger.settings.CHANNEL_LOGGING_DIR = str(tmpdir) 153 | 154 | # Create tmp file 155 | file = tmpdir.mkdir('#foo').join('2014-12-01.txt') 156 | file.write("it's lonely here") 157 | 158 | assert list(self.view.messages()) == [ 159 | { 160 | 'time': '', 161 | 'nick': '', 162 | 'message': "it's lonely here", 163 | } 164 | ] 165 | 166 | def test_download(self, tmpdir): 167 | request = Mock() 168 | contents = ('00:00:00 - foo - this is what i said\n' 169 | '12:01:35 - bar - another thing i said\n' 170 | '16:17:18 - baz - this - has - delimiters\n' 171 | u'21:22:23 - qux - ☃'.encode('utf-8')) 172 | 173 | logger.settings.CHANNEL_LOGGING_DIR = str(tmpdir) 174 | 175 | # Create tmp file 176 | file = tmpdir.mkdir('#foo').join('2014-12-01.txt') 177 | file.write(contents, mode='wb') 178 | 179 | assert self.view.download(request) == contents 180 | request.setHeader.assert_any_call('Content-Type', 'text/plain') 181 | request.setHeader.assert_any_call('Content-Disposition', 'attachment; filename=2014-12-01.txt') 182 | 183 | 184 | class TestWebhook(object): 185 | 186 | def setup(self): 187 | self.request = Mock() 188 | logger.settings.CHANNEL_LOGGING = True 189 | 190 | def test_raises_501(self): 191 | logger.settings.CHANNEL_LOGGING = False 192 | with pytest.raises(logger.HttpError): 193 | logger.logger(None, None) 194 | 195 | def _mock_log_dir(self, tmpdir): 196 | # Create tmp file 197 | logger.settings.CHANNEL_LOGGING_DIR = str(tmpdir) 198 | file = tmpdir.mkdir('#foo').join('2014-12-01.txt') 199 | file.write('00:00:00 - foo - this is what i said') 200 | 201 | def test_renders_index(self, tmpdir): 202 | self._mock_log_dir(tmpdir) 203 | response = logger.logger(self.request, None) 204 | self.request.setHeader.assert_called_with('Content-Type', 'text/html') 205 | 206 | # Output asserts 207 | assert 'Channel Logs' in response 208 | assert '#foo' in response 209 | 210 | def test_renders_channel_index(self, tmpdir): 211 | self._mock_log_dir(tmpdir) 212 | response = logger.logger(self.request, None, 'foo') 213 | self.request.setHeader.assert_called_with('Content-Type', 'text/html') 214 | 215 | # Output asserts 216 | assert '#foo Channel Logs' in response 217 | assert '2014-12-01' in response 218 | 219 | def test_renders_channel_log(self, tmpdir): 220 | self._mock_log_dir(tmpdir) 221 | response = logger.logger(self.request, None, 'foo', '2014-12-01') 222 | self.request.setHeader.assert_called_with('Content-Type', 'text/html') 223 | 224 | # Output asserts 225 | assert '#foo Channel Logs for 2014-12-01' in response 226 | assert '00:00:00' in response 227 | assert 'foo' in response 228 | assert '
this is what i said
' in response 229 | 230 | def test_renders_channel_log_as_text(self, tmpdir): 231 | self._mock_log_dir(tmpdir) 232 | response = logger.logger(self.request, None, 'foo', '2014-12-01', as_text=True) 233 | self.request.setHeader.assert_any_call('Content-Type', 'text/plain') 234 | self.request.setHeader.assert_any_call('Content-Disposition', 'attachment; filename=2014-12-01.txt') 235 | 236 | # Output asserts 237 | assert response == '00:00:00 - foo - this is what i said' 238 | -------------------------------------------------------------------------------- /helga/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Default settings and configuration utilities 3 | """ 4 | import os 5 | import sys 6 | 7 | #: Dictionary of connection details. At a minimum this should contain keys 8 | #: ``HOST`` and ``PORT`` which default to 'localhost' and 6667 respectively for irc. 9 | #: Optionally, you can specify a boolean key ``SSL`` if you require helga to 10 | #: connect via SSL. You may also specify keys ``USERNAME`` and ``PASSWORD`` 11 | #: if your server requires authentication. For example:: 12 | #: 13 | #: SERVER = { 14 | #: 'HOST': 'localhost', 15 | #: 'PORT': 6667, 16 | #: 'SSL': False, 17 | #: 'USERNAME': 'user', 18 | #: 'PASSWORD': 'pass', 19 | #: } 20 | #: 21 | #: Additional, optional keys are supported for different chat backends: 22 | #: 23 | #: - ``TYPE``: the backend type to use, 'irc' or 'xmpp' 24 | #: - ``MUC_HOST``: the MUC group chat domain like 'conference.example.com' for group chat 25 | #: - ``JID``: A full jabber ID to use instead of USERNAME (xmpp only) 26 | SERVER = { 27 | 'HOST': 'localhost', 28 | 'PORT': 6667, 29 | 'TYPE': 'irc', 30 | } 31 | 32 | 33 | #: A string for the logging level helga should use for process logging 34 | LOG_LEVEL = 'DEBUG' 35 | 36 | #: A string, if set, a string indicating the log file for python logs. By default helga 37 | #: will log directly to stdout 38 | LOG_FILE = None 39 | 40 | #: A string that is compatible with configuring a python logging formatter. 41 | LOG_FORMAT = '%(asctime)-15s [%(levelname)s] [%(name)s:%(lineno)d]: %(message)s' 42 | 43 | #: Integer value for 'low' priority plugins (see :ref:`plugins.priorities`) 44 | PLUGIN_PRIORITY_LOW = 25 45 | 46 | #: Integer value for 'normal' priority plugins (see :ref:`plugins.priorities`) 47 | PLUGIN_PRIORITY_NORMAL = 50 48 | 49 | #: Integer value for 'high' priority plugins (see :ref:`plugins.priorities`) 50 | PLUGIN_PRIORITY_HIGH = 75 51 | 52 | #: A boolean, if True, will enable conversation logging on all channels 53 | CHANNEL_LOGGING = False 54 | 55 | #: If :data:`CHANNEL_LOGGING` is enabled, this is a string of the directory to which channel logs 56 | #: should be written. 57 | CHANNEL_LOGGING_DIR = '.logs' 58 | 59 | #: A list of channel names (either with or without a '#' prefix) that will be hidden in the 60 | #: browsable channel log web ui. 61 | CHANNEL_LOGGING_HIDE_CHANNELS = [] 62 | 63 | #: The preferred nick of the bot instance. For XMPP clients, this will be used when joining rooms. 64 | NICK = 'helga' 65 | 66 | #: A list of channels to automatically join. You can specify either a single channel name 67 | #: or a two-tuple of channel name, and password. For example:: 68 | #: 69 | #: CHANNELS = [ 70 | #: '#bots', 71 | #: ('#foo', 'password'), 72 | #: ] 73 | #: 74 | #: Note that this setting is only for hardcoded autojoined channels. Helga also responds 75 | #: to /INVITE commands as well offers a builtin plugin to configure autojoin channels at 76 | #: runtime (see :ref:`builtin.plugins.operator`) 77 | #: 78 | #: For XMPP/HipChat support, channel names should either be the full room JID in the form 79 | #: of ``room@host`` or a simple channel name prefixed with a '#' such as ``#room``. Depending 80 | #: on the configuration, the room JID will be constructed using the ``MUC_HOST`` value of the 81 | #: ``SERVER`` setting or by prefixing 'conference.' to the ``HOST`` value. 82 | CHANNELS = [ 83 | ('#bots',), 84 | ] 85 | 86 | #: A boolean indicating if the bot automatically reconnect on connection lost 87 | AUTO_RECONNECT = True 88 | 89 | #: An integer for the time, in seconds, to delay between reconnect attempts 90 | AUTO_RECONNECT_DELAY = 5 91 | 92 | #: IRC Only. An integer indicating the rate limit, in seconds, for messages sent over IRC. 93 | #: This may help to prevent flood, but may degrade the performance of the bot, as it applies 94 | #: to every message sent to IRC. 95 | RATE_LIMIT = None 96 | 97 | #: A list of chat nicks that should be considered operators/administrators 98 | OPERATORS = [] 99 | 100 | #: A dictionary containing connection info for MongoDB. The minimum settings that should 101 | #: exist here are 'HOST', the MongoDB host, 'PORT, the MongoDB port, and 'DB' which should be the 102 | #: MongoDB collection to use. These values default to 'localhost', 27017, and 'helga' respectively. 103 | #: Both 'USERNAME' and 'PASSWORD' can be specified if MongoDB requires authentication. For example:: 104 | #: 105 | #: DATABASE = { 106 | #: 'HOST': 'localhost', 107 | #: 'PORT': 27017, 108 | #: 'DB': 'helga', 109 | #: 'USERNAME': 'foo', 110 | #: 'PASSWORD': 'bar', 111 | #: } 112 | DATABASE = { 113 | 'HOST': 'localhost', 114 | 'PORT': 27017, 115 | 'DB': 'helga', 116 | } 117 | 118 | #: A list of plugin names that should be loaded by the plugin manager. This effectively serves 119 | #: as a mechanism for explicitly including plugins that have been installed on the system. 120 | #: If this value is True, the plugin manager will load any plugin configured with an entry 121 | #: point and make it available for use. If it is None, or an empty list, no plugins will be loaded. 122 | #: See :ref:`plugins` for more information. 123 | ENABLED_PLUGINS = True 124 | 125 | #: A list of plugin names that should NOT be loaded by the plugin manager. This effectively serves 126 | #: as a mechanism for explicitly excluding plugins that have been installed on the system. 127 | #: If this value is True, the plugin manager will NOT load any plugin configured with an entry 128 | #: point. If it is None, or an empty list, no plugins will be blacklisted. 129 | #: See :ref:`plugins` for more information. 130 | DISABLED_PLUGINS = [] 131 | 132 | #: A list of plugin names that should be enabled automatically for any channel. If this value 133 | #: is True, all plugins installed will be enabled by default. If this value is None, or an empty 134 | #: list, no plugins will be enabled on channels by default. See :ref:`plugins` for more information. 135 | DEFAULT_CHANNEL_PLUGINS = True 136 | 137 | #: A list of whitelisted webhook names that should be loaded and enabled on process startup. If this value 138 | #: is True, then all webhooks available are loaded and made available. An empty list or None implies 139 | #: that no webhooks will be made available. See :ref:`webhooks` for more details. 140 | ENABLED_WEBHOOKS = True 141 | 142 | #: A list of blacklisted webhook names that should NOT be loaded and enabled on process startup. If this value 143 | #: is True, then all webhooks available are loaded and made available. An empty list or None implies 144 | #: that no webhooks will be made available. See :ref:`webhooks` for more details. 145 | DISABLED_WEBHOOKS = None 146 | 147 | #: A boolean, if True, the first response received from a plugin will be the only message 148 | #: sent back to the chat server. If False, all responses are sent. 149 | PLUGIN_FIRST_RESPONDER_ONLY = True 150 | 151 | #: If a boolean and True, command plugins can be run by asking directly, such as 'helga foo_command'. 152 | #: This can also be a string for specifically setting a nick type prefix (such as @NickName for HipChat) 153 | COMMAND_PREFIX_BOTNICK = True 154 | 155 | #: A string char, if non-empty, that can be used to invoke a command without requiring the bot's nick. 156 | #: For example 'helga foo' could be run with '!foo'. 157 | COMMAND_PREFIX_CHAR = '!' 158 | 159 | #: A boolean that controls the behavior of argument parsing for command plugins. If False, 160 | #: command plugin arguments are parsed using a naive whitespace split. If True, they will 161 | #: be parsed using `shlex.split`. See :ref:`plugins.creating.commands` for more information. 162 | #: The default is False, but this shlex parsing will be the only supported means of argument 163 | #: string parsing in a future version. 164 | COMMAND_ARGS_SHLEX = False 165 | 166 | #: A boolean on whether commands should be treated with case insensitivity. For example, 167 | #: a command 'foo' will respond to 'FOO', 'Foo', 'foo', etc. 168 | COMMAND_IGNORECASE = False 169 | 170 | #: The integer port the webhooks plugin should listen for http requests. 171 | WEBHOOKS_PORT = 8080 172 | 173 | #: List of two-tuple username and passwords used for http webhook basic authentication 174 | WEBHOOKS_CREDENTIALS = [] # Tuples of (user, pass) 175 | 176 | 177 | def configure(overrides): 178 | """ 179 | Applies custom configuration to global helga settings. Overrides can either be 180 | a python import path string like 'foo.bar.baz' or a filesystem path like 181 | 'foo/bar/baz.py' 182 | 183 | :param overrides: an importable python path string like 'foo.bar' or a filesystem path 184 | to a python file like 'foo/bar.py' 185 | """ 186 | this = sys.modules[__name__] 187 | 188 | # Filesystem path to settings file 189 | if os.path.isfile(overrides): 190 | execfile(overrides, this.__dict__) 191 | return 192 | 193 | # Module import path settings file 194 | fromlist = [overrides.split('.')[-1]] 195 | overrides = __import__(overrides, this.__dict__, {}, fromlist) 196 | 197 | for attr in filter(lambda x: not x.startswith('_'), dir(overrides)): 198 | setattr(this, attr, getattr(overrides, attr)) 199 | -------------------------------------------------------------------------------- /.irssi/config: -------------------------------------------------------------------------------- 1 | servers = ( 2 | { 3 | address = "127.0.0.1"; 4 | chatnet = "vagrant"; 5 | port = "6667"; 6 | autoconnect = "yes"; 7 | } 8 | ); 9 | 10 | chatnets = { 11 | vagrant = { 12 | type = "IRC"; 13 | nick = "testuser"; 14 | username = "testuser"; 15 | realname = "Test User"; 16 | }; 17 | }; 18 | 19 | channels = ( 20 | { name = "#bots"; chatnet = "vagrant"; autojoin = "yes"; } 21 | ); 22 | 23 | aliases = { 24 | ATAG = "WINDOW SERVER"; 25 | ADDALLCHANS = "SCRIPT EXEC foreach my \\$channel (Irssi::channels()) { Irssi::command(\"CHANNEL ADD -auto \\$channel->{name} \\$channel->{server}->{tag} \\$channel->{key}\")\\;}"; 26 | B = "BAN"; 27 | BACK = "AWAY"; 28 | BANS = "BAN"; 29 | BYE = "QUIT"; 30 | C = "CLEAR"; 31 | CALC = "EXEC - if command -v bc >/dev/null 2>&1\\; then printf '%s=' '$*'\\; echo '$*' | bc -l\\; else echo bc was not found\\; fi"; 32 | CHAT = "DCC CHAT"; 33 | CUBES = "SCRIPT EXEC Irssi::active_win->print(\"%_bases\", MSGLEVEL_CLIENTCRAP) \\; Irssi::active_win->print( do { join '', map { \"%x0\\${_}0\\$_\" } '0'..'9','A'..'F' }, MSGLEVEL_NEVER | MSGLEVEL_CLIENTCRAP) \\; Irssi::active_win->print(\"%_cubes\", MSGLEVEL_CLIENTCRAP) \\; Irssi::active_win->print( do { my \\$y = \\$_*6 \\; join '', map { my \\$x = \\$_ \\; map { \"%x\\$x\\$_\\$x\\$_\" } @{['0'..'9','A'..'Z']}[\\$y .. \\$y+5] } 1..6 }, MSGLEVEL_NEVER | MSGLEVEL_CLIENTCRAP) for 0..5 \\; Irssi::active_win->print(\"%_grays\", MSGLEVEL_CLIENTCRAP) \\; Irssi::active_win->print( do { join '', map { \"%x7\\${_}7\\$_\" } 'A'..'X' }, MSGLEVEL_NEVER | MSGLEVEL_CLIENTCRAP) \\; Irssi::active_win->print(\"%_mIRC extended colours\", MSGLEVEL_CLIENTCRAP) \\; my \\$x \\; \\$x .= sprintf \"\00399,%02d%02d\",\\$_,\\$_ for 0..15 \\; Irssi::active_win->print(\\$x, MSGLEVEL_NEVER | MSGLEVEL_CLIENTCRAP) \\; for my \\$z (0..6) { my \\$x \\; \\$x .= sprintf \"\00399,%02d%02d\",\\$_,\\$_ for 16+(\\$z*12)..16+(\\$z*12)+11 \\; Irssi::active_win->print(\\$x, MSGLEVEL_NEVER | MSGLEVEL_CLIENTCRAP) }"; 34 | DATE = "TIME"; 35 | DEHIGHLIGHT = "DEHILIGHT"; 36 | DESCRIBE = "ACTION"; 37 | DHL = "DEHILIGHT"; 38 | EXEMPTLIST = "MODE $C +e"; 39 | EXIT = "QUIT"; 40 | GOTO = "SCROLLBACK GOTO"; 41 | HIGHLIGHT = "HILIGHT"; 42 | HL = "HILIGHT"; 43 | HOST = "USERHOST"; 44 | INVITELIST = "MODE $C +I"; 45 | J = "JOIN"; 46 | K = "KICK"; 47 | KB = "KICKBAN"; 48 | KN = "KNOCKOUT"; 49 | LAST = "LASTLOG"; 50 | LEAVE = "PART"; 51 | M = "MSG"; 52 | MUB = "UNBAN *"; 53 | N = "NAMES"; 54 | NMSG = "^MSG"; 55 | P = "PART"; 56 | Q = "QUERY"; 57 | RESET = "SET -default"; 58 | RUN = "SCRIPT LOAD"; 59 | SAY = "MSG *"; 60 | SB = "SCROLLBACK"; 61 | SBAR = "STATUSBAR"; 62 | SIGNOFF = "QUIT"; 63 | SV = "MSG * Irssi $J ($V) - http://www.irssi.org"; 64 | T = "TOPIC"; 65 | UB = "UNBAN"; 66 | UMODE = "MODE $N"; 67 | UNSET = "SET -clear"; 68 | W = "WHO"; 69 | WC = "WINDOW CLOSE"; 70 | WG = "WINDOW GOTO"; 71 | WJOIN = "JOIN -window"; 72 | WI = "WHOIS"; 73 | WII = "WHOIS $0 $0"; 74 | WL = "WINDOW LIST"; 75 | WN = "WINDOW NEW HIDDEN"; 76 | WQUERY = "QUERY -window"; 77 | WW = "WHOWAS"; 78 | 1 = "WINDOW GOTO 1"; 79 | 2 = "WINDOW GOTO 2"; 80 | 3 = "WINDOW GOTO 3"; 81 | 4 = "WINDOW GOTO 4"; 82 | 5 = "WINDOW GOTO 5"; 83 | 6 = "WINDOW GOTO 6"; 84 | 7 = "WINDOW GOTO 7"; 85 | 8 = "WINDOW GOTO 8"; 86 | 9 = "WINDOW GOTO 9"; 87 | 10 = "WINDOW GOTO 10"; 88 | 11 = "WINDOW GOTO 11"; 89 | 12 = "WINDOW GOTO 12"; 90 | 13 = "WINDOW GOTO 13"; 91 | 14 = "WINDOW GOTO 14"; 92 | 15 = "WINDOW GOTO 15"; 93 | 16 = "WINDOW GOTO 16"; 94 | 17 = "WINDOW GOTO 17"; 95 | 18 = "WINDOW GOTO 18"; 96 | 19 = "WINDOW GOTO 19"; 97 | 20 = "WINDOW GOTO 20"; 98 | 21 = "WINDOW GOTO 21"; 99 | 22 = "WINDOW GOTO 22"; 100 | 23 = "WINDOW GOTO 23"; 101 | 24 = "WINDOW GOTO 24"; 102 | 25 = "WINDOW GOTO 25"; 103 | 26 = "WINDOW GOTO 26"; 104 | 27 = "WINDOW GOTO 27"; 105 | 28 = "WINDOW GOTO 28"; 106 | 29 = "WINDOW GOTO 29"; 107 | 30 = "WINDOW GOTO 30"; 108 | 31 = "WINDOW GOTO 31"; 109 | 32 = "WINDOW GOTO 32"; 110 | 33 = "WINDOW GOTO 33"; 111 | 34 = "WINDOW GOTO 34"; 112 | 35 = "WINDOW GOTO 35"; 113 | 36 = "WINDOW GOTO 36"; 114 | 37 = "WINDOW GOTO 37"; 115 | 38 = "WINDOW GOTO 38"; 116 | 39 = "WINDOW GOTO 39"; 117 | 40 = "WINDOW GOTO 40"; 118 | 41 = "WINDOW GOTO 41"; 119 | 42 = "WINDOW GOTO 42"; 120 | 43 = "WINDOW GOTO 43"; 121 | 44 = "WINDOW GOTO 44"; 122 | 45 = "WINDOW GOTO 45"; 123 | 46 = "WINDOW GOTO 46"; 124 | 47 = "WINDOW GOTO 47"; 125 | 48 = "WINDOW GOTO 48"; 126 | 49 = "WINDOW GOTO 49"; 127 | 50 = "WINDOW GOTO 50"; 128 | 51 = "WINDOW GOTO 51"; 129 | 52 = "WINDOW GOTO 52"; 130 | 53 = "WINDOW GOTO 53"; 131 | 54 = "WINDOW GOTO 54"; 132 | 55 = "WINDOW GOTO 55"; 133 | 56 = "WINDOW GOTO 56"; 134 | 57 = "WINDOW GOTO 57"; 135 | 58 = "WINDOW GOTO 58"; 136 | 59 = "WINDOW GOTO 59"; 137 | 60 = "WINDOW GOTO 60"; 138 | 61 = "WINDOW GOTO 61"; 139 | 62 = "WINDOW GOTO 62"; 140 | 63 = "WINDOW GOTO 63"; 141 | 64 = "WINDOW GOTO 64"; 142 | 65 = "WINDOW GOTO 65"; 143 | 66 = "WINDOW GOTO 66"; 144 | 67 = "WINDOW GOTO 67"; 145 | 68 = "WINDOW GOTO 68"; 146 | 69 = "WINDOW GOTO 69"; 147 | 70 = "WINDOW GOTO 70"; 148 | 71 = "WINDOW GOTO 71"; 149 | 72 = "WINDOW GOTO 72"; 150 | 73 = "WINDOW GOTO 73"; 151 | 74 = "WINDOW GOTO 74"; 152 | 75 = "WINDOW GOTO 75"; 153 | 76 = "WINDOW GOTO 76"; 154 | 77 = "WINDOW GOTO 77"; 155 | 78 = "WINDOW GOTO 78"; 156 | 79 = "WINDOW GOTO 79"; 157 | 80 = "WINDOW GOTO 80"; 158 | 81 = "WINDOW GOTO 81"; 159 | 82 = "WINDOW GOTO 82"; 160 | 83 = "WINDOW GOTO 83"; 161 | 84 = "WINDOW GOTO 84"; 162 | 85 = "WINDOW GOTO 85"; 163 | 86 = "WINDOW GOTO 86"; 164 | 87 = "WINDOW GOTO 87"; 165 | 88 = "WINDOW GOTO 88"; 166 | 89 = "WINDOW GOTO 89"; 167 | 90 = "WINDOW GOTO 90"; 168 | 91 = "WINDOW GOTO 91"; 169 | 92 = "WINDOW GOTO 92"; 170 | 93 = "WINDOW GOTO 93"; 171 | 94 = "WINDOW GOTO 94"; 172 | 95 = "WINDOW GOTO 95"; 173 | 96 = "WINDOW GOTO 96"; 174 | 97 = "WINDOW GOTO 97"; 175 | 98 = "WINDOW GOTO 98"; 176 | 99 = "WINDOW GOTO 99"; 177 | }; 178 | 179 | statusbar = { 180 | 181 | items = { 182 | 183 | barstart = "{sbstart}"; 184 | barend = "{sbend}"; 185 | 186 | topicbarstart = "{topicsbstart}"; 187 | topicbarend = "{topicsbend}"; 188 | 189 | time = "{sb $Z}"; 190 | user = "{sb {sbnickmode $cumode}$N{sbmode $usermode}{sbaway $A}}"; 191 | 192 | window = "{sb $winref:$tag/$itemname{sbmode $M}}"; 193 | window_empty = "{sb $winref{sbservertag $tag}}"; 194 | 195 | prompt = "{prompt $[.15]itemname}"; 196 | prompt_empty = "{prompt $winname}"; 197 | 198 | topic = " $topic"; 199 | topic_empty = " Irssi v$J - http://www.irssi.org"; 200 | 201 | lag = "{sb Lag: $0-}"; 202 | act = "{sb Act: $0-}"; 203 | more = "-- more --"; 204 | }; 205 | 206 | default = { 207 | 208 | window = { 209 | 210 | disabled = "no"; 211 | type = "window"; 212 | placement = "bottom"; 213 | position = "1"; 214 | visible = "active"; 215 | 216 | items = { 217 | barstart = { priority = "100"; }; 218 | time = { }; 219 | user = { }; 220 | window = { }; 221 | window_empty = { }; 222 | lag = { priority = "-1"; }; 223 | act = { priority = "10"; }; 224 | more = { priority = "-1"; alignment = "right"; }; 225 | barend = { priority = "100"; alignment = "right"; }; 226 | }; 227 | }; 228 | 229 | window_inact = { 230 | 231 | type = "window"; 232 | placement = "bottom"; 233 | position = "1"; 234 | visible = "inactive"; 235 | 236 | items = { 237 | barstart = { priority = "100"; }; 238 | window = { }; 239 | window_empty = { }; 240 | more = { priority = "-1"; alignment = "right"; }; 241 | barend = { priority = "100"; alignment = "right"; }; 242 | }; 243 | }; 244 | 245 | prompt = { 246 | 247 | type = "root"; 248 | placement = "bottom"; 249 | position = "100"; 250 | visible = "always"; 251 | 252 | items = { 253 | prompt = { priority = "-1"; }; 254 | prompt_empty = { priority = "-1"; }; 255 | input = { priority = "10"; }; 256 | }; 257 | }; 258 | 259 | topic = { 260 | 261 | type = "root"; 262 | placement = "top"; 263 | position = "1"; 264 | visible = "always"; 265 | 266 | items = { 267 | topicbarstart = { priority = "100"; }; 268 | topic = { }; 269 | topic_empty = { }; 270 | topicbarend = { priority = "100"; alignment = "right"; }; 271 | }; 272 | }; 273 | }; 274 | }; 275 | settings = { 276 | core = { 277 | real_name = "Test User"; 278 | user_name = "testuser"; 279 | nick = "testuser"; 280 | }; 281 | "fe-text" = { actlist_sort = "refnum"; }; 282 | }; 283 | windows = { 284 | 1 = { 285 | immortal = "yes"; 286 | name = "(status)"; 287 | level = "ALL"; 288 | sticky = "yes"; 289 | parent = "2"; 290 | }; 291 | 2 = { 292 | items = ( 293 | { 294 | type = "CHANNEL"; 295 | chat_type = "IRC"; 296 | name = "#bots"; 297 | tag = "vagrant"; 298 | } 299 | ); 300 | sticky = "yes"; 301 | }; 302 | }; 303 | mainwindows = { 2 = { first_line = "1"; lines = "77"; }; }; 304 | -------------------------------------------------------------------------------- /.irssi/default.theme: -------------------------------------------------------------------------------- 1 | # When testing changes, the easiest way to reload the theme is with /RELOAD. 2 | # This reloads the configuration file too, so if you did any changes remember 3 | # to /SAVE it first. Remember also that /SAVE overwrites the theme file with 4 | # old data so keep backups :) 5 | 6 | ################################## 7 | # WARINING - WARNING - WARNING # 8 | # this file is managed by Puppet # 9 | ################################## 10 | 11 | # TEMPLATES: 12 | 13 | # The real text formats that irssi uses are the ones you can find with 14 | # /FORMAT command. Back in the old days all the colors and texts were mixed 15 | # up in those formats, and it was really hard to change the colors since you 16 | # might have had to change them in tens of different places. So, then came 17 | # this templating system. 18 | 19 | # Now the /FORMATs don't have any colors in them, and they also have very 20 | # little other styling. Most of the stuff you need to change is in this 21 | # theme file. If you can't change something here, you can always go back 22 | # to change the /FORMATs directly, they're also saved in these .theme files. 23 | 24 | # So .. the templates. They're those {blahblah} parts you see all over the 25 | # /FORMATs and here. Their usage is simply {name parameter1 parameter2}. 26 | # When irssi sees this kind of text, it goes to find "name" from abstracts 27 | # block below and sets "parameter1" into $0 and "parameter2" into $1 (you 28 | # can have more parameters of course). Templates can have subtemplates. 29 | # Here's a small example: 30 | # /FORMAT format hello {colorify {underline world}} 31 | # abstracts = { colorify = "%G$0-%n"; underline = "%U$0-%U"; } 32 | # When irssi expands the templates in "format", the final string would be: 33 | # hello %G%Uworld%U%n 34 | # ie. underlined bright green "world" text. 35 | # and why "$0-", why not "$0"? $0 would only mean the first parameter, 36 | # $0- means all the parameters. With {underline hello world} you'd really 37 | # want to underline both of the words, not just the hello (and world would 38 | # actually be removed entirely). 39 | 40 | # COLORS: 41 | 42 | # You can find definitions for the color format codes in docs/formats.txt. 43 | 44 | # There's one difference here though. %n format. Normally it means the 45 | # default color of the terminal (white mostly), but here it means the 46 | # "reset color back to the one it was in higher template". For example 47 | # if there was /FORMAT test %g{foo}bar, and foo = "%Y$0%n", irssi would 48 | # print yellow "foo" (as set with %Y) but "bar" would be green, which was 49 | # set at the beginning before the {foo} template. If there wasn't the %g 50 | # at start, the normal behaviour of %n would occur. If you _really_ want 51 | # to use the terminal's default color, use %N. 52 | 53 | ############################################################################# 54 | 55 | # default foreground color (%N) - -1 is the "default terminal color" 56 | default_color = "-1"; 57 | 58 | # print timestamp/servertag at the end of line, not at beginning 59 | info_eol = "false"; 60 | 61 | # these characters are automatically replaced with specified color 62 | # (dark grey by default) 63 | replaces = { "[]=" = "%K$*%n"; }; 64 | 65 | abstracts = { 66 | ## 67 | ## generic 68 | ## 69 | 70 | # text to insert at the beginning of each non-message line 71 | line_start = "%B-%n!%B-%n "; 72 | 73 | # timestamp styling, nothing by default 74 | timestamp = "$*"; 75 | 76 | # any kind of text that needs hilighting, default is to bold 77 | hilight = "%_$*%_"; 78 | 79 | # any kind of error message, default is bright red 80 | error = "%R$*%n"; 81 | 82 | # channel name is printed 83 | channel = "%_$*%_"; 84 | 85 | # nick is printed 86 | nick = "%_$*%_"; 87 | 88 | # nick host is printed 89 | nickhost = "[$*]"; 90 | 91 | # server name is printed 92 | server = "%_$*%_"; 93 | 94 | # some kind of comment is printed 95 | comment = "[$*]"; 96 | 97 | # reason for something is printed (part, quit, kick, ..) 98 | reason = "{comment $*}"; 99 | 100 | # mode change is printed ([+o nick]) 101 | mode = "{comment $*}"; 102 | 103 | ## 104 | ## channel specific messages 105 | ## 106 | 107 | # highlighted nick/host is printed (joins) 108 | channick_hilight = "%C$*%n"; 109 | chanhost_hilight = "{nickhost %c$*%n}"; 110 | 111 | # nick/host is printed (parts, quits, etc.) 112 | channick = "%c$*%n"; 113 | chanhost = "{nickhost $*}"; 114 | 115 | # highlighted channel name is printed 116 | channelhilight = "%c$*%n"; 117 | 118 | # ban/ban exception/invite list mask is printed 119 | ban = "%c$*%n"; 120 | 121 | ## 122 | ## messages 123 | ## 124 | 125 | # the basic styling of how to print message, $0 = nick mode, $1 = nick 126 | msgnick = "%K<%n$0$1-%K>%n %|"; 127 | 128 | # message from you is printed. "msgownnick" specifies the styling of the 129 | # nick ($0 part in msgnick) and "ownmsgnick" specifies the styling of the 130 | # whole line. 131 | 132 | # Example1: You want the message text to be green: 133 | # ownmsgnick = "{msgnick $0 $1-}%g"; 134 | # Example2.1: You want < and > chars to be yellow: 135 | # ownmsgnick = "%Y{msgnick $0 $1-%Y}%n"; 136 | # (you'll also have to remove <> from replaces list above) 137 | # Example2.2: But you still want to keep <> grey for other messages: 138 | # pubmsgnick = "%K{msgnick $0 $1-%K}%n"; 139 | # pubmsgmenick = "%K{msgnick $0 $1-%K}%n"; 140 | # pubmsghinick = "%K{msgnick $1 $0$2-%n%K}%n"; 141 | # ownprivmsgnick = "%K{msgnick $*%K}%n"; 142 | # privmsgnick = "%K{msgnick %R$*%K}%n"; 143 | 144 | # $0 = nick mode, $1 = nick 145 | ownmsgnick = "{msgnick $0 $1-}"; 146 | ownnick = "%_$*%n"; 147 | 148 | # public message in channel, $0 = nick mode, $1 = nick 149 | pubmsgnick = "{msgnick $0 $1-}"; 150 | pubnick = "%N$*%n"; 151 | 152 | # public message in channel meant for me, $0 = nick mode, $1 = nick 153 | pubmsgmenick = "{msgnick $0 $1-}"; 154 | menick = "%Y$*%n"; 155 | 156 | # public highlighted message in channel 157 | # $0 = highlight color, $1 = nick mode, $2 = nick 158 | pubmsghinick = "{msgnick $1 $0$2-%n}"; 159 | 160 | # channel name is printed with message 161 | msgchannel = "%K:%c$*%n"; 162 | 163 | # private message, $0 = nick, $1 = host 164 | privmsg = "[%R$0%K(%r$1-%K)%n] "; 165 | 166 | # private message from you, $0 = "msg", $1 = target nick 167 | ownprivmsg = "[%r$0%K(%R$1-%K)%n] "; 168 | 169 | # own private message in query 170 | ownprivmsgnick = "{msgnick $*}"; 171 | ownprivnick = "%_$*%n"; 172 | 173 | # private message in query 174 | privmsgnick = "{msgnick %R$*%n}"; 175 | 176 | ## 177 | ## Actions (/ME stuff) 178 | ## 179 | 180 | # used internally by this theme 181 | action_core = "%_ * $*%n"; 182 | 183 | # generic one that's used by most actions 184 | action = "{action_core $*} "; 185 | 186 | # own action, both private/public 187 | ownaction = "{action $*}"; 188 | 189 | # own action with target, both private/public 190 | ownaction_target = "{action_core $0}%K:%c$1%n "; 191 | 192 | # private action sent by others 193 | pvtaction = "%_ (*) $*%n "; 194 | pvtaction_query = "{action $*}"; 195 | 196 | # public action sent by others 197 | pubaction = "{action $*}"; 198 | 199 | 200 | ## 201 | ## other IRC events 202 | ## 203 | 204 | # whois 205 | whois = "%# $[8]0 : $1-"; 206 | 207 | # notices 208 | ownnotice = "[%r$0%K(%R$1-%K)]%n "; 209 | notice = "%K-%M$*%K-%n "; 210 | pubnotice_channel = "%K:%m$*"; 211 | pvtnotice_host = "%K(%m$*%K)"; 212 | servernotice = "%g!$*%n "; 213 | 214 | # CTCPs 215 | ownctcp = "[%r$0%K(%R$1-%K)] "; 216 | ctcp = "%g$*%n"; 217 | 218 | # wallops 219 | wallop = "%_$*%n: "; 220 | wallop_nick = "%n$*"; 221 | wallop_action = "%_ * $*%n "; 222 | 223 | # netsplits 224 | netsplit = "%R$*%n"; 225 | netjoin = "%C$*%n"; 226 | 227 | # /names list 228 | names_prefix = ""; 229 | names_nick = "[%_$0%_$1-] "; 230 | names_nick_op = "{names_nick $*}"; 231 | names_nick_halfop = "{names_nick $*}"; 232 | names_nick_voice = "{names_nick $*}"; 233 | names_users = "[%g$*%n]"; 234 | names_channel = "%G$*%n"; 235 | 236 | # DCC 237 | dcc = "%g$*%n"; 238 | dccfile = "%_$*%_"; 239 | 240 | # DCC chat, own msg/action 241 | dccownmsg = "[%r$0%K($1-%K)%n] "; 242 | dccownnick = "%R$*%n"; 243 | dccownquerynick = "%_$*%n"; 244 | dccownaction = "{action $*}"; 245 | dccownaction_target = "{action_core $0}%K:%c$1%n "; 246 | 247 | # DCC chat, others 248 | dccmsg = "[%G$1-%K(%g$0%K)%n] "; 249 | dccquerynick = "%G$*%n"; 250 | dccaction = "%_ (*dcc*) $*%n %|"; 251 | 252 | ## 253 | ## statusbar 254 | ## 255 | 256 | # default background for all statusbars. You can also give 257 | # the default foreground color for statusbar items. 258 | sb_background = "%4%w"; 259 | 260 | # default backround for "default" statusbar group 261 | #sb_default_bg = "%4"; 262 | # background for prompt / input line 263 | sb_prompt_bg = "%n"; 264 | # background for info statusbar 265 | sb_info_bg = "%8"; 266 | # background for topicbar (same default) 267 | #sb_topic_bg = "%4"; 268 | 269 | # text at the beginning of statusbars. sb-item already puts 270 | # space there,so we don't use anything by default. 271 | sbstart = ""; 272 | # text at the end of statusbars. Use space so that it's never 273 | # used for anything. 274 | sbend = " "; 275 | 276 | topicsbstart = "{sbstart $*}"; 277 | topicsbend = "{sbend $*}"; 278 | 279 | prompt = "[$*] "; 280 | 281 | sb = " %c[%n$*%c]%n"; 282 | sbmode = "(%c+%n$*)"; 283 | sbaway = " (%GzZzZ%n)"; 284 | sbservertag = ":$0 (change with ^X)"; 285 | sbnickmode = "$0"; 286 | 287 | # activity in statusbar 288 | 289 | # ',' separator 290 | sb_act_sep = "%c$*"; 291 | # normal text 292 | sb_act_text = "%c$*"; 293 | # public message 294 | sb_act_msg = "%W$*"; 295 | # hilight 296 | sb_act_hilight = "%M$*"; 297 | # hilight with specified color, $0 = color, $1 = text 298 | sb_act_hilight_color = "$0$1-%n"; 299 | }; 300 | -------------------------------------------------------------------------------- /helga/tests/comm/test_irc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | import re 3 | 4 | from mock import Mock, call, patch 5 | from unittest import TestCase 6 | 7 | from helga.comm import irc 8 | 9 | 10 | class FactoryTestCase(TestCase): 11 | 12 | def setUp(self): 13 | self.factory = irc.Factory() 14 | 15 | def test_build_protocol(self): 16 | client = self.factory.buildProtocol('address') 17 | assert client.factory == self.factory 18 | 19 | @patch('helga.comm.irc.settings') 20 | @patch('helga.comm.irc.reactor') 21 | def test_client_connection_lost_retries(self, reactor, settings): 22 | settings.AUTO_RECONNECT = True 23 | settings.AUTO_RECONNECT_DELAY = 1 24 | connector = Mock() 25 | self.factory.clientConnectionLost(connector, Exception) 26 | reactor.callLater.assert_called_with(1, connector.connect) 27 | 28 | @patch('helga.comm.irc.settings') 29 | def test_client_connection_lost_raises(self, settings): 30 | settings.AUTO_RECONNECT = False 31 | connector = Mock() 32 | self.assertRaises(Exception, self.factory.clientConnectionLost, connector, Exception) 33 | 34 | @patch('helga.comm.irc.settings') 35 | @patch('helga.comm.irc.reactor') 36 | def test_client_connection_failed(self, reactor, settings): 37 | settings.AUTO_RECONNECT = False 38 | self.factory.clientConnectionFailed(Mock(), reactor) 39 | assert reactor.stop.called 40 | 41 | @patch('helga.comm.irc.settings') 42 | @patch('helga.comm.irc.reactor') 43 | def test_client_connection_failed_retries(self, reactor, settings): 44 | settings.AUTO_RECONNECT = True 45 | settings.AUTO_RECONNECT_DELAY = 1 46 | connector = Mock() 47 | self.factory.clientConnectionFailed(connector, reactor) 48 | reactor.callLater.assert_called_with(1, connector.connect) 49 | 50 | 51 | class ClientTestCase(TestCase): 52 | 53 | def setUp(self): 54 | self.client = irc.Client() 55 | 56 | def test_parse_nick(self): 57 | nick = self.client.parse_nick('foo!~foobar@localhost') 58 | assert nick == 'foo' 59 | 60 | def test_parse_nick_unicode(self): 61 | nick = self.client.parse_nick(u'☃!~foobar@localhost') 62 | assert nick == u'☃' 63 | 64 | @patch('helga.comm.irc.irc.IRCClient') 65 | def test_me_converts_from_unicode(self, irc): 66 | snowman = u'☃' 67 | bytes = '\xe2\x98\x83' 68 | self.client.me('#foo', snowman) 69 | irc.describe.assert_called_with(self.client, '#foo', bytes) 70 | 71 | @patch('helga.comm.irc.irc.IRCClient') 72 | def test_msg_sends_byte_string(self, irc): 73 | snowman = u'☃' 74 | bytes = '\xe2\x98\x83' 75 | 76 | self.client.msg('#foo', snowman) 77 | irc.msg.assert_called_with(self.client, '#foo', bytes) 78 | 79 | def test_alterCollidedNick(self): 80 | self.client.alterCollidedNick('foo') 81 | assert re.match(r'^foo_[\d]+$', self.client.nickname) 82 | 83 | # Should take the first part up to '_' 84 | self.client.alterCollidedNick('foo_bar') 85 | assert re.match(r'^foo_[\d]+$', self.client.nickname) 86 | 87 | def test_erroneousNickFallback(self): 88 | assert re.match(r'^helga_[\d]+$', self.client.erroneousNickFallback) 89 | 90 | @patch('helga.comm.irc.settings') 91 | @patch('helga.comm.irc.smokesignal') 92 | def test_signedOn(self, signal, settings): 93 | snowman = u'☃' 94 | 95 | settings.CHANNELS = [ 96 | ('#bots',), 97 | ('#foo', 'bar'), 98 | (u'#baz', snowman), # Handles unicode gracefully? 99 | ['#a', 'b'], # As a list 100 | '#test', # Single channel 101 | ] 102 | 103 | with patch.object(self.client, 'join') as join: 104 | self.client.signedOn() 105 | assert join.call_args_list == [ 106 | call('#bots'), 107 | call('#foo', 'bar'), 108 | call('#baz', snowman), 109 | call('#a', 'b'), 110 | call('#test'), 111 | ] 112 | 113 | @patch('helga.comm.irc.settings') 114 | @patch('helga.comm.irc.smokesignal') 115 | def test_signedOn_sends_signal(self, signal, settings): 116 | settings.CHANNELS = [] 117 | self.client.signedOn() 118 | signal.emit.assert_called_with('signon', self.client) 119 | 120 | @patch('helga.comm.irc.registry') 121 | def test_privmsg_sends_single_string(self, registry): 122 | self.client.msg = Mock() 123 | registry.process.return_value = ['line1', 'line2'] 124 | 125 | self.client.privmsg('foo!~bar@baz', '#bots', 'this is the input') 126 | 127 | args = self.client.msg.call_args[0] 128 | assert args[0] == '#bots' 129 | assert args[1] == 'line1\nline2' 130 | 131 | @patch('helga.comm.irc.registry') 132 | def test_privmsg_responds_to_user_when_private(self, registry): 133 | self.client.nickname = 'helga' 134 | self.client.msg = Mock() 135 | registry.process.return_value = ['line1', 'line2'] 136 | 137 | self.client.privmsg('foo!~bar@baz', 'helga', 'this is the input') 138 | 139 | assert self.client.msg.call_args[0][0] == 'foo' 140 | 141 | @patch('helga.comm.irc.registry') 142 | def test_action(self, registry): 143 | self.client.msg = Mock() 144 | registry.process.return_value = ['eats the snack'] 145 | 146 | self.client.action('foo!~bar@baz', '#bots', 'offers helga a snack') 147 | 148 | args = self.client.msg.call_args[0] 149 | assert args[0] == '#bots' 150 | assert args[1] == 'eats the snack' 151 | 152 | @patch('helga.comm.irc.settings') 153 | @patch('helga.comm.irc.irc.IRCClient') 154 | def test_connectionMade(self, irc, settings): 155 | self.client.connectionMade() 156 | irc.connectionMade.assert_called_with(self.client) 157 | 158 | @patch('helga.comm.irc.settings') 159 | @patch('helga.comm.irc.irc.IRCClient') 160 | def test_connectionLost(self, irc, settings): 161 | self.client.connectionLost('an error...') 162 | irc.connectionLost.assert_called_with(self.client, 'an error...') 163 | 164 | @patch('helga.comm.irc.settings') 165 | @patch('helga.comm.irc.irc.IRCClient') 166 | def test_connectionLost_handles_unicode(self, irc, settings): 167 | snowman = u'☃' 168 | bytes = '\xe2\x98\x83' 169 | self.client.connectionLost(snowman) 170 | irc.connectionLost.assert_called_with(self.client, bytes) 171 | 172 | @patch('helga.comm.irc.smokesignal') 173 | def test_joined(self, signal): 174 | # Test str and unicode 175 | with patch.object(self.client, 'sendLine'): # patch this away since we have no active conn 176 | for channel in ('foo', u'☃'): 177 | assert channel not in self.client.channels 178 | self.client.joined(channel) 179 | assert channel in self.client.channels 180 | signal.emit.assert_called_with('join', self.client, channel) 181 | 182 | @patch('helga.comm.irc.smokesignal') 183 | def test_left(self, signal): 184 | # Test str and unicode 185 | for channel in ('foo', u'☃'): 186 | self.client.channels.add(channel) 187 | self.client.left(channel) 188 | assert channel not in self.client.channels 189 | signal.emit.assert_called_with('left', self.client, channel) 190 | 191 | def test_kickedFrom(self): 192 | # Test str and unicode 193 | for channel in ('foo', u'☃'): 194 | self.client.channels.add(channel) 195 | self.client.kickedFrom(channel, 'me', 'no bots allowed') 196 | assert channel not in self.client.channels 197 | 198 | def test_on_invite(self): 199 | with patch.object(self.client, 'join') as join: 200 | self.client.nickname = 'helga' 201 | self.client.on_invite('me', 'helga', '#bots') 202 | assert join.called 203 | 204 | def test_on_invite_ignores_other_invites(self): 205 | with patch.object(self.client, 'join') as join: 206 | self.client.nickname = 'helga' 207 | self.client.on_invite('me', 'someone_else', '#bots') 208 | assert not join.called 209 | 210 | def test_irc_unknown(self): 211 | with patch.object(self.client, 'on_invite') as on_invite: 212 | self.client.irc_unknown('me', 'INVITE', ['helga', '#bots']) 213 | on_invite.assert_called_with('me', 'helga', '#bots') 214 | 215 | on_invite.reset_mock() 216 | self.client.irc_unknown('me', 'SOME_COMMAND', []) 217 | assert not on_invite.called 218 | 219 | @patch('helga.comm.irc.smokesignal') 220 | def test_userJoined(self, signal): 221 | user = 'helga!helgabot@127.0.0.1' 222 | self.client.userJoined(user, '#bots') 223 | signal.emit.assert_called_with('user_joined', self.client, 'helga', '#bots') 224 | 225 | @patch('helga.comm.irc.smokesignal') 226 | def test_userLeft(self, signal): 227 | user = 'helga!helgabot@127.0.0.1' 228 | self.client.userLeft(user, '#bots') 229 | signal.emit.assert_called_with('user_left', self.client, 'helga', '#bots') 230 | 231 | @patch('helga.comm.irc.irc.IRCClient') 232 | def test_join_converts_from_unicode(self, irc): 233 | snowman = u'☃' 234 | bytes = '\xe2\x98\x83' 235 | self.client.join(snowman, snowman) 236 | irc.join.assert_called_with(self.client, bytes, key=bytes) 237 | 238 | @patch('helga.comm.irc.irc.IRCClient') 239 | def test_leave_converts_from_unicode(self, irc): 240 | snowman = u'☃' 241 | bytes = '\xe2\x98\x83' 242 | self.client.leave(snowman, snowman) 243 | irc.leave.assert_called_with(self.client, bytes, reason=bytes) 244 | 245 | @patch('helga.comm.irc.log') 246 | def test_get_channel_logger_no_existing_logger(self, log): 247 | self.client.channel_loggers = {} 248 | log.get_channel_logger.return_value = 'foo' 249 | 250 | assert 'foo' == self.client.get_channel_logger('#foo') 251 | assert '#foo' in self.client.channel_loggers 252 | 253 | @patch('helga.comm.irc.log') 254 | def test_get_channel_logger_existing_logger(self, log): 255 | self.client.channel_loggers = {'#foo': 'bar'} 256 | log.get_channel_logger.return_value = 'foo' 257 | 258 | assert 'bar' == self.client.get_channel_logger('#foo') 259 | assert not log.get_channel_logger.called 260 | 261 | @patch('helga.comm.irc.settings') 262 | def test_log_channel_message(self, settings): 263 | settings.CHANNEL_LOGGING = True 264 | logger = Mock() 265 | 266 | with patch.object(self.client, 'get_channel_logger'): 267 | self.client.get_channel_logger.return_value = logger 268 | self.client.log_channel_message('foo', 'bar', 'baz') 269 | self.client.get_channel_logger.assert_called_with('foo') 270 | logger.info.assert_called_with('baz', extra={'nick': 'bar'}) 271 | -------------------------------------------------------------------------------- /helga/plugins/webhooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Webhook HTTP server plugin and core webhook API 3 | 4 | Webhooks provide a way to expose HTTP endpoints that can interact with helga. A command 5 | plugin manages an HTTP server that is run on a port specified by setting 6 | :data:`helga.settings.WEBHOOKS_PORT` (default 8080). An additional, optional setting that can be 7 | used for routes requiring HTTP basic auth is :data:`helga.settings.WEBHOOKS_CREDENTIALS`, 8 | which should be a list of tuples, where each tuple is a pair of (username, password). 9 | 10 | Routes are URL path endpoints. On the surface they are just python callables decorated using 11 | :func:`@route `. The route decorated must be given a path regex, and optional list of 12 | HTTP methods to accept. Webhook plugins must be registered in the same way normal plugins are 13 | registered, using setuptools entry_points. However, they must belong to the entry_point group 14 | ``helga_webhooks``. For example:: 15 | 16 | setup(entry_points={ 17 | 'helga_webhooks': [ 18 | 'api = myapi.decorated_route' 19 | ] 20 | }) 21 | 22 | For more information, see :ref:`webhooks` 23 | """ 24 | import functools 25 | import pkg_resources 26 | import re 27 | 28 | from twisted.internet import reactor 29 | from twisted.web import server, resource 30 | from twisted.web.error import Error 31 | 32 | import smokesignal 33 | 34 | from helga import log, settings 35 | from helga.plugins import Command, registry 36 | from helga.util.encodings import from_unicode 37 | 38 | 39 | logger = log.getLogger(__name__) 40 | 41 | 42 | # Subclassed only for better naming 43 | class HttpError(Error): 44 | __doc__ = Error.__doc__ 45 | 46 | 47 | class WebhookPlugin(Command): 48 | """ 49 | A command plugin that manages running an HTTP server for webhook routes and services. Usage:: 50 | 51 | helga webhooks (start|stop|routes) 52 | 53 | Both ``start`` and ``stop`` are privileged actions and can start and stop the HTTP listener for 54 | webhooks respectively. To use them, a user must be configured as an operator. The ``routes`` 55 | subcommand will list all of the URL routes known to the webhook listener. 56 | 57 | Webhook routes are generally loaded automatically if they are installed. There are whitelist 58 | and blacklist controls to limit loading webhook routes (see :data:`~helga.settings.ENABLED_WEBHOOKS` 59 | and :data:`~helga.settings.DISABLED_WEBHOOKS`) 60 | """ 61 | command = 'webhooks' 62 | help = ('HTTP service for interacting with helga. Command options usage: ' 63 | 'helga webhooks (start|stop|routes). Note: start/stop' 64 | 'can be run only by helga operators') 65 | 66 | def __init__(self, *args, **kwargs): 67 | super(Command, self).__init__(*args, **kwargs) 68 | 69 | # Per issue 137, these were previously set on signon, but there is a bit of a 70 | # chicken and egg situation where routes in the same module as commands and matches 71 | # would try to register themselves before the connection was made, so the command 72 | # or match would fail to load. 73 | self.root = WebhookRoot() 74 | self.site = server.Site(self.root) 75 | self.port = getattr(settings, 'WEBHOOKS_PORT', 8080) 76 | 77 | self.webhook_names = set(ep.name for ep in pkg_resources.iter_entry_points('helga_webhooks')) 78 | 79 | self.whitelist_webhooks = self._create_webhook_list('ENABLED_WEBHOOKS', default=True) 80 | self.blacklist_webhooks = self._create_webhook_list('DISABLED_WEBHOOKS', default=True) 81 | 82 | @smokesignal.on('signon') 83 | def setup(client): # pragma: no cover 84 | self._start(client) 85 | self._init_routes() 86 | 87 | def _create_webhook_list(self, setting_name, default): 88 | """ 89 | Used to get either webhook whitelists or blacklists 90 | 91 | :param setting_name: either ENABLED_WEBHOOKS or DISABLED_WEBHOOKS 92 | :param default: the default value to use if the setting does not exist 93 | """ 94 | webhooks = getattr(settings, setting_name, default) 95 | if isinstance(webhooks, bool): 96 | return self.webhook_names if webhooks else set() 97 | else: 98 | return set(webhooks or []) 99 | 100 | def _init_routes(self): 101 | """ 102 | Initialize all webhook routes by loading entry points while honoring both 103 | webhook whitelist and blacklist 104 | """ 105 | if not self.whitelist_webhooks: 106 | logger.debug('Webhook whitelist was empty, none, or false. Skipping') 107 | return 108 | 109 | for entry_point in pkg_resources.iter_entry_points(group='helga_webhooks'): 110 | if entry_point.name in self.blacklist_webhooks: 111 | logger.info('Skipping blacklisted webhook %s', entry_point.name) 112 | continue 113 | 114 | if entry_point.name not in self.whitelist_webhooks: 115 | logger.info('Skipping non-whitelisted webhook %s', entry_point.name) 116 | continue 117 | 118 | try: 119 | logger.info('Loading webhook %s', entry_point.name) 120 | entry_point.load() 121 | except Exception: 122 | logger.exception('Error loading webhook %s', entry_point) 123 | 124 | def _start(self, client=None): 125 | logger.info('Starting webhooks service on port %s', self.port) 126 | self.root.chat_client = client 127 | self.tcp = reactor.listenTCP(self.port, self.site) 128 | 129 | def _stop(self): 130 | logger.info('Stopping webhooks service on port %s', self.port) 131 | self.tcp.stopListening() 132 | self.tcp.loseConnection() 133 | self.tcp = None 134 | 135 | def add_route(self, fn, path, methods): 136 | """ 137 | Adds a route handler function to the root web resource at a given path 138 | and for the given methods 139 | 140 | :param fn: the route handler function 141 | :param path: the URL path of the route 142 | :param methods: list of HTTP methods that the route should respond to 143 | """ 144 | self.root.add_route(fn, path, methods) # pragma: no cover 145 | 146 | def list_routes(self, client, nick): 147 | """ 148 | Messages a user with all webhook routes and their supported HTTP methods 149 | 150 | :param client: an instance of :class:`helga.comm.irc.Client` or :class:`helga.comm.xmpp.Client` 151 | :param nick: the nick of the chat user to message 152 | """ 153 | client.msg(nick, u'{0}, here are the routes I know about'.format(nick)) 154 | for pattern, route in self.root.routes.iteritems(): 155 | http_methods = route[0] # Route is a tuple (http_methods, function) 156 | client.msg(nick, u'[{0}] {1}'.format(','.join(http_methods), pattern)) 157 | 158 | def control(self, action): 159 | """ 160 | Control the running HTTP server. Intended for helga operators. 161 | 162 | :param action: the action to perform, either 'start' or 'stop' 163 | """ 164 | running = self.tcp is not None 165 | 166 | if action == 'stop': 167 | if running: 168 | self._stop() 169 | return u"Webhooks service stopped" 170 | return u"Webhooks service not running" 171 | 172 | if action == 'start': 173 | if not running: 174 | self._start() 175 | return u"Webhooks service started" 176 | return u"Webhooks service already running" 177 | 178 | def run(self, client, channel, nick, msg, cmd, args): 179 | try: 180 | subcmd = args[0] 181 | except IndexError: 182 | subcmd = 'routes' 183 | 184 | if subcmd == 'routes': 185 | client.me(channel, u'whispers to {0}'.format(nick)) 186 | self.list_routes(client, nick) 187 | elif subcmd in ('start', 'stop'): 188 | if nick not in client.operators: 189 | return u"Sorry {0}, Only an operator can do that".format(nick) 190 | return self.control(subcmd) 191 | 192 | 193 | class WebhookRoot(resource.Resource): 194 | """ 195 | The root HTTP resource the webhook HTTP server uses to respond to requests. This 196 | manages all registered webhook route handlers, manages running them, and manages 197 | returning any responses generated. 198 | """ 199 | isLeaf = True 200 | 201 | def __init__(self, *args, **kwargs): 202 | #: An instance of :class:`helga.comm.irc.Client` or :class:`helga.comm.xmpp.Client` 203 | self.chat_client = None 204 | 205 | #: A dictionary of regular expression URL paths as keys, and two-tuple values 206 | #: of allowed methods, and the route handler function 207 | self.routes = {} 208 | 209 | def add_route(self, fn, path, methods): 210 | """ 211 | Adds a route handler function to the root web resource at a given path 212 | and for the given methods 213 | 214 | :param fn: the route handler function 215 | :param path: the URL path of the route 216 | :param methods: list of HTTP methods that the route should respond to 217 | """ 218 | self.routes[path] = (methods, fn) 219 | 220 | def render(self, request): 221 | """ 222 | Renders a response for an incoming request. Handles finding and dispatching the route 223 | matching the incoming request path. Any response string generated will be explicitly 224 | encoded as a UTF-8 byte string. 225 | 226 | If no route patch matches the incoming request, a 404 is returned. 227 | 228 | If a route is found, but the request uses a method that the route handler does not 229 | support, a 405 is returned. 230 | 231 | :param request: The incoming HTTP request, ``twisted.web.http.Request`` 232 | :returns: a string with the HTTP response content 233 | """ 234 | request.setHeader('Server', 'helga') 235 | for pat, route in self.routes.iteritems(): 236 | match = re.match(pat, request.path) 237 | if match: 238 | break 239 | else: 240 | request.setResponseCode(404) 241 | return '404 Not Found' 242 | 243 | # Ensure that this route handles the request method 244 | methods, fn = route 245 | if request.method.upper() not in methods: 246 | request.setResponseCode(405) 247 | return '405 Method Not Allowed' 248 | 249 | # Handle raised HttpErrors 250 | try: 251 | # Explicitly return a byte string. Twisted expects this 252 | return from_unicode(fn(request, self.chat_client, **match.groupdict())) 253 | except HttpError as e: 254 | request.setResponseCode(int(e.status)) 255 | return e.message or e.response 256 | 257 | 258 | def authenticated(fn): 259 | """ 260 | Decorator for declaring a webhook route as requiring HTTP basic authentication. 261 | Incoming requests validate a supplied basic auth username and password against the list 262 | configured in the setting :data:`~helga.settings.WEBHOOKS_CREDENTIALS`. If no valid 263 | credentials are supplied, an HTTP 401 response is returned. 264 | 265 | :param fn: the route handler to decorate 266 | """ 267 | @functools.wraps(fn) 268 | def ensure_authenticated(request, *args, **kwargs): 269 | for user, password in getattr(settings, 'WEBHOOKS_CREDENTIALS', []): 270 | if user == request.getUser() and password == request.getPassword(): 271 | return fn(request, *args, **kwargs) 272 | 273 | # No valid basic auth provided 274 | request.setResponseCode(401) 275 | return '401 Unauthorized' 276 | return ensure_authenticated 277 | 278 | 279 | def route(path, methods=None): 280 | """ 281 | Decorator to register a webhook route. This requires a path regular expression, and 282 | optionally a list of HTTP methods to accept, which defaults to accepting ``GET`` requests 283 | only. Incoming HTTP requests that use a non-allowed method will receive a 405 HTTP response. 284 | 285 | :param path: a regular expression string for the URL path of the route 286 | :param methods: a list of accepted HTTP methods for this route, defaulting to ``['GET']`` 287 | 288 | Decorated routes must follow this pattern: 289 | 290 | .. function:: func(request, client) 291 | :noindex: 292 | 293 | :param request: The incoming HTTP request, ``twisted.web.http.Request`` 294 | :param client: The client connection. An instance of :class:`helga.comm.irc.Client` 295 | or :class:`helga.comm.xmpp.Client` 296 | :returns: a string HTTP response 297 | """ 298 | plugin = registry.get_plugin('webhooks') 299 | if methods is None: 300 | methods = ['GET'] 301 | 302 | def wrapper(fn): 303 | if plugin is not None: 304 | plugin.add_route(fn, path, methods) 305 | return fn 306 | 307 | return wrapper 308 | -------------------------------------------------------------------------------- /helga/tests/plugins/test_webhooks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | import pytest 3 | 4 | from mock import Mock, patch, call 5 | 6 | from helga.plugins import webhooks 7 | from twisted.web import server 8 | 9 | 10 | @patch('helga.plugins.webhooks.registry') 11 | def test_route(reg): 12 | reg.get_plugin.return_value = reg 13 | fake_fn = lambda: 'foo' 14 | webhooks.route('/foo', methods=['GET', 'POST'])(fake_fn) 15 | 16 | reg.add_route.assert_called_with(fake_fn, '/foo', ['GET', 'POST']) 17 | 18 | 19 | @patch('helga.plugins.webhooks.registry') 20 | def test_route_with_no_methods(reg): 21 | reg.get_plugin.return_value = reg 22 | fake_fn = lambda: 'foo' 23 | webhooks.route('/foo')(fake_fn) 24 | 25 | reg.add_route.assert_called_with(fake_fn, '/foo', ['GET']) 26 | 27 | 28 | @patch('helga.plugins.webhooks.settings') 29 | def test_authenticated_passes(settings): 30 | @webhooks.authenticated 31 | def fake_fn(*args, **kwargs): 32 | return 'OK' 33 | 34 | settings.WEBHOOKS_CREDENTIALS = [('foo', 'bar')] 35 | 36 | request = Mock() 37 | request.getUser.return_value = 'foo' 38 | request.getPassword.return_value = 'bar' 39 | 40 | assert fake_fn(request) == 'OK' 41 | 42 | 43 | @patch('helga.plugins.webhooks.settings') 44 | def test_authenticated_fails_when_called(settings): 45 | @webhooks.authenticated 46 | def fake_fn(*args, **kwargs): 47 | return 'OK' 48 | 49 | settings.WEBHOOKS_CREDENTIALS = [('person', 'password')] 50 | 51 | request = Mock() 52 | request.getUser.return_value = 'foo' 53 | request.getPassword.return_value = 'bar' 54 | 55 | assert fake_fn(request) == '401 Unauthorized' 56 | request.setResponseCode.assert_called_with(401) 57 | 58 | 59 | class TestWebhookPlugin(object): 60 | 61 | def setup(self): 62 | self.plugin = webhooks.WebhookPlugin() 63 | 64 | def test_initializes_root_and_site(self): 65 | plugin = webhooks.WebhookPlugin() 66 | assert isinstance(plugin.root, webhooks.WebhookRoot) 67 | assert isinstance(plugin.site, webhooks.server.Site) 68 | 69 | def _make_mock(self, **attrs): 70 | m = Mock() 71 | for k, v in attrs.iteritems(): 72 | setattr(m, k, v) 73 | return m 74 | 75 | @pytest.mark.parametrize('setting,expected', [ 76 | (True, set(('foo', 'bar', 'baz'))), 77 | (False, set()), 78 | (None, set()), 79 | (['foo', 'bar'], set(('foo', 'bar'))), 80 | ]) 81 | def test_whitelist_setup(self, setting, expected): 82 | entry_points = [ 83 | self._make_mock(name='foo'), 84 | self._make_mock(name='bar'), 85 | self._make_mock(name='baz'), 86 | ] 87 | 88 | with patch('helga.plugins.webhooks.pkg_resources') as pkg_resources: 89 | with patch('helga.plugins.webhooks.settings') as settings: 90 | settings.ENABLED_WEBHOOKS = setting 91 | settings.DISABLED_WEBHOOKS = False 92 | 93 | pkg_resources.iter_entry_points.return_value = entry_points 94 | 95 | plugin = webhooks.WebhookPlugin() 96 | 97 | assert plugin.whitelist_webhooks == expected 98 | 99 | @pytest.mark.parametrize('setting,expected', [ 100 | (True, set(('foo', 'bar', 'baz'))), 101 | (False, set()), 102 | (None, set()), 103 | (['foo', 'bar'], set(('foo', 'bar'))), 104 | ]) 105 | def test_blacklist_setup(self, setting, expected): 106 | entry_points = [ 107 | self._make_mock(name='foo'), 108 | self._make_mock(name='bar'), 109 | self._make_mock(name='baz'), 110 | ] 111 | 112 | with patch('helga.plugins.webhooks.pkg_resources') as pkg_resources: 113 | with patch('helga.plugins.webhooks.settings') as settings: 114 | settings.ENABLED_WEBHOOKS = True 115 | settings.DISABLED_WEBHOOKS = setting 116 | 117 | pkg_resources.iter_entry_points.return_value = entry_points 118 | 119 | plugin = webhooks.WebhookPlugin() 120 | 121 | assert plugin.blacklist_webhooks == expected 122 | 123 | @patch('helga.plugins.webhooks.settings') 124 | def test_custom_port(self, settings): 125 | settings.WEBHOOKS_PORT = 1337 126 | plugin = webhooks.WebhookPlugin() 127 | assert self.plugin.port == 8080 128 | assert plugin.port == 1337 129 | 130 | @patch('helga.plugins.webhooks.pkg_resources') 131 | def test_init_routes(self, pkg_resources): 132 | entry_points = [Mock()] 133 | entry_points[0].name = 'foo' 134 | pkg_resources.iter_entry_points.return_value = entry_points 135 | 136 | with patch.multiple(self.plugin, whitelist_webhooks=['foo'], blacklist_webhooks=[]): 137 | self.plugin._init_routes() 138 | pkg_resources.iter_entry_points.assert_called_with(group='helga_webhooks') 139 | assert entry_points[0].load.called 140 | 141 | @patch('helga.plugins.webhooks.pkg_resources') 142 | def test_init_routes_no_whitelist(self, pkg_resources): 143 | entry_points = [Mock()] 144 | entry_points[0].name = 'foo' 145 | pkg_resources.iter_entry_points.return_value = entry_points 146 | 147 | with patch.multiple(self.plugin, whitelist_webhooks=[], blacklist_webhooks=[]): 148 | self.plugin._init_routes() 149 | assert not pkg_resources.iter_entry_points.called 150 | 151 | @patch('helga.plugins.webhooks.pkg_resources') 152 | def test_init_routes_skips_blacklisted(self, pkg_resources): 153 | entry_points = [Mock()] 154 | entry_points[0].name = 'foo' 155 | pkg_resources.iter_entry_points.return_value = entry_points 156 | 157 | with patch.multiple(self.plugin, whitelist_webhooks=['foo'], blacklist_webhooks=['foo']): 158 | self.plugin._init_routes() 159 | pkg_resources.iter_entry_points.assert_called_with(group='helga_webhooks') 160 | assert not entry_points[0].load.called 161 | 162 | @patch('helga.plugins.webhooks.pkg_resources') 163 | def test_init_routes_skips_non_whitelisted(self, pkg_resources): 164 | entry_points = [Mock()] 165 | entry_points[0].name = 'foo' 166 | pkg_resources.iter_entry_points.return_value = entry_points 167 | 168 | with patch.multiple(self.plugin, whitelist_webhooks=['bar'], blacklist_webhooks=[]): 169 | self.plugin._init_routes() 170 | pkg_resources.iter_entry_points.assert_called_with(group='helga_webhooks') 171 | assert not entry_points[0].load.called 172 | 173 | @patch('helga.plugins.webhooks.pkg_resources') 174 | def test_init_routes_catches_load_exception(self, pkg_resources): 175 | entry_points = [ 176 | self._make_mock(name='foo'), 177 | self._make_mock(name='bar'), 178 | ] 179 | 180 | entry_points[0].load.side_effect = Exception 181 | pkg_resources.iter_entry_points.return_value = entry_points 182 | 183 | with patch.multiple(self.plugin, whitelist_webhooks=['foo', 'bar'], blacklist_webhooks=[]): 184 | self.plugin._init_routes() 185 | assert entry_points[0].load.called 186 | assert entry_points[1].load.called 187 | 188 | @patch('helga.plugins.webhooks.reactor') 189 | def test_start(self, reactor): 190 | client = Mock() 191 | self.plugin._start(client) 192 | assert isinstance(self.plugin.root, webhooks.WebhookRoot) 193 | assert isinstance(self.plugin.site, server.Site) 194 | reactor.listenTCP.assert_called_with(8080, self.plugin.site) 195 | 196 | @patch('helga.plugins.webhooks.WebhookRoot') 197 | @patch('helga.plugins.webhooks.reactor') 198 | def test_start_with_existing_root(self, reactor, WebhookRoot): 199 | self.plugin.root = Mock() 200 | self.plugin._start(Mock()) 201 | assert not WebhookRoot.called 202 | 203 | def test_stop(self): 204 | tcp_mock = Mock() 205 | self.plugin.tcp = tcp_mock 206 | self.plugin._stop() 207 | 208 | assert tcp_mock.stopListening.called 209 | assert tcp_mock.loseConnection.called 210 | assert self.plugin.tcp is None 211 | 212 | def test_list_routes(self): 213 | client = Mock() 214 | root = Mock() 215 | root.routes = { 216 | '/foo/bar/': [['POST', 'GET'], lambda: None], 217 | u'/unicode/support/☃': [['PUT'], lambda: None] 218 | } 219 | 220 | self.plugin.root = root 221 | self.plugin.list_routes(client, 'me') 222 | 223 | call_list = client.msg.call_args_list 224 | assert call('me', 'me, here are the routes I know about') in call_list 225 | assert call('me', '[POST,GET] /foo/bar/') in call_list 226 | assert call('me', u'[PUT] /unicode/support/☃') in call_list 227 | 228 | def test_control_stop(self): 229 | # When running 230 | self.plugin.tcp = 'foo' 231 | with patch.object(self.plugin, '_stop') as stop: 232 | assert self.plugin.control('stop') == 'Webhooks service stopped' 233 | assert stop.called 234 | 235 | # When not running 236 | self.plugin.tcp = None 237 | with patch.object(self.plugin, '_stop') as stop: 238 | assert self.plugin.control('stop') == 'Webhooks service not running' 239 | assert not stop.called 240 | 241 | def test_control_start(self): 242 | # When running 243 | self.plugin.tcp = 'foo' 244 | with patch.object(self.plugin, '_start') as start: 245 | assert self.plugin.control('start') == 'Webhooks service already running' 246 | assert not start.called 247 | 248 | # When not running 249 | self.plugin.tcp = None 250 | with patch.object(self.plugin, '_start') as start: 251 | assert self.plugin.control('start') == 'Webhooks service started' 252 | assert start.called 253 | 254 | def test_run_defaults_to_list_routes(self): 255 | client = Mock() 256 | with patch.object(self.plugin, 'list_routes') as routes: 257 | self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', []) 258 | client.me.assert_called_with('#bots', 'whispers to me') 259 | routes.assert_called_with(client, 'me') 260 | 261 | def test_run_list_routes(self): 262 | client = Mock() 263 | with patch.object(self.plugin, 'list_routes') as routes: 264 | self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', ['routes']) 265 | client.me.assert_called_with('#bots', 'whispers to me') 266 | routes.assert_called_with(client, 'me') 267 | 268 | def test_run_start_stop_requires_operator(self): 269 | client = Mock(operators=[]) 270 | with patch.object(self.plugin, 'control') as control: 271 | resp = self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', ['start']) 272 | assert resp == 'Sorry me, Only an operator can do that' 273 | assert not control.called 274 | 275 | resp = self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', ['stop']) 276 | assert resp == 'Sorry me, Only an operator can do that' 277 | assert not control.called 278 | 279 | def test_run_start_stop_as_operator(self): 280 | client = Mock(operators=['me']) 281 | with patch.object(self.plugin, 'control') as control: 282 | self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', ['start']) 283 | control.assert_called_with('start') 284 | 285 | control.reset_mock() 286 | self.plugin.run(client, '#bots', 'me', 'msg', 'cmd', ['stop']) 287 | control.assert_called_with('stop') 288 | 289 | 290 | class TestWebhookRoot(object): 291 | 292 | def setup(self): 293 | self.client = Mock() 294 | self.root = webhooks.WebhookRoot(self.client) 295 | 296 | def test_render_returns_404(self): 297 | mock_fn = Mock(return_value='foo') 298 | request = Mock(path='/foo/bar/baz', method='POST') 299 | self.root.routes['/path/to/resource'] = (['GET'], mock_fn) 300 | 301 | assert '404 Not Found' == self.root.render(request) 302 | request.setResponseCode.assert_called_with(404) 303 | 304 | def test_render_returns_405(self): 305 | mock_fn = Mock(return_value='foo') 306 | request = Mock(path='/path/to/resource', method='POST') 307 | self.root.routes['/path/to/resource'] = (['GET'], mock_fn) 308 | 309 | assert '405 Method Not Allowed' == self.root.render(request) 310 | request.setResponseCode.assert_called_with(405) 311 | 312 | def test_render(self): 313 | mock_fn = Mock(return_value='foo') 314 | request = Mock(path='/path/to/resource', method='GET') 315 | self.root.routes['/path/to/resource'] = (['GET'], mock_fn) 316 | 317 | assert 'foo' == self.root.render(request) 318 | mock_fn.assert_called_with(request, self.root.chat_client) 319 | request.setHeader.assert_called_with('Server', 'helga') 320 | 321 | def test_reunder_handles_http_error(self): 322 | mock_fn = Mock(side_effect=webhooks.HttpError(404, 'foo not found')) 323 | request = Mock(path='/path/to/resource', method='GET') 324 | self.root.routes['/path/to/resource'] = (['GET'], mock_fn) 325 | 326 | assert 'foo not found' == self.root.render(request) 327 | request.setResponseCode.assert_called_with(404) 328 | 329 | def test_add_route(self): 330 | fn = lambda: None 331 | methods = ['GET', 'POST'] 332 | path = '/path/to/resource' 333 | self.root.add_route(fn, path, methods) 334 | assert self.root.routes[path] == (methods, fn) 335 | -------------------------------------------------------------------------------- /helga/comm/irc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Twisted protocol and communication implementations for IRC 3 | """ 4 | import time 5 | 6 | import smokesignal 7 | 8 | from twisted.internet import protocol, reactor 9 | from twisted.words.protocols import irc 10 | 11 | from helga import settings, log 12 | from helga.comm.base import BaseClient 13 | from helga.plugins import registry 14 | from helga.util import encodings 15 | 16 | 17 | logger = log.getLogger(__name__) 18 | 19 | 20 | class Factory(protocol.ClientFactory): 21 | """ 22 | The client factory for twisted. Ensures that a client is properly created and handles 23 | auto reconnect if helga is configured for it (see settings :data:`~helga.settings.AUTO_RECONNECT` 24 | and :data:`~helga.settings.AUTO_RECONNECT_DELAY`) 25 | """ 26 | def __init__(self): 27 | self.client = Client(factory=self) 28 | 29 | def buildProtocol(self, address): 30 | """ 31 | Build the helga protocol for twisted, or in other words, create the client 32 | object and return it. 33 | 34 | :param address: an implementation of `twisted.internet.interfaces.IAddress` 35 | :returns: an instance of :class:`Client` 36 | """ 37 | logger.debug('Constructing Helga protocol') 38 | return self.client 39 | 40 | def clientConnectionLost(self, connector, reason): 41 | """ 42 | Handler for when the IRC connection is lost. Handles auto reconnect if helga 43 | is configured for it (see settings :data:`~helga.settings.AUTO_RECONNECT` and 44 | :data:`~helga.settings.AUTO_RECONNECT_DELAY`) 45 | """ 46 | logger.info('Connection to server lost: %s', reason) 47 | 48 | # FIXME: Max retries 49 | if getattr(settings, 'AUTO_RECONNECT', True): 50 | delay = getattr(settings, 'AUTO_RECONNECT_DELAY', 5) 51 | reactor.callLater(delay, connector.connect) 52 | else: 53 | raise reason 54 | 55 | def clientConnectionFailed(self, connector, reason): 56 | """ 57 | Handler for when the IRC connection fails. Handles auto reconnect if helga 58 | is configured for it (see settings :data:`~helga.settings.AUTO_RECONNECT` and 59 | :data:`~helga.settings.AUTO_RECONNECT_DELAY`) 60 | """ 61 | logger.warning('Connection to server failed: %s', reason) 62 | 63 | # FIXME: Max retries 64 | if getattr(settings, 'AUTO_RECONNECT', True): 65 | delay = getattr(settings, 'AUTO_RECONNECT_DELAY', 5) 66 | reactor.callLater(delay, connector.connect) 67 | else: 68 | reactor.stop() 69 | 70 | 71 | class Client(irc.IRCClient, BaseClient): 72 | """ 73 | An implementation of `twisted.words.protocols.irc.IRCClient` with some overrides 74 | derived from helga settings (see :ref:`config`). Some methods are overridden 75 | to provide additional functionality. 76 | """ 77 | 78 | #: The preferred IRC nick of the bot instance (setting :data:`~helga.settings.NICK`) 79 | nickname = None 80 | 81 | #: A username should the IRC server require authentication (setting :data:`~helga.settings.SERVER`) 82 | username = None 83 | 84 | #: A password should the IRC server require authentication (setting :data:`~helga.settings.SERVER`) 85 | password = None 86 | 87 | #: An integer, in seconds, if IRC messages should be sent at a limit of once per this many seconds. 88 | #: ``None`` implies no limit. (setting :data:`~helga.settings.RATE_LIMIT`) 89 | lineRate = None 90 | 91 | #: The URL where the source of the bot is found 92 | sourceURL = 'http://github.com/shaunduncan/helga' 93 | 94 | #: The assumed encoding of IRC messages 95 | encoding = 'UTF-8' 96 | 97 | #: A backup nick should the preferred :attr:`nickname` be taken. This defaults to a string in the 98 | #: form of the preferred nick plus the timestamp when the process was started (i.e. helga_12345) 99 | erroneousNickFallback = None 100 | 101 | def __init__(self, factory=None): 102 | BaseClient.__init__(self) 103 | 104 | self.factory = factory 105 | self.erroneousNickFallback = '{0}_{1}'.format(settings.NICK, int(time.time())) 106 | 107 | # These are set here to ensure using properly overridden settings 108 | self.nickname = settings.NICK 109 | self.username = settings.SERVER.get('USERNAME', None) 110 | self.password = settings.SERVER.get('PASSWORD', None) 111 | self.lineRate = getattr(settings, 'RATE_LIMIT', None) 112 | self._use_sasl = settings.SERVER.get('SASL', False) 113 | 114 | def get_channel_logger(self, channel): 115 | """ 116 | Gets a channel logger, keeping track of previously requested ones. 117 | (see :ref:`builtin.channel_logging`) 118 | 119 | :param channel: A channel name 120 | :returns: a python logger suitable for channel logging 121 | """ 122 | if channel not in self.channel_loggers: 123 | self.channel_loggers[channel] = log.get_channel_logger(channel) 124 | return self.channel_loggers[channel] 125 | 126 | def log_channel_message(self, channel, nick, message): 127 | """ 128 | Logs one or more messages by a user on a channel using a channel logger. 129 | If channel logging is not enabled, nothing happens. (see :ref:`builtin.channel_logging`) 130 | 131 | :param channel: A channel name 132 | :param nick: The nick of the user sending an IRC message 133 | :param message: The IRC message 134 | """ 135 | if not settings.CHANNEL_LOGGING: 136 | return 137 | chan_logger = self.get_channel_logger(channel) 138 | chan_logger.info(message, extra={'nick': nick}) 139 | 140 | def connectionMade(self): 141 | logger.info('Connection made to %s', settings.SERVER['HOST']) 142 | if self._use_sasl: 143 | self._reallySendLine('CAP REQ :sasl') 144 | irc.IRCClient.connectionMade(self) 145 | 146 | def irc_CAP(self, prefix, params): 147 | if params[1] != 'ACK' or params[2].split() != ['sasl']: 148 | logger.info('SASL is not available!') 149 | self.quit('') 150 | sasl = ('{0}\0{0}\0{1}'.format(self.username, self.password)).encode('base64').strip() 151 | self.sendLine('AUTHENTICATE PLAIN') 152 | self.sendLine('AUTHENTICATE ' + sasl) 153 | 154 | def irc_903(self, prefix, params): 155 | self.sendLine('CAP END') 156 | 157 | def irc_904(self, prefix, params): 158 | logger.info('SASL auth failed: %s', params) 159 | self.quit('') 160 | irc_905 = irc_904 161 | 162 | @encodings.from_unicode_args 163 | def connectionLost(self, reason): 164 | logger.info('Connection to %s lost: %s', settings.SERVER['HOST'], reason) 165 | irc.IRCClient.connectionLost(self, reason) 166 | 167 | def signedOn(self): 168 | """ 169 | Called when the client has successfully signed on to IRC. Establishes automatically 170 | joining channels. Sends the ``signon`` signal (see :ref:`plugins.signals`) 171 | """ 172 | 173 | for channel in settings.CHANNELS: 174 | # If channel is more than one item tuple, second value is password 175 | if isinstance(channel, (tuple, list)): 176 | self.join(*channel) 177 | else: 178 | self.join(channel) 179 | 180 | smokesignal.emit('signon', self) 181 | 182 | def joined(self, channel): 183 | """ 184 | Called when the client successfully joins a new channel. Adds the channel to the known 185 | channel list and sends the ``join`` signal (see :ref:`plugins.signals`) 186 | 187 | :param channel: the channel that has been joined 188 | """ 189 | logger.info('Joined %s', channel) 190 | self.channels.add(channel) 191 | self.sendLine("NAMES %s" % (channel,)) 192 | smokesignal.emit('join', self, channel) 193 | 194 | def left(self, channel): 195 | """ 196 | Called when the client successfully leaves a channel. Removes the channel from the known 197 | channel list and sends the ``left`` signal (see :ref:`plugins.signals`) 198 | 199 | :param channel: the channel that has been left 200 | """ 201 | logger.info('Left %s', channel) 202 | self.channels.discard(channel) 203 | smokesignal.emit('left', self, channel) 204 | 205 | def parse_nick(self, full_nick): 206 | """ 207 | Parses a nick from a full IRC user string. For example from ``me!~myuser@localhost`` 208 | would return ``me``. 209 | 210 | :param full_nick: the full IRC user string of the form ``{nick}!~{user}@{host}`` 211 | :returns: The nick portion of the IRC user string 212 | """ 213 | return full_nick.split('!')[0] 214 | 215 | def is_public_channel(self, channel): 216 | """ 217 | Checks if a given channel is public or not. A channel is public if it starts with 218 | '#' and is not the bot's nickname (which occurs when a private message is received) 219 | 220 | :param channel: the channel name to check 221 | """ 222 | return self.nickname != channel and channel.startswith('#') 223 | 224 | @encodings.to_unicode_args 225 | def privmsg(self, user, channel, message): 226 | """ 227 | Handler for an IRC message. This method handles logging channel messages (if it occurs 228 | on a public channel) as well as allowing the plugin manager to send the message to all 229 | registered plugins. Should the plugin manager yield a response, it will be sent back 230 | over IRC. 231 | 232 | :param user: IRC user string of the form ``{nick}!~{user}@{host}`` 233 | :param channel: the channel from which the message came 234 | :param message: the message contents 235 | """ 236 | user = self.parse_nick(user) 237 | message = message.strip() 238 | 239 | # Log the incoming message and notify message subscribers 240 | logger.debug('[<--] %s/%s - %s', channel, user, message) 241 | is_public = self.is_public_channel(channel) 242 | 243 | # When we get a priv msg, the channel is our current nick, so we need to 244 | # respond to the user that is talking to us 245 | if is_public: 246 | # Only log convos on public channels 247 | self.log_channel_message(channel, user, message) 248 | else: 249 | channel = user 250 | 251 | # Some things should go first 252 | try: 253 | channel, user, message = registry.preprocess(self, channel, user, message) 254 | except (TypeError, ValueError): 255 | pass 256 | 257 | # if not message.has_response: 258 | responses = registry.process(self, channel, user, message) 259 | 260 | if responses: 261 | message = u'\n'.join(responses) 262 | self.msg(channel, message) 263 | 264 | if is_public: 265 | self.log_channel_message(channel, self.nickname, message) 266 | 267 | # Update last message 268 | self.last_message[channel][user] = message 269 | 270 | """ 271 | Handle IRC "/me" messages the same as regular IRC messages. 272 | """ 273 | action = privmsg 274 | 275 | def alterCollidedNick(self, nickname): 276 | """ 277 | Called when the bot has a nickname collision. This will generate a new nick 278 | containing the perferred nick and the current timestamp. 279 | 280 | :param nickname: the nickname that was already taken 281 | """ 282 | logger.info('Nick %s already taken', nickname) 283 | 284 | parts = nickname.split('_') 285 | if len(parts) > 1: 286 | parts = parts[:-1] 287 | 288 | stripped = '_'.join(parts) 289 | self.nickname = '{0}_{1}'.format(stripped, int(time.time())) 290 | 291 | return self.nickname 292 | 293 | def kickedFrom(self, channel, kicker, message): 294 | logger.warning('%s kicked bot from %s: %s', kicker, channel, message) 295 | self.channels.discard(channel) 296 | 297 | @encodings.from_unicode_args 298 | def msg(self, channel, message): 299 | """ 300 | Send a message over IRC to the specified channel 301 | 302 | :param channel: The IRC channel to send the message to. A channel not prefixed by a '#' 303 | will be sent as a private message to a user with that nick. 304 | :param message: The message to send 305 | """ 306 | logger.debug('[-->] %s - %s', channel, message) 307 | irc.IRCClient.msg(self, channel, message) 308 | 309 | def on_invite(self, inviter, invitee, channel): 310 | """ 311 | Handler for /INVITE commands. If the invitee is the bot, it will join the requested channel. 312 | 313 | :param inviter: IRC user string of the form ``{nick}!~{user}@{host}`` 314 | :param invitee: the nick of the user receiving the invite 315 | :param channel: the channel to which invitee has been invited 316 | """ 317 | nick = self.parse_nick(inviter) 318 | if invitee == self.nickname: 319 | logger.info('%s invited %s to %s', nick, invitee, channel) 320 | self.join(channel) 321 | 322 | def irc_unknown(self, prefix, command, params): 323 | """ 324 | Handler for any unknown IRC commands. Currently handles /INVITE commands 325 | 326 | :param prefix: any command prefix, such as the IRC user 327 | :param command: the IRC command received 328 | :param params: list of parameters for the given command 329 | """ 330 | if command.lower() == 'invite': 331 | self.on_invite(prefix, params[0], params[1]) 332 | 333 | @encodings.from_unicode_args 334 | def me(self, channel, message): 335 | """ 336 | Equivalent to: /me message 337 | 338 | :param channel: The IRC channel to send the message to. A channel not prefixed by a '#' 339 | will be sent as a private message to a user with that nick. 340 | :param message: The message to send 341 | """ 342 | # A proxy for the WTF-named method `describe`. Basically the same as doing `/me waves` 343 | irc.IRCClient.describe(self, channel, message) 344 | 345 | def userJoined(self, user, channel): 346 | """ 347 | Called when a user joins a channel in which the bot resides. Responsible for sending 348 | the ``user_joined`` signal (see :ref:`plugins.signals`) 349 | 350 | :param user: IRC user string of the form ``{nick}!~{user}@{host}`` 351 | :param channel: the channel in which the event occurred 352 | """ 353 | nick = self.parse_nick(user) 354 | smokesignal.emit('user_joined', self, nick, channel) 355 | 356 | def userLeft(self, user, channel): 357 | """ 358 | Called when a user leaves a channel in which the bot resides. Responsible for sending 359 | the ``user_left`` signal (see :ref:`plugins.signals`) 360 | 361 | :param user: IRC user string of the form ``{nick}!~{user}@{host}`` 362 | :param channel: the channel in which the event occurred 363 | """ 364 | nick = self.parse_nick(user) 365 | smokesignal.emit('user_left', self, nick, channel) 366 | 367 | @encodings.from_unicode_args 368 | def join(self, channel, key=None): 369 | """ 370 | Join a channel, optionally with a passphrase required to join. 371 | 372 | :param channel: the name of the channel to join 373 | :param key: an optional passphrase used to join the given channel 374 | """ 375 | logger.info("Joining channel %s", channel) 376 | irc.IRCClient.join(self, channel, key=key) 377 | 378 | @encodings.from_unicode_args 379 | def leave(self, channel, reason=None): 380 | """ 381 | Leave a channel, optionally with a reason for leaving 382 | 383 | :param channel: the name of the channel to leave 384 | :param reason: an optional reason for leaving 385 | """ 386 | logger.info("Leaving channel %s: %s", channel, reason) 387 | irc.IRCClient.leave(self, channel, reason=reason) 388 | 389 | def userRenamed(self, oldname, newname): 390 | """ 391 | :param oldname: the nick of the user before the rename 392 | :param newname: the nick of the user after the rename 393 | """ 394 | 395 | smokesignal.emit('user_rename', self, oldname, newname) 396 | 397 | def irc_RPL_NAMREPLY(self, prefix, params): 398 | nicks = params[3].split() 399 | smokesignal.emit('names_reply', self, nicks) 400 | --------------------------------------------------------------------------------