├── storage ├── .gitkeep ├── logs │ └── .gitkeep └── database │ └── .gitkeep ├── .dockerignore ├── cardinal ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── fake_plugins │ │ ├── __init__.py │ │ ├── valid │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── event_callback │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── registers_event │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── setup_missing │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── close_no_arguments │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── close_one_argument │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── config_ambiguous │ │ │ ├── __init__.py │ │ │ ├── config.yml │ │ │ ├── config.json │ │ │ ├── config.yaml │ │ │ └── plugin.py │ │ ├── config_invalid_json │ │ │ ├── __init__.py │ │ │ ├── config.json │ │ │ └── plugin.py │ │ ├── config_valid_json │ │ │ ├── __init__.py │ │ │ ├── config.json │ │ │ └── plugin.py │ │ ├── config_valid_yaml │ │ │ ├── __init__.py │ │ │ ├── config.yaml │ │ │ └── plugin.py │ │ ├── setup_one_argument │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── setup_two_arguments │ │ │ ├── __init__.py │ │ │ ├── config.json │ │ │ └── plugin.py │ │ ├── close_raises_exception │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── close_too_many_arguments │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── command_raises_exception │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── setup_too_many_arguments │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── setup_config_cardinal_order │ │ │ ├── __init__.py │ │ │ ├── config.json │ │ │ └── plugin.py │ │ ├── multiple_event_callbacks_one_fails │ │ │ ├── __init__.py │ │ │ └── plugin.py │ │ ├── entrypoint │ │ │ └── plugin.py │ │ └── entrypoint_invalid │ │ │ └── plugin.py │ ├── invalid-json-config.json │ └── config.json ├── unittest_util.py ├── exceptions.py ├── decorators.py ├── test_util.py ├── util.py ├── test_config.py ├── config.py └── test_decorators.py ├── plugins ├── __init__.py ├── sed │ ├── __init__.py │ ├── plugin.py │ └── test_plugin.py ├── google │ ├── __init__.py │ ├── .gitignore │ ├── requirements.txt │ ├── config.json.example │ └── plugin.py ├── seen │ ├── __init__.py │ ├── config.json │ └── plugin.py ├── ticker │ ├── __init__.py │ ├── .gitignore │ ├── requirements.txt │ └── config.example.json ├── timezone │ ├── __init__.py │ ├── requirements.txt │ └── plugin.py ├── 8ball │ ├── __init__.py │ └── plugin.py ├── movies │ ├── __init__.py │ ├── config.json.example │ └── plugin.py ├── urbandict │ ├── __init__.py │ └── plugin.py ├── wikipedia │ ├── __init__.py │ ├── requirements.txt │ ├── config.json │ └── plugin.py ├── admin │ ├── .gitignore │ ├── __init__.py │ ├── config.example.json │ ├── test_plugin.py │ └── plugin.py ├── crypto │ ├── .gitignore │ ├── config.example.json │ └── plugin.py ├── imgur │ ├── .gitignore │ ├── config.example.json │ └── plugin.py ├── join_on_invite │ ├── .gitignore │ ├── config.example.json │ ├── __init__.py │ └── plugin.py ├── twitter │ ├── requirements.txt │ ├── config.example.json │ └── plugin.py ├── wolframalpha │ ├── config.json.example │ └── plugin.py ├── github │ ├── config.json │ ├── __init__.py │ └── plugin.py ├── lastfm │ ├── config.json │ ├── __init__.py │ └── plugin.py ├── youtube │ ├── config.json │ ├── __init__.py │ └── plugin.py ├── weather │ ├── config.json │ ├── __init__.py │ └── plugin.py ├── help │ ├── __init__.py │ ├── test_plugin.py │ └── plugin.py ├── ping │ ├── __init__.py │ └── plugin.py ├── remind │ ├── __init__.py │ └── plugin.py ├── urls │ ├── __init__.py │ ├── config.json │ ├── test_plugin.py │ └── plugin.py ├── random │ └── plugin.py └── tv │ └── plugin.py ├── setup.cfg ├── .coveragerc ├── test_requirements.txt ├── requirements.txt ├── Dockerfile ├── docker-compose.yml ├── Makefile ├── CONTRIBUTORS ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── config └── config.example.json ├── README.md ├── CONTRIBUTING.md ├── cardinal.py └── _assets └── cardinal.svg /storage/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /cardinal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/sed/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/google/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/seen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/ticker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/timezone/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/database/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/8ball/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /plugins/movies/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /plugins/urbandict/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/wikipedia/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/admin/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /plugins/crypto/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /plugins/google/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /plugins/imgur/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /plugins/ticker/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/valid/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/google/requirements.txt: -------------------------------------------------------------------------------- 1 | google==3.0.0 2 | -------------------------------------------------------------------------------- /plugins/join_on_invite/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /plugins/timezone/requirements.txt: -------------------------------------------------------------------------------- 1 | pytz==2024.1 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/event_callback/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/registers_event/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_missing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/invalid-json-config.json: -------------------------------------------------------------------------------- 1 | {'invalid' 2 | -------------------------------------------------------------------------------- /plugins/google/config.json.example: -------------------------------------------------------------------------------- 1 | {"max_results": 1} 2 | -------------------------------------------------------------------------------- /plugins/twitter/requirements.txt: -------------------------------------------------------------------------------- 1 | python-twitter==3.5 2 | -------------------------------------------------------------------------------- /plugins/wikipedia/requirements.txt: -------------------------------------------------------------------------------- 1 | pymediawiki==0.7.4 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_no_arguments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_one_argument/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_ambiguous/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_invalid_json/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_json/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_yaml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_one_argument/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_two_arguments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/wolframalpha/config.json.example: -------------------------------------------------------------------------------- 1 | {"app_id": ""} 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_raises_exception/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_too_many_arguments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/command_raises_exception/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_too_many_arguments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/movies/config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "" 3 | } 4 | -------------------------------------------------------------------------------- /plugins/seen/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignored_channels": [] 3 | } 4 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_ambiguous/config.yml: -------------------------------------------------------------------------------- 1 | test: true 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_config_cardinal_order/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/crypto/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "cmc_api_key": "" 3 | } 4 | -------------------------------------------------------------------------------- /plugins/join_on_invite/config.example.json: -------------------------------------------------------------------------------- 1 | {"rejoin_on_kick": false} 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_ambiguous/config.json: -------------------------------------------------------------------------------- 1 | {"test": true} 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_ambiguous/config.yaml: -------------------------------------------------------------------------------- 1 | config: True 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_yaml/config.yaml: -------------------------------------------------------------------------------- 1 | test: True 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/multiple_event_callbacks_one_fails/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_invalid_json/config.json: -------------------------------------------------------------------------------- 1 | this is invalid! 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_json/config.json: -------------------------------------------------------------------------------- 1 | {"test": true} 2 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_two_arguments/config.json: -------------------------------------------------------------------------------- 1 | {"test": true} 2 | -------------------------------------------------------------------------------- /plugins/github/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "default_repo": "JohnMaguire/Cardinal" 3 | } 4 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_config_cardinal_order/config.json: -------------------------------------------------------------------------------- 1 | {"test": true} 2 | -------------------------------------------------------------------------------- /plugins/imgur/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "", 3 | "client_secret": "" 4 | } 5 | -------------------------------------------------------------------------------- /plugins/lastfm/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "56737ebeaff37f6166be75c92d13a216" 3 | } 4 | -------------------------------------------------------------------------------- /plugins/ticker/requirements.txt: -------------------------------------------------------------------------------- 1 | holidays==0.51 2 | python-dateutil==2.8.2 3 | pytz==2024.1 4 | -------------------------------------------------------------------------------- /plugins/youtube/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "AIzaSyA94GQ9F_dQqoo0jbn5AjRRCudkNQM-KpU" 3 | } 4 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_missing/plugin.py: -------------------------------------------------------------------------------- 1 | class TestSetupMissingPlugin: 2 | pass 3 | -------------------------------------------------------------------------------- /plugins/twitter/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "consumer_key": "", 3 | "consumer_secret": "" 4 | } 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --cov-report term-missing --cov . 3 | env = 4 | PYTEST=true 5 | -------------------------------------------------------------------------------- /plugins/wikipedia/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_code": "en", 3 | "max_description_length": 250 4 | } 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | plugins/* 4 | */test_*.py 5 | cardinal/fixtures/* 6 | include=cardinal/* 7 | -------------------------------------------------------------------------------- /plugins/weather/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "provider": "weatherapi", 3 | "api_key": "9dc48ecdcea74f9eae0135324211206" 4 | } 5 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | # Unit tests 2 | pytest==8.0.0 3 | pytest-cov==4.1.0 4 | pytest-env==1.1.3 5 | pytest-twisted==1.14.0 6 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/entrypoint/plugin.py: -------------------------------------------------------------------------------- 1 | class EntrypointTestPlugin: 2 | pass 3 | 4 | 5 | entrypoint = EntrypointTestPlugin 6 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/valid/plugin.py: -------------------------------------------------------------------------------- 1 | class TestValidPlugin: 2 | pass 3 | 4 | 5 | def setup(): 6 | return TestValidPlugin() 7 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/entrypoint_invalid/plugin.py: -------------------------------------------------------------------------------- 1 | class EntrypointTestPlugin: 2 | pass 3 | 4 | 5 | entrypoint = 123 # this isn't valid 6 | -------------------------------------------------------------------------------- /plugins/github/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'Sol Bekic' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/admin/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/help/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/lastfm/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/ping/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/remind/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/urls/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/weather/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /plugins/youtube/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_ambiguous/plugin.py: -------------------------------------------------------------------------------- 1 | class TestConfigAmbiguousPlugin: 2 | pass 3 | 4 | 5 | def setup(): 6 | return TestConfigAmbiguousPlugin() 7 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_json/plugin.py: -------------------------------------------------------------------------------- 1 | class TestConfigValidJsonPlugin: 2 | pass 3 | 4 | 5 | def setup(): 6 | return TestConfigValidJsonPlugin() 7 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_valid_yaml/plugin.py: -------------------------------------------------------------------------------- 1 | class TestConfigValidYamlPlugin: 2 | pass 3 | 4 | 5 | def setup(): 6 | return TestConfigValidYamlPlugin() 7 | -------------------------------------------------------------------------------- /plugins/join_on_invite/__init__.py: -------------------------------------------------------------------------------- 1 | # Plugin author 2 | __author__ = 'John Maguire' 3 | 4 | # Plugin homepage 5 | __url__ = 'https://github.com/JohnMaguire2013/Cardinal/' 6 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/config_invalid_json/plugin.py: -------------------------------------------------------------------------------- 1 | class TestConfigInvalidJsonPlugin: 2 | pass 3 | 4 | 5 | def setup(): 6 | return TestConfigInvalidJsonPlugin() 7 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_too_many_arguments/plugin.py: -------------------------------------------------------------------------------- 1 | class TestSetupTooManyArgumentsPlugin: 2 | pass 3 | 4 | 5 | def setup(cardinal, config, three): 6 | pass 7 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_no_arguments/plugin.py: -------------------------------------------------------------------------------- 1 | class TestCloseNoArgumentsPlugin: 2 | def close(self): 3 | pass 4 | 5 | 6 | def setup(): 7 | return TestCloseNoArgumentsPlugin() 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core 2 | Twisted==23.10.0 3 | 4 | # SSL connections 5 | pyOpenSSL==24.0.0 6 | service_identity==24.1.0 7 | 8 | # Various URL scraping plugins 9 | beautifulsoup4==4.12.3 10 | requests==2.31.0 11 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_raises_exception/plugin.py: -------------------------------------------------------------------------------- 1 | class TestCloseRaisesExceptionPlugin: 2 | def close(self): 3 | raise Exception() 4 | 5 | 6 | def setup(): 7 | return TestCloseRaisesExceptionPlugin() 8 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_one_argument/plugin.py: -------------------------------------------------------------------------------- 1 | class TestCloseOneArgumentPlugin: 2 | def close(self, cardinal): 3 | self.cardinal = cardinal 4 | 5 | 6 | def setup(): 7 | return TestCloseOneArgumentPlugin() 8 | -------------------------------------------------------------------------------- /plugins/ticker/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "channels": ["#finance"], 3 | "api_key": "", 4 | "stocks": [ 5 | ["SPY", "SPDR S&P 500 ETF"], 6 | ["DIA", "SPDR Dow ETF"], 7 | ["AGG", "US Bond"], 8 | ["VEU", "Foreign"] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_one_argument/plugin.py: -------------------------------------------------------------------------------- 1 | class TestSetupOneArgumentPlugin: 2 | def __init__(self, cardinal): 3 | self.cardinal = cardinal 4 | 5 | 6 | def setup(cardinal): 7 | return TestSetupOneArgumentPlugin(cardinal) 8 | -------------------------------------------------------------------------------- /plugins/urls/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 5, 3 | "read_bytes": 524288, 4 | "lookup_cooloff": 10, 5 | "handle_generic_urls": true, 6 | "shorten_links": true, 7 | "crdnlxyz_api_key": "jmlHgK+wF1hd55XqksG3uI3H92b/jGjPEOoZnwiZ26c=" 8 | } 9 | -------------------------------------------------------------------------------- /cardinal/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": "value", 3 | "ignored_string": "ignored", 4 | "int": 3, 5 | "bool": false, 6 | "dict": { 7 | "dict": { 8 | "string": "value" 9 | }, 10 | "list": [ 11 | "foo", 12 | "bar", 13 | "baz" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | COPY . . 8 | RUN find plugins -type f -name requirements.txt -exec pip install --no-cache-dir -r {} \; 9 | 10 | ENTRYPOINT [ "python", "./cardinal.py" ] 11 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_two_arguments/plugin.py: -------------------------------------------------------------------------------- 1 | class TestSetupTwoArgumentsPlugin: 2 | def __init__(self, cardinal, config): 3 | self.cardinal = cardinal 4 | self.config = config 5 | 6 | 7 | def setup(cardinal, config): 8 | return TestSetupTwoArgumentsPlugin(cardinal, config) 9 | -------------------------------------------------------------------------------- /plugins/admin/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "admins": [ 3 | { 4 | "nick": "your_nickname", 5 | "vhost": "your.vhost", 6 | "ident": "yourusername" 7 | }, 8 | { 9 | "nick": "your_nickname", 10 | "vhost": "your.vhost" 11 | }, 12 | { 13 | "vhost": "your.vhost" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/setup_config_cardinal_order/plugin.py: -------------------------------------------------------------------------------- 1 | class TestSetupTwoArgumentsPlugin: 2 | def __init__(self, cardinal, config): 3 | self.cardinal = cardinal 4 | self.config = config 5 | 6 | 7 | def setup(config, cardinal): 8 | return TestSetupTwoArgumentsPlugin(cardinal, config) 9 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/registers_event/plugin.py: -------------------------------------------------------------------------------- 1 | class TestRegistersEventPlugin: 2 | def __init__(self, cardinal): 3 | cardinal.event_manager.register('test.event', 1) 4 | 5 | def close(self, cardinal): 6 | cardinal.event_manager.remove('test.event') 7 | 8 | 9 | def setup(cardinal): 10 | return TestRegistersEventPlugin(cardinal) 11 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/close_too_many_arguments/plugin.py: -------------------------------------------------------------------------------- 1 | class TestCloseTooManyArgumentsPlugin: 2 | def __init__(self): 3 | self.called = False 4 | 5 | def close(self, cardinal, _): 6 | """This should never be hit due to wrong number of args.""" 7 | self.called = True 8 | 9 | 10 | def setup(): 11 | return TestCloseTooManyArgumentsPlugin() 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | cardinal: 4 | container_name: cardinal 5 | build: . 6 | image: jmaguire/cardinal 7 | command: config/config.json 8 | volumes: 9 | - ./storage/:/usr/src/app/storage/ 10 | - ./config/:/usr/src/app/config/ 11 | - ./plugins/:/usr/src/app/plugins/ 12 | restart: unless-stopped 13 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/multiple_event_callbacks_one_fails/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import event 2 | 3 | 4 | class MultipleEventCallbacksOneFailsPlugin: 5 | @event('foo') 6 | def A(self, cardinal): 7 | pass 8 | 9 | @event('foo') 10 | def z(self, cardinal, wrong_signature): 11 | pass 12 | 13 | 14 | def setup(): 15 | return MultipleEventCallbacksOneFailsPlugin() 16 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/command_raises_exception/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import command 2 | 3 | 4 | class TestCommandRaisesExceptionPlugin: 5 | def __init__(self): 6 | self.command_calls = [] 7 | 8 | @command('command') 9 | def command(self, *args): 10 | self.command_calls.append(args) 11 | raise Exception() 12 | 13 | 14 | def setup(): 15 | return TestCommandRaisesExceptionPlugin() 16 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/event_callback/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import event 2 | 3 | 4 | class TestEventCallbackPlugin: 5 | def __init__(self): 6 | self.cardinal = None 7 | self.messages = [] 8 | 9 | @event('irc.raw') 10 | def irc_raw_callback(self, cardinal, message): 11 | self.cardinal = cardinal 12 | self.messages.append(message) 13 | 14 | 15 | def setup(): 16 | return TestEventCallbackPlugin() 17 | -------------------------------------------------------------------------------- /plugins/ping/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import command, help, regex 2 | 3 | 4 | class PingPlugin: 5 | @regex(r'(?i)^ping[.?!]?$') 6 | @command(['ping']) 7 | @help("Responds to a ping message with 'Pong.'") 8 | def pong(self, cardinal, user, channel, msg): 9 | if channel != user: 10 | cardinal.sendMsg(channel, "%s: Pong." % user.nick) 11 | else: 12 | cardinal.sendMsg(channel, "Pong.") 13 | 14 | 15 | entrypoint = PingPlugin 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: install 2 | 3 | clean: 4 | rm -rf venv 5 | 6 | venv: 7 | python -m venv venv 8 | 9 | install: venv 10 | . venv/bin/activate; pip install -r requirements.txt \ 11 | && find plugins -type f -name requirements.txt -exec pip install --no-cache-dir -r {} \; 12 | 13 | install-dev: install 14 | . venv/bin/activate; pip install -r test_requirements.txt \ 15 | && find plugins -type f -name test_requirements.txt -exec pip install --no-cache-dir -r {} \; 16 | 17 | .PHONY: clean install install-dev all 18 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | This is a list of people who have submitted contributions to this 2 | project. If you would like to have your name listed here, please add 3 | your name to this list in a commit when submitting your pull request. 4 | Please try to keep this list organized alphabetically by first name. 5 | 6 | Adam 'Flarf' Straub 7 | Conny Sjöblom 8 | Filip Milković 9 | John Maguire 10 | Josh Smailes 11 | Shawn Smith 12 | Sol Bekic 13 | Tony Skuse 14 | Vineet Menon 15 | Scott A. Williams 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Test Plan 7 | 8 | 9 | ## Contribution Checklist 10 | - [ ] I have read the [contributing guidelines](https://github.com/JohnMaguire/Cardinal/blob/master/CONTRIBUTING.md). 11 | - [ ] I consent to the [MIT project license](https://github.com/JohnMaguire/Cardinal/blob/master/LICENSE). 12 | - [ ] I have added my name to the [`CONTRIBUTORS`](https://github.com/JohnMaguire/Cardinal/blob/master/CONTRIBUTORS) file if desired (optional). 13 | -------------------------------------------------------------------------------- /cardinal/unittest_util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from contextlib import contextmanager 5 | 6 | 7 | @contextmanager 8 | def tempdir(name): 9 | tempdir_path = os.path.join(tempfile.gettempdir(), name) 10 | os.mkdir(tempdir_path) 11 | try: 12 | yield tempdir_path 13 | finally: 14 | shutil.rmtree(tempdir_path) 15 | 16 | 17 | def get_mock_db(): 18 | db = {} 19 | 20 | def get_db(name, network_specific=True, default=None): 21 | if default is not None: 22 | db.update(default) 23 | 24 | @contextmanager 25 | def mock_db(): 26 | yield db 27 | 28 | return mock_db 29 | 30 | return get_db, db 31 | -------------------------------------------------------------------------------- /cardinal/fixtures/fake_plugins/commands/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import command, regex 2 | 3 | 4 | class TestCommandsPlugin: 5 | def __init__(self): 6 | self.command1_calls = [] 7 | self.command2_calls = [] 8 | self.regex_command_calls = [] 9 | 10 | @command(['command1', 'command1_alias']) 11 | def command1(self, *args): 12 | self.command1_calls.append(args) 13 | 14 | @command('command2') 15 | def command2(self, *args): 16 | self.command2_calls.append(args) 17 | 18 | @regex('^regex') 19 | def regex_command(self, *args): 20 | self.regex_command_calls.append(args) 21 | 22 | 23 | def setup(): 24 | return TestCommandsPlugin() 25 | -------------------------------------------------------------------------------- /plugins/join_on_invite/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import event 2 | 3 | 4 | class InviteJoinPlugin: 5 | """Simple plugin that joins a channel if an invite is given.""" 6 | 7 | def __init__(self, cardinal, config): 8 | self.rejoin_on_kick = False 9 | if config: 10 | self.rejoin_on_kick = config.get('rejoin_on_kick', False) 11 | 12 | @event('irc.invite') 13 | def join_channel(self, cardinal, user, channel): 14 | """Callback for irc.invite that joins a channel""" 15 | cardinal.join(channel) 16 | 17 | @event('irc.kick') 18 | def rejoin_channel(self, cardinal, user, channel, nick, reason): 19 | if not self.rejoin_on_kick: 20 | return 21 | 22 | if nick == cardinal.nickname: 23 | cardinal.join(channel) 24 | 25 | 26 | entrypoint = InviteJoinPlugin 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Cardinal configuration files 4 | /config/* 5 | !/config/config.example.json 6 | 7 | # Docker-compose overrides 8 | docker-compose.*.yml 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Packages 14 | .Python 15 | *.egg 16 | *.egg-info 17 | dist 18 | build 19 | eggs 20 | parts 21 | bin 22 | var 23 | sdist 24 | develop-eggs 25 | .installed.cfg 26 | lib 27 | lib64 28 | include 29 | 30 | # Python venv 31 | venv/ 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | .coverage 39 | .tox 40 | nosetests.xml 41 | .cache 42 | 43 | # python-rope 44 | .ropeproject/ 45 | 46 | # Translations 47 | *.mo 48 | 49 | # Mr Developer 50 | .mr.developer.cfg 51 | .project 52 | .pydevproject 53 | 54 | # Sublime Text 2 55 | *.sublime-project 56 | *.sublime-workspace 57 | 58 | # vim 59 | *.swp 60 | 61 | # PyCharm 62 | .idea/ 63 | 64 | # Persistence files generated at runtime 65 | storage/database/* 66 | storage/logs/* 67 | 68 | # OS X 69 | .DS_Store 70 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Cardinal 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: ['3.9', '3.10', '3.11', '3.12'] 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | pip install -r test_requirements.txt 27 | find plugins -type f -name requirements.txt -exec pip install --no-cache-dir -r {} \; 28 | 29 | - name: Test with pytest 30 | run: pytest 31 | 32 | - name: Report coverage to Codecov 33 | uses: codecov/codecov-action@v4 34 | with: 35 | fail_ci_if_error: True 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | -------------------------------------------------------------------------------- /cardinal/exceptions.py: -------------------------------------------------------------------------------- 1 | class CardinalException(Exception): 2 | """Base class that all Cardinal exceptions extend.""" 3 | 4 | 5 | class LockInUseError(CardinalException): 6 | """Raised when a lock is unavailable.""" 7 | 8 | 9 | class PluginError(CardinalException): 10 | """Raised when a plugin is invalid in some way.""" 11 | 12 | 13 | class CommandNotFoundError(CardinalException): 14 | """Raised when a given command isn't loaded.""" 15 | 16 | 17 | class ConfigNotFoundError(CardinalException): 18 | """Raised when an expected plugin config isn't found.""" 19 | 20 | 21 | class EventAlreadyExistsError(CardinalException): 22 | """Raised durring attempt to register an event name already registered.""" 23 | 24 | 25 | class EventDoesNotExistError(CardinalException): 26 | """Raised during attempt to register a callback for a nonexistent event.""" 27 | 28 | 29 | class EventCallbackError(CardinalException): 30 | """Raised when there is an error with a callback.""" 31 | 32 | 33 | class EventRejectedMessage(CardinalException): 34 | """Raised when an event callback wants to reject an event.""" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2021 John Maguire 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /plugins/remind/plugin.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import error, reactor 2 | 3 | from cardinal.decorators import command, help 4 | 5 | 6 | class RemindPlugin: 7 | def __init__(self): 8 | self.call_ids = [] 9 | 10 | @command('remind') 11 | @help("Sends a reminder after a set time.") 12 | @help("Syntax: .remind ") 13 | def remind(self, cardinal, user, channel, msg): 14 | message = msg.split(None, 2) 15 | if len(message) < 3: 16 | cardinal.sendMsg(channel, "Syntax: .remind ") 17 | return 18 | 19 | self.call_ids.append(reactor.callLater(60 * int(message[1]), 20 | cardinal.sendMsg, user.nick, message[2])) 21 | 22 | cardinal.sendMsg(channel, 23 | "%s: You will be reminded in %d minutes." % 24 | (user.nick, int(message[1]))) 25 | 26 | def close(self): 27 | for call_id in self.call_ids: 28 | try: 29 | call_id.cancel() 30 | except error.AlreadyCancelled: 31 | pass 32 | 33 | 34 | entrypoint = RemindPlugin 35 | -------------------------------------------------------------------------------- /plugins/urls/test_plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from . import plugin 6 | 7 | 8 | class TestURLRegex: 9 | @staticmethod 10 | def assertFindUrl(message, url): 11 | m = plugin.get_urls(message) 12 | assert len(m) == 1 13 | assert m[0] == url 14 | 15 | @pytest.mark.parametrize("url,expected", [ 16 | ["http://tiny.cc/PiratesLive", "http://tiny.cc/PiratesLive"], 17 | ["http://tiny.cc/PiratesLive\x0f", "http://tiny.cc/PiratesLive"], 18 | ["http://tiny.cc/PiratesLive\x0f\x0f", "http://tiny.cc/PiratesLive"], 19 | ["\x1fhttp://tiny.cc/PiratesLive\x0f", "http://tiny.cc/PiratesLive"], 20 | ["\x1f\x0f\x0fhttp://tiny.cc/PiratesLive\x0f", "http://tiny.cc/PiratesLive"], 21 | ["\x1f\x0f\x0fhttp://tiny.cc/PiratesLive", "http://tiny.cc/PiratesLive"], 22 | ]) 23 | def test_url_cant_contain_control_characters(self, url, expected): 24 | self.assertFindUrl(url, expected) 25 | 26 | @pytest.mark.parametrize("url", [ 27 | "http://google.com/", 28 | "http://google.google/", 29 | "google.google", 30 | "google.com", 31 | "https://google.com/", 32 | "https://mail.google.com/u/0", 33 | "http://tiny.cc/PiratesLive", 34 | ]) 35 | def test_valid(self, url): 36 | self.assertFindUrl(url, url) 37 | -------------------------------------------------------------------------------- /plugins/8ball/plugin.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from twisted.internet import defer 4 | 5 | from cardinal.decorators import command, help 6 | from cardinal.util import sleep 7 | 8 | 9 | class Magic8BallPlugin: 10 | @command(['8', '8ball']) 11 | @help("Ask the might 8-ball a question.") 12 | @help("Syntax: .8 ") 13 | @defer.inlineCallbacks 14 | def answer(self, cardinal, user, channel, msg): 15 | if not (msg.endswith("?") and len(msg.split()) > 1): 16 | cardinal.sendMsg(channel, "Was that a question?") 17 | return 18 | 19 | cardinal.sendMsg( 20 | channel, 21 | "Let me dig deep into the waters of life, and find your answer." 22 | ) 23 | yield sleep(2) 24 | cardinal.sendMsg(channel, "Hmmm...") 25 | yield sleep(2) 26 | cardinal.sendMsg(channel, self._get_random_answer()) 27 | 28 | def _get_random_answer(self): 29 | answers = ['It is certain', 'It is decidedly so', 'Without a doubt', 30 | 'Yes definitely', 'You may rely on it', 'As I see it, yes', 31 | 'Most likely', 'Outlook good', 'Yes', 'Signs point to yes', 32 | 'Reply hazy try again', 'Ask again later', 33 | 'Better not tell you now', 'Cannot predict now', 34 | 'Concentrate and ask again', "Don't count on it", 35 | 'My reply is no', 'My sources say no', 36 | 'Outlook not so good', 'Very doubtful'] 37 | 38 | return random.choice(answers) 39 | 40 | 41 | entrypoint = Magic8BallPlugin 42 | -------------------------------------------------------------------------------- /cardinal/decorators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | _RETYPE = type(re.compile('foobar')) 5 | 6 | 7 | def command(triggers): 8 | if isinstance(triggers, str): 9 | triggers = [triggers] 10 | 11 | if not isinstance(triggers, list): 12 | raise TypeError("Command must be a trigger string or list of triggers") 13 | 14 | def wrap(f): 15 | f.commands = triggers 16 | return f 17 | 18 | return wrap 19 | 20 | 21 | def regex(expression): 22 | if (not isinstance(expression, str) and 23 | not isinstance(expression, _RETYPE)): 24 | raise TypeError("Regular expression must be a string or regex type") 25 | 26 | def wrap(f): 27 | f.regex = expression 28 | return f 29 | 30 | return wrap 31 | 32 | 33 | def help(lines): 34 | # For backwards compatibility 35 | if isinstance(lines, str): 36 | lines = [lines] 37 | 38 | if not isinstance(lines, list): 39 | raise TypeError("Help must be a help string or list of help strings") 40 | 41 | def wrap(f): 42 | # Create help list or prepend to it 43 | if not hasattr(f, 'help'): 44 | f.help = lines 45 | else: 46 | f.help = lines + f.help 47 | 48 | return f 49 | 50 | return wrap 51 | 52 | 53 | def event(triggers): 54 | if isinstance(triggers, str): 55 | triggers = [triggers] 56 | 57 | if not isinstance(triggers, list): 58 | raise TypeError("Event must be a trigger string or list of triggers") 59 | 60 | def wrap(f): 61 | f.events = triggers 62 | return f 63 | 64 | return wrap 65 | -------------------------------------------------------------------------------- /plugins/google/plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.decorators import command, help 2 | 3 | from googlesearch import search 4 | 5 | DEFAULT_MAX_RESULTS = 3 6 | 7 | 8 | class GoogleSearch: 9 | def __init__(self, config): 10 | config = config if config is not None else {} 11 | self.max_results = config.get('max_results', DEFAULT_MAX_RESULTS) 12 | 13 | @command(['google', 'lmgtfy', 'g']) 14 | @help("Returns the URL of the top result for a given search query") 15 | @help("Syntax: .google ") 16 | def query(self, cardinal, user, channel, msg): 17 | # gets search string from message, and makes it url safe 18 | try: 19 | search_string = msg.split(' ', 1)[1] 20 | except IndexError: 21 | return cardinal.sendMsg(channel, 'Syntax: .google ') 22 | 23 | urls = [] 24 | counter = self.max_results 25 | for url in search(search_string): 26 | urls.append(url) 27 | 28 | counter -= 1 29 | if counter == 0: 30 | break 31 | 32 | if not urls: 33 | cardinal.sendMsg(channel, "No results found") 34 | return 35 | elif len(urls) == 1: 36 | cardinal.sendMsg(channel, "Top result for '{}': {}".format( 37 | search_string, 38 | urls[0], 39 | )) 40 | else: 41 | cardinal.sendMsg(channel, "Top results for '{}':".format( 42 | search_string 43 | )) 44 | 45 | for url in urls: 46 | cardinal.sendMsg(channel, " {}".format(url)) 47 | 48 | 49 | entrypoint = GoogleSearch 50 | -------------------------------------------------------------------------------- /plugins/help/test_plugin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from unittest.mock import Mock, PropertyMock, call, patch 4 | 5 | from cardinal.bot import CardinalBot, user_info 6 | from plugins.help import plugin 7 | 8 | 9 | class TestHelpPlugin: 10 | def setup_method(self, method): 11 | self.mock_cardinal = Mock(spec=CardinalBot) 12 | self.plugin = plugin.HelpPlugin() 13 | 14 | @patch.object(plugin, 'datetime') 15 | def test_cmd_info(self, mock_datetime): 16 | channel = '#test' 17 | msg = '.info' 18 | 19 | now = datetime.now() 20 | mock_datetime.now = Mock(return_value=now) 21 | 22 | self.mock_cardinal.booted = now - timedelta(seconds=30) 23 | self.mock_cardinal.uptime = now - timedelta(seconds=15) 24 | self.mock_cardinal.config.return_value = { 25 | 'admins': [ 26 | {'nick': 'whoami'}, 27 | {'nick': 'test_foo'}, 28 | ] 29 | } 30 | 31 | self.plugin.cmd_info( 32 | self.mock_cardinal, 33 | user_info(None, None, None), 34 | channel, 35 | msg, 36 | ) 37 | 38 | assert self.mock_cardinal.sendMsg.mock_calls == [ 39 | call( 40 | channel, 41 | "I am a Python 3 IRC bot, online since 00:00:15. I initially " 42 | "connected 00:00:30 ago. My admins are: test_foo, whoami. " 43 | "Use .help to list commands." 44 | ), 45 | call( 46 | channel, 47 | "Visit https://github.com/JohnMaguire/Cardinal to learn more." 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /plugins/wolframalpha/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from twisted.internet import defer 5 | from twisted.internet.threads import deferToThread 6 | 7 | from cardinal.decorators import command, help 8 | 9 | 10 | API_URL = "https://api.wolframalpha.com/v1/result" 11 | 12 | 13 | class WolframAlphaPlugin: 14 | def __init__(self, config): 15 | self.app_id = config.get('app_id', None) 16 | if not self.app_id: 17 | raise Exception("Please set app_id in plugin config") 18 | self.logger = logging.getLogger(__name__) 19 | 20 | @defer.inlineCallbacks 21 | def make_query(self, query): 22 | self.logger.debug("Making query to Wolfram Alpha: %s", query) 23 | 24 | r = yield deferToThread(requests.get, API_URL, params={ 25 | 'appid': self.app_id, 26 | 'i': query, 27 | }) 28 | r.raise_for_status() 29 | answer = r.text 30 | 31 | self.logger.debug("Got answer for query '%s': %s", query, answer) 32 | 33 | return answer 34 | 35 | @command(['wolfram', 'calc']) 36 | @help('Make a query with Wolfram Alpha') 37 | @help('Syntax: .wolfram ') 38 | @defer.inlineCallbacks 39 | def wolfram(self, cardinal, user, channel, message): 40 | try: 41 | query = message.split(' ', 1)[1] 42 | except IndexError: 43 | cardinal.sendMsg(channel, "Syntax: .wolfram ") 44 | return 45 | 46 | try: 47 | answer = yield self.make_query(query) 48 | except Exception: 49 | cardinal.sendMsg(channel, "Couldn't parse the query or result") 50 | return 51 | 52 | cardinal.sendMsg(channel, "{}: {}".format(user.nick, answer)) 53 | 54 | 55 | entrypoint = WolframAlphaPlugin 56 | -------------------------------------------------------------------------------- /plugins/urbandict/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cardinal.decorators import command, help 4 | 5 | import requests 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | URBANDICT_API_PREFIX = 'http://api.urbandictionary.com/v0/define' 10 | 11 | 12 | class UrbanDictPlugin: 13 | def __init__(self): 14 | self.logger = logging.getLogger(__name__) 15 | 16 | @defer.inlineCallbacks 17 | @command(['ud', 'urbandict']) 18 | @help('Returns the top Urban Dictionary definition for a given word.') 19 | @help('Syntax: .ud ') 20 | def get_ud(self, cardinal, user, channel, msg): 21 | try: 22 | word = msg.split(' ', 1)[1] 23 | except IndexError: 24 | cardinal.sendMsg(channel, 'Syntax: .ud ') 25 | return 26 | 27 | try: 28 | url = URBANDICT_API_PREFIX 29 | r = yield deferToThread(requests.get, url, params={'term': word}) 30 | 31 | data = r.json() 32 | entry = data['list'].pop(0) 33 | 34 | definition = entry['definition'][0:300] 35 | if definition != entry['definition']: 36 | definition = definition + "…" 37 | thumbs_up = entry['thumbs_up'] 38 | thumbs_down = entry['thumbs_down'] 39 | link = entry['permalink'] 40 | 41 | response = 'UD [%s]: %s [\u25b2%d|\u25bc%d] - %s' % ( 42 | word, definition, thumbs_up, thumbs_down, link 43 | ) 44 | 45 | cardinal.sendMsg(channel, response) 46 | except Exception: 47 | self.logger.exception("Error with definition: %s", word) 48 | cardinal.sendMsg(channel, 49 | "Could not retrieve definition for %s" % word) 50 | 51 | 52 | entrypoint = UrbanDictPlugin 53 | -------------------------------------------------------------------------------- /plugins/random/plugin.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | 4 | from cardinal.decorators import command, help 5 | 6 | # Maximum number of dice we will roll at one time 7 | DICE_LIMIT = 10 8 | 9 | 10 | def parse_roll(arg): 11 | # some people might separate with commas 12 | arg = arg.rstrip(',') 13 | 14 | # regex intentionally does not match negatives 15 | if match := re.match(r'^(\d+)d(\d+)$', arg): 16 | num_dice = int(match.group(1)) 17 | sides = int(match.group(2)) 18 | elif match := re.match(r'^d?(\d+)$', arg): 19 | num_dice = 1 20 | sides = int(match.group(1)) 21 | else: 22 | return [] 23 | 24 | # Ignore 1-sided dice, or large dice 25 | if sides < 2 or sides > 120: 26 | return [] 27 | 28 | # Don't let people exhaust memory 29 | if num_dice > 10: 30 | num_dice = 10 31 | 32 | return [sides] * num_dice 33 | 34 | 35 | class RandomPlugin: 36 | @command('roll') 37 | @help("Roll dice") 38 | @help("Syntax: .roll #d# (e.g. .roll 2d6)") 39 | def roll(self, cardinal, user, channel, msg): 40 | args = msg.split(' ') 41 | args.pop(0) 42 | if not args: 43 | return 44 | 45 | dice = [] 46 | for arg in args: 47 | dice = dice + parse_roll(arg) 48 | if len(dice) >= DICE_LIMIT: 49 | break 50 | 51 | dice = dice[:DICE_LIMIT] 52 | 53 | # Ignore things like 2d0, 0d2, etc. 54 | if not dice: 55 | return 56 | 57 | results = [] 58 | sum_ = 0 59 | for sides in dice: 60 | roll = random.randint(1, sides) 61 | 62 | results.append((sides, roll)) 63 | sum_ += roll 64 | 65 | count = len(dice) 66 | messages = ' '.join( 67 | ["Rolling {} {}...".format(count, "die" if count < 2 else "dice")] 68 | + [f"d{sides}:{result}" for sides, result in results] 69 | + [f"Total: {sum_}"] 70 | ) 71 | 72 | cardinal.sendMsg(channel, messages) 73 | 74 | 75 | entrypoint = RandomPlugin 76 | -------------------------------------------------------------------------------- /plugins/timezone/plugin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytz 4 | from pytz.exceptions import UnknownTimeZoneError 5 | 6 | from cardinal.decorators import command, help 7 | 8 | TIME_FORMAT = '%b %d, %I:%M:%S %p UTC%z' 9 | 10 | 11 | class TimezonePlugin: 12 | @command(['time']) 13 | @help("Returns the current time in a given time zone or GMT offset.") 14 | @help("Syntax: time ") 15 | def get_time(self, cardinal, user, channel, msg): 16 | utc = pytz.utc 17 | now = datetime.now(utc) 18 | 19 | try: 20 | tz_input = msg.split(' ', 1)[1].strip() 21 | except IndexError: 22 | # no timezone specified, default to UTC 23 | return cardinal.sendMsg(channel, now.strftime(TIME_FORMAT)) 24 | 25 | # handle common offset formats 26 | if (tz_input.startswith('UTC') or tz_input.startswith('GMT')) and \ 27 | len(tz_input) > 3 and tz_input[3] in ('+', '-'): 28 | tz_input = tz_input[3:] 29 | 30 | offset = None 31 | try: 32 | offset = int(tz_input) 33 | except ValueError: 34 | pass 35 | 36 | if offset is not None: 37 | try: 38 | if offset < 0: 39 | # for some reason the GMT+4 == America/Eastern, and GMT-4 40 | # is over in Asia 41 | user_tz = pytz.timezone('Etc/GMT+{0}'.format(offset * -1)) 42 | elif offset > 0: 43 | user_tz = pytz.timezone('Etc/GMT{0}'.format(offset * -1)) 44 | else: 45 | user_tz = utc 46 | except UnknownTimeZoneError: 47 | return cardinal.sendMsg(channel, 'Invalid UTC offset') 48 | else: 49 | try: 50 | user_tz = pytz.timezone(tz_input) 51 | except UnknownTimeZoneError: 52 | return cardinal.sendMsg(channel, 'Invalid timezone') 53 | 54 | now = user_tz.normalize(now) 55 | cardinal.sendMsg(channel, now.strftime(TIME_FORMAT)) 56 | 57 | 58 | entrypoint = TimezonePlugin 59 | -------------------------------------------------------------------------------- /config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "Cardinal", 3 | "password": "NICKSERV PASSWORD", 4 | "username": "cardinal", 5 | "realname": "Another IRC bot", 6 | "network": "irc.darkscience.net", 7 | "server_password": "SERVER PASSWORD (IF NECESSARY)", 8 | "port": 6697, 9 | "ssl": true, 10 | "storage": "storage/", 11 | "channels": [ 12 | "#bots" 13 | ], 14 | 15 | "plugins": [ 16 | "admin", 17 | "github", 18 | "google", 19 | "help", 20 | "join_on_invite", 21 | "lastfm", 22 | "ping", 23 | "remind", 24 | "sed", 25 | "seen", 26 | "timezone", 27 | "urbandict", 28 | "urls", 29 | "weather", 30 | "wikipedia", 31 | "youtube" 32 | ], 33 | 34 | "censored_words": { 35 | "supernets": "s-nets" 36 | }, 37 | 38 | "logging": { 39 | "version": 1, 40 | 41 | "formatters": { 42 | "simple": { 43 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 44 | } 45 | }, 46 | 47 | "handlers": { 48 | "console": { 49 | "class": "logging.StreamHandler", 50 | "level": "DEBUG", 51 | "formatter": "simple", 52 | "stream": "ext://sys.stderr" 53 | }, 54 | 55 | "file": { 56 | "class": "logging.handlers.RotatingFileHandler", 57 | "level": "INFO", 58 | "formatter": "simple", 59 | "filename": "storage/logs/cardinal.log", 60 | "maxBytes": 10485760, 61 | "backupCount": 10, 62 | "encoding": "utf8" 63 | }, 64 | 65 | "null": { 66 | "class": "logging.NullHandler" 67 | } 68 | }, 69 | 70 | "loggers": { 71 | "": { 72 | "level": "DEBUG", 73 | "handlers": ["console", "file"] 74 | }, 75 | 76 | "cardinal.bot.irc": { 77 | "propagate": false, 78 | "level": "INFO", 79 | "handlers": ["null"] 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /plugins/twitter/plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlparse 3 | 4 | import requests 5 | import twitter 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | from cardinal.decorators import event 10 | from cardinal.exceptions import EventRejectedMessage 11 | 12 | 13 | class TwitterPlugin: 14 | def __init__(self, config): 15 | consumer_key = config['consumer_key'] 16 | consumer_secret = config['consumer_secret'] 17 | 18 | if not all([consumer_key, consumer_secret]): 19 | raise Exception( 20 | "Twitter plugin requires consumer_key and consumer_secret" 21 | ) 22 | 23 | self.api = twitter.Api( 24 | consumer_key=consumer_key, 25 | consumer_secret=consumer_secret, 26 | application_only_auth=True, 27 | ) 28 | 29 | @defer.inlineCallbacks 30 | def get_tweet(self, tweet_id): 31 | tweet = yield deferToThread(self.api.GetStatus, 32 | tweet_id) 33 | 34 | return tweet 35 | 36 | @defer.inlineCallbacks 37 | def follow_short_link(self, url): 38 | r = yield deferToThread(requests.get, 39 | url) 40 | 41 | # Twitter returns 400 in normal operation 42 | if not r.ok and r.status_code != 400: 43 | r.raise_for_status() 44 | 45 | return r.url 46 | 47 | @event('urls.detection') 48 | @defer.inlineCallbacks 49 | def handle_tweet(self, cardinal, channel, url): 50 | o = urlparse(url) 51 | 52 | # handle t.co short links 53 | if o.netloc == 't.co': 54 | url = yield self.follow_short_link(url) 55 | o = urlparse(url) 56 | 57 | if o.netloc in ('twitter.com', 'mobile.twitter.com') \ 58 | and (match := re.match(r'^/.*/status/(\d+)$', o.path)): 59 | tweet_id = match.group(1) 60 | 61 | t = yield self.get_tweet(tweet_id) 62 | cardinal.sendMsg(channel, "Tweet from @{}: {}".format( 63 | t.user.screen_name, 64 | t.text, 65 | )) 66 | else: 67 | raise EventRejectedMessage 68 | 69 | 70 | entrypoint = TwitterPlugin 71 | -------------------------------------------------------------------------------- /plugins/imgur/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from urllib.parse import urlparse 4 | 5 | import requests 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | from cardinal.decorators import event 10 | from cardinal.exceptions import EventRejectedMessage 11 | 12 | 13 | class ImgurPlugin: 14 | def __init__(self, config): 15 | self.logger = logging.getLogger(__name__) 16 | self.api = ImgurApi(config['client_id']) 17 | 18 | @event("urls.detection") 19 | @defer.inlineCallbacks 20 | def handle_url(self, cardinal, channel, url): 21 | o = urlparse(url) 22 | if o.netloc not in ('i.imgur.com', 'imgur.com'): 23 | raise EventRejectedMessage 24 | 25 | # it's not really possible to tell if this is an image or an album 26 | if match := re.match(r'^(?:.*?)/(\w+)(?:\.\w+)?$', o.path): 27 | imgur_hash = match.group(1) 28 | try: 29 | image = yield self.api.get_image(imgur_hash) 30 | cardinal.sendMsg(channel, self.format_image(image)) 31 | return 32 | except requests.exceptions.HTTPError: 33 | # probably not an image. no support for other types currently 34 | raise EventRejectedMessage 35 | 36 | raise EventRejectedMessage 37 | 38 | def format_image(self, image): 39 | # [imgur] 86533 views image/jpeg 1900x1200 [nsfw] 40 | return f"[imgur] {image['views']:,} views " \ 41 | f"{image['type']} {image['width']}x{image['height']}" + ( 42 | " [nsfw]" if image['nsfw'] else "" 43 | ) 44 | 45 | 46 | class ImgurApi: 47 | API_URL = "https://api.imgur.com/3" 48 | 49 | def __init__(self, client_id): 50 | self.client_id = client_id 51 | 52 | @defer.inlineCallbacks 53 | def _make_request(self, url): 54 | r = yield deferToThread( 55 | requests.get, 56 | url, 57 | headers={'Authorization': f'Client-ID {self.client_id}'}, 58 | ) 59 | 60 | r.raise_for_status() 61 | 62 | res = r.json() 63 | if not res['success']: 64 | raise Exception("Error during imgur request: {}", res) 65 | 66 | return res['data'] 67 | 68 | @defer.inlineCallbacks 69 | def get_image(self, image_hash): 70 | return (yield self._make_request( 71 | f"{self.API_URL}/image/{image_hash}")) 72 | 73 | @defer.inlineCallbacks 74 | def get_album(self, album_hash): 75 | return (yield self._make_request( 76 | f"{self.API_URL}/albums/{album_hash}")) 77 | 78 | 79 | entrypoint = ImgurPlugin 80 | -------------------------------------------------------------------------------- /cardinal/test_util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from twisted.internet import defer 5 | 6 | from cardinal import util 7 | 8 | 9 | @pytest.mark.parametrize('message,expected', ( 10 | ('\x01ACTION tests\x01', True), 11 | ('\x01ACTION tests', True), 12 | ('\x01ACTION', True), 13 | ('ACTION tests', False), 14 | )) 15 | def test_is_action(message, expected): 16 | assert util.is_action(message) == expected 17 | 18 | 19 | @pytest.mark.parametrize('nick,message,expected', ( 20 | ('Cardinal', '\x01ACTION tests\x01', '* Cardinal tests'), 21 | ('Cardinal', '\x01ACTION tests', '* Cardinal tests'), 22 | ('Cardinal', '\x01ACTION \x01', '* Cardinal'), 23 | ('Cardinal', '\x01ACTION ', '* Cardinal'), 24 | ('Cardinal', '\x01ACTION\x01', '* Cardinal'), 25 | ('Cardinal', '\x01ACTION', '* Cardinal'), 26 | )) 27 | def test_parse_action(nick, message, expected): 28 | assert util.parse_action(nick, message) == expected 29 | 30 | 31 | def test_parse_action_raises(): 32 | with pytest.raises(ValueError): 33 | util.parse_action('Cardinal', 'this is not an action!') 34 | 35 | 36 | @pytest.mark.parametrize("input_,expected", ( 37 | ('\x02bold\x02', 'bold'), 38 | ('\x0309colored\x03', 'colored'), 39 | # a naive implementation may return 45 40 | ('\x03\x033,012345', '2345'), 41 | )) 42 | def test_strip_formatting(input_, expected): 43 | assert util.strip_formatting(input_) == expected 44 | 45 | 46 | @defer.inlineCallbacks 47 | def test_sleep(): 48 | now = datetime.datetime.now() 49 | yield util.sleep(1) 50 | delta = datetime.datetime.now() - now 51 | assert delta.seconds == 1 52 | 53 | 54 | class TestColors: 55 | @pytest.mark.parametrize('color,color_value', ( 56 | ('white', 0), 57 | ('black', 1), 58 | ('blue', 2), 59 | ('green', 3), 60 | ('light_red', 4), 61 | ('brown', 5), 62 | ('purple', 6), 63 | ('orange', 7), 64 | ('yellow', 8), 65 | ('light_green', 9), 66 | ('cyan', 10), 67 | ('light_cyan', 11), 68 | ('light_blue', 12), 69 | ('pink', 13), 70 | ('grey', 14), 71 | ('light_grey', 15), 72 | )) 73 | def test_colors(self, color, color_value): 74 | text = 'sample message' 75 | 76 | f = getattr(util.F.C, color) 77 | 78 | # foreground color numbers must be zero-padded to a width of 2 79 | assert f(text) == '\x03{:02}{}\x03'.format(color_value, text) 80 | 81 | 82 | class TestFormatting: 83 | @pytest.mark.parametrize('func,hex_code', ( 84 | ('bold', "\x02"), 85 | ('monospace', "\x11"), 86 | ('italic', "\x1d"), 87 | ('strikethrough', "\x1e"), 88 | ('underline', "\x1f"), 89 | )) 90 | def test_colors(self, func, hex_code): 91 | text = 'sample message' 92 | 93 | f = getattr(util.F, func) 94 | 95 | # foreground color numbers must be zero-padded to a width of 2 96 | assert f(text) == '{}{}{}'.format(hex_code, text, hex_code) 97 | -------------------------------------------------------------------------------- /plugins/admin/test_plugin.py: -------------------------------------------------------------------------------- 1 | from cardinal.bot import user_info 2 | from plugins.admin.plugin import AdminPlugin 3 | 4 | 5 | class TestAdminPlugin: 6 | def test_admins_translation(self): 7 | plugin = AdminPlugin(None, { 8 | 'admins': [ 9 | {'nick': 'nick', 'user': 'user', 'vhost': 'vhost'}, 10 | {'nick': 'nick', 'user': 'user'}, 11 | {'nick': 'nick', 'vhost': 'vhost'}, 12 | {'user': 'user', 'vhost': 'vhost'}, 13 | {'nick': 'nick'}, 14 | {'user': 'user'}, 15 | {'vhost': 'vhost'}, 16 | ] 17 | }) 18 | 19 | assert plugin.admins == [ 20 | user_info('nick', 'user', 'vhost'), 21 | user_info('nick', 'user', None), 22 | user_info('nick', None, 'vhost'), 23 | user_info(None, 'user', 'vhost'), 24 | user_info('nick', None, None), 25 | user_info(None, 'user', None), 26 | user_info(None, None, 'vhost'), 27 | ] 28 | 29 | def test_no_config(self): 30 | plugin = AdminPlugin(None, None) 31 | assert plugin.admins == [] 32 | 33 | def test_is_admin(self): 34 | plugin = AdminPlugin(None, {'admins': [{'nick': 'nick'}]}) 35 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 36 | assert plugin.is_admin(user_info('bad_nick', 'user', 'vhost')) is False 37 | 38 | plugin = AdminPlugin(None, {'admins': [{'user': 'user'}]}) 39 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 40 | assert plugin.is_admin(user_info('nick', 'bad_user', 'vhost')) is False 41 | 42 | plugin = AdminPlugin(None, {'admins': [{'vhost': 'vhost'}]}) 43 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 44 | assert plugin.is_admin(user_info('nick', 'user', 'bad_vhost')) is False 45 | 46 | plugin = AdminPlugin(None, 47 | {'admins': [{'nick': 'nick', 'vhost': 'vhost'}]}) 48 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 49 | assert plugin.is_admin(user_info('bad_nick', 'user', 'vhost')) is False 50 | assert plugin.is_admin(user_info('nick', 'user', 'bad_vhost')) is False 51 | 52 | plugin = AdminPlugin(None, 53 | {'admins': [{'user': 'user', 'vhost': 'vhost'}]}) 54 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 55 | assert plugin.is_admin(user_info('nick', 'bad_user', 'vhost')) is False 56 | assert plugin.is_admin(user_info('nick', 'user', 'bad_vhost')) is False 57 | 58 | plugin = AdminPlugin(None, 59 | {'admins': [{'nick': 'nick', 'user': 'user'}]}) 60 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 61 | assert plugin.is_admin(user_info('bad_nick', 'user', 'vhost')) is False 62 | assert plugin.is_admin(user_info('nick', 'bad_user', 'vhost')) is False 63 | 64 | plugin = AdminPlugin(None, 65 | {'admins': [{'nick': 'nick', 66 | 'user': 'user', 67 | 'vhost': 'vhost'}]}) 68 | assert plugin.is_admin(user_info('nick', 'user', 'vhost')) is True 69 | assert plugin.is_admin(user_info('bad_nick', 'user', 'vhost')) is False 70 | assert plugin.is_admin(user_info('nick', 'bad_user', 'vhost')) is False 71 | assert plugin.is_admin(user_info('nick', 'user', 'bad_vhost')) is False 72 | -------------------------------------------------------------------------------- /cardinal/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from twisted.internet import reactor 4 | from twisted.internet.task import deferLater 5 | 6 | 7 | def is_action(message): 8 | """Checks if a message is a /me message.""" 9 | return message.startswith("\x01ACTION") 10 | 11 | 12 | def parse_action(nick, message): 13 | """Parses a /me message like an IRC client might. 14 | 15 | e.g. "/me dances." -> "* Cardinal dances." 16 | """ 17 | if not is_action(message): 18 | raise ValueError("This message is not an ACTION message") 19 | 20 | message = message[len("\x01ACTION "):] 21 | if message and message[-1] == "\x01": 22 | message = message[:-1] 23 | 24 | return "* {}{}".format( 25 | nick, 26 | " " + message if message else "", 27 | ) 28 | 29 | 30 | def sleep(secs): 31 | """Async sleep function""" 32 | return deferLater(reactor, secs, lambda: None) 33 | 34 | 35 | def strip_formatting(line): 36 | """Removes mIRC control code formatting""" 37 | return re.sub(r"(?:\x03\d\d?,\d\d?|\x03\d\d?|[\x01-\x1f])", "", line) 38 | 39 | 40 | class formatting: 41 | class color: 42 | @staticmethod 43 | def white(text): 44 | return "\x0300{}\x03".format(text) 45 | 46 | @staticmethod 47 | def black(text): 48 | return "\x0301{}\x03".format(text) 49 | 50 | @staticmethod 51 | def blue(text): 52 | return "\x0302{}\x03".format(text) 53 | 54 | @staticmethod 55 | def green(text): 56 | return "\x0303{}\x03".format(text) 57 | 58 | @staticmethod 59 | def light_red(text): 60 | return "\x0304{}\x03".format(text) 61 | 62 | @staticmethod 63 | def brown(text): 64 | return "\x0305{}\x03".format(text) 65 | 66 | @staticmethod 67 | def purple(text): 68 | return "\x0306{}\x03".format(text) 69 | 70 | @staticmethod 71 | def orange(text): 72 | return "\x0307{}\x03".format(text) 73 | 74 | @staticmethod 75 | def yellow(text): 76 | return "\x0308{}\x03".format(text) 77 | 78 | @staticmethod 79 | def light_green(text): 80 | return "\x0309{}\x03".format(text) 81 | 82 | @staticmethod 83 | def cyan(text): 84 | return "\x0310{}\x03".format(text) 85 | 86 | @staticmethod 87 | def light_cyan(text): 88 | return "\x0311{}\x03".format(text) 89 | 90 | @staticmethod 91 | def light_blue(text): 92 | return "\x0312{}\x03".format(text) 93 | 94 | @staticmethod 95 | def pink(text): 96 | return "\x0313{}\x03".format(text) 97 | 98 | @staticmethod 99 | def grey(text): 100 | return "\x0314{}\x03".format(text) 101 | gray = grey 102 | 103 | @staticmethod 104 | def light_grey(text): 105 | return "\x0315{}\x03".format(text) 106 | light_gray = light_grey 107 | 108 | # alias as this will be used commonly 109 | C = color 110 | 111 | @staticmethod 112 | def bold(text): 113 | return "\x02{}\x02".format(text) 114 | 115 | @staticmethod 116 | def monospace(text): 117 | return "\x11{}\x11".format(text) 118 | 119 | @staticmethod 120 | def italic(text): 121 | return "\x1d{}\x1d".format(text) 122 | 123 | @staticmethod 124 | def strikethrough(text): 125 | return "\x1e{}\x1e".format(text) 126 | 127 | @staticmethod 128 | def underline(text): 129 | return "\x1f{}\x1f".format(text) 130 | 131 | reset = "\x03" 132 | 133 | 134 | # alias as this will be used commonly 135 | F = formatting 136 | -------------------------------------------------------------------------------- /plugins/wikipedia/plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from mediawiki import MediaWiki 5 | from mediawiki.exceptions import DisambiguationError 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | from cardinal.decorators import command, event, help 10 | from cardinal.exceptions import EventRejectedMessage 11 | 12 | ARTICLE_URL_REGEX = r"https?://(?:\w{2}\.)?wikipedia\..{2,4}/wiki/(.+)" 13 | 14 | DEFAULT_LANGUAGE_CODE = 'en' 15 | DEFAULT_MAX_DESCRIPTION_LENGTH = 250 16 | 17 | 18 | # This is used to filter out blank paragraphs 19 | def class_is_not_mw_empty_elt(css_class): 20 | return css_class != 'mw-empty-elt' 21 | 22 | 23 | class WikipediaPlugin: 24 | def __init__(self, cardinal, config): 25 | """Registers a callback for URL detection.""" 26 | # Initialize logger 27 | self.logger = logging.getLogger(__name__) 28 | 29 | self._max_description_length = config.get( 30 | 'max_description_length', 31 | DEFAULT_MAX_DESCRIPTION_LENGTH) 32 | 33 | self._language_code = config.get( 34 | 'language_code', 35 | DEFAULT_LANGUAGE_CODE) 36 | 37 | self._wiki = None 38 | 39 | @defer.inlineCallbacks 40 | def get_wiki(self): 41 | if self._wiki: 42 | return self._wiki 43 | 44 | # makes a network call 45 | def get_wiki(): 46 | self._wiki = MediaWiki( 47 | lang=self._language_code) 48 | 49 | yield deferToThread(get_wiki) 50 | 51 | return self._wiki 52 | 53 | @defer.inlineCallbacks 54 | def _get_article_info(self, name): 55 | try: 56 | w = yield self.get_wiki() 57 | p = yield deferToThread(w.page, name) 58 | # FIXME 59 | except DisambiguationError as err: 60 | options = "{}".format(', '.join(err.options[:10])) 61 | if len(err.options) > 10: 62 | options += ", and {} more".format(len(err.options) - 10) 63 | 64 | return "Wikipedia Disambiguation: {options} - {url}".format( 65 | options=options, 66 | url=err.url, 67 | ) 68 | 69 | # makes a network call 70 | def get_summary(p): 71 | return "{}...".format(p.summary[:self._max_description_length]) \ 72 | if len(p.summary) > self._max_description_length else \ 73 | p.summary 74 | summary = yield deferToThread(get_summary, p) 75 | 76 | return "Wikipedia: {title} - {summary} - {url}".format( 77 | title=p.title, 78 | summary=summary, 79 | url=p.url, 80 | ) 81 | 82 | @event('urls.detection') 83 | @defer.inlineCallbacks 84 | def url_callback(self, cardinal, channel, url): 85 | match = re.match(ARTICLE_URL_REGEX, url) 86 | if not match: 87 | raise EventRejectedMessage 88 | 89 | try: 90 | article_info = yield self._get_article_info(match.group(1)) 91 | except Exception: 92 | self.logger.exception("Error reading Wikipedia API for: {}".format( 93 | match.group(1))) 94 | raise EventRejectedMessage 95 | 96 | cardinal.sendMsg(channel, article_info) 97 | 98 | @command(['wiki', 'wikipedia']) 99 | @help("Gets a summary and link to a Wikipedia page") 100 | @help("Syntax: .wiki
") 101 | @defer.inlineCallbacks 102 | def wiki(self, cardinal, user, channel, message): 103 | name = message.split(' ', 1)[1] 104 | 105 | article_info = yield self._get_article_info(name) 106 | cardinal.sendMsg(channel, article_info) 107 | 108 | 109 | entrypoint = WikipediaPlugin 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Cardinal](./_assets/cardinal.svg)](https://github.com/JohnMaguire/Cardinal) 2 | 3 | # Meet Cardinal. 4 | 5 | [![Build Status](https://github.com/JohnMaguire/Cardinal/workflows/Cardinal/badge.svg)](https://github.com/JohnMaguire/Cardinal/actions?query=workflow%3ACardinal) [![Coverage Status](https://codecov.io/github/JohnMaguire/Cardinal/coverage.svg?branch=master)](https://codecov.io/github/JohnMaguire/Cardinal?branch=master) 6 | 7 | Cardinal is a Python Twisted IRC bot with a focus on ease of development. It features reloadable asynchronous plugins, [Python decorators for commands](https://github.com/JohnMaguire/Cardinal/wiki/Writing-Plugins#adding-commands-to-a-plugin) and [IRC events](https://github.com/JohnMaguire/Cardinal/wiki/Cardinal-Events), [simple persistent JSON data storage](https://github.com/JohnMaguire/Cardinal/wiki/Database-Access), and [a well-documented API](https://github.com/JohnMaguire/Cardinal/wiki/Cardinal-Methods). 8 | 9 | You can join [#cardinal](https://www.mibbit.com/#cardinal@irc.darkscience.net:+6697) on the [DarkScience](http://www.darkscience.net/) IRC network for questions or support. (irc.darkscience.net/+6697 — SSL required) 10 | 11 | ## What can Cardinal do? 12 | 13 | Anything, if you're creative! Cardinal does come with some plugins to get you started... 14 | 15 | * Fetching URL titles 16 | * Wolfram Alpha calculations 17 | * Wikipedia definitions 18 | * Urban Dictionary definitions 19 | * Movie and TV show lookups 20 | * Weather reports 21 | * Reminders 22 | * Google searches 23 | * Now playing w/ Last.fm 24 | * Stock ticker 25 | * sed-like substitutions 26 | * ... and more! 27 | 28 | But the best part of Cardinal is how easy it is to add more! 29 | 30 | ## Basic Usage 31 | 32 | ### Configuration 33 | 34 | 1. Copy the `config/config.example.json` file to `config/config.json` (you can use another filename as well, such as `config.freenode.json` if you plan to run Cardinal on multiple networks). 35 | 36 | 2. Copy `plugins/admin/config.example.json` to `plugins/admin/config.json` and add your `nick` and `vhost` in order to take advantage of admin-only commands (such as reloading plugins, telling Cardinal to join a channel, or blacklisting plugins within a channel). 37 | 38 | ### Running 39 | 40 | Cardinal is run via Docker. To get started, install [Docker](https://docs.docker.com/install/) and [docker-compose](https://docs.docker.com/compose/install/). 41 | 42 | If your config file is named something other than `config/config.json`, you will need to create a `docker-compose.override.yml` file like so: 43 | 44 | ```yaml 45 | version: "2.1" 46 | services: 47 | cardinal: 48 | command: config/config_file_name.json 49 | ``` 50 | 51 | To start Cardinal, run `docker-compose up -d`. To restart Cardinal, run `docker-compose restart`. To stop Cardinal, run `docker-compose down`. 52 | 53 | ## Writing Plugins 54 | 55 | Cardinal was designed with ease of development in mind. 56 | 57 | ```python 58 | from cardinal.decorators import command, help 59 | 60 | class HelloWorldPlugin: 61 | @command(['hello', 'hi']) 62 | @help("Responds to the user with a greeting.") 63 | @help("Syntax: .hello") 64 | def hello(self, cardinal, user, channel, msg): 65 | nick, ident, vhost = user 66 | cardinal.sendMsg(channel, "Hello {}!".format(nick)) 67 | 68 | entrypoint = HelloWorldPlugin 69 | ``` 70 | 71 | Cardinal also offers a [lightweight database API](https://github.com/JohnMaguire/Cardinal/wiki/Database-Access). [Visit the wiki](https://github.com/JohnMaguire/Cardinal/wiki/Writing-Plugins) for detailed information. 72 | 73 | ## Contributing 74 | 75 | Cardinal is a public, open-source project, licensed under the [MIT License](LICENSE). [Anyone may contribute.](CONTRIBUTING.md) 76 | 77 | When submitting a pull request, you may add your name to the [CONTRIBUTORS](CONTRIBUTORS) file. 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Help us to help you! 2 | 3 | Thank you for taking the time to contribute! 4 | 5 | * [Suggesting a feature](#suggesting-a-feature) 6 | * [Filing a bug report](#filing-a-bug-report) 7 | * [Submitting a pull request](#submitting-a-pull-request) 8 | 9 | ## Suggesting a feature 10 | 11 | We can't think of everything. If you've got a good idea for a feature or plugin, then please let us know! 12 | 13 | Feature suggestions are embraced, but will often be filed for a rainy day. If you require a feature urgently it's best to write it yourself. Please consider sharing your work! 14 | 15 | When suggesting a feature, make sure to: 16 | 17 | * Check the code on GitHub to make sure it's not already hiding in an unreleased version ;) 18 | * Check existing issues, open and closed, to make sure it hasn't already been suggested 19 | 20 | ## Filing a bug report 21 | 22 | If you're having trouble with Cardinal, the fastest way to get a response is to join us at [#cardinal](https://www.mibbit.com/#cardinal@irc.darkscience.net:+6697) on the DarkScience IRC network. (irc.darkscience.net/+6697 &emdash; SSL required) 23 | 24 | If all else fails then please raise an Issue to let us know. Be as detailed as possible, and be ready to answer questions when we get back to you. Some information that will help: 25 | 26 | * Tell us which version of Cardinal you're running: `git describe --tags` 27 | * Tell us which version of Docker you're running: `docker version` 28 | * List the steps you've taken so far and any solutions you've tried 29 | * Logs around the time of the issue (Cardinal and IRC logs as relevant) 30 | 31 | ## Submitting a pull request 32 | 33 | If you've decided to fix a bug, even something as small as a single-letter typo then great! Anything that improves the code/documentation for all future users is warmly welcomed. 34 | 35 | If you decide to work on a requested feature it's best to let us (and everyone else) know what you're working on to avoid any duplication of effort. You can do this by replying to the original Issue for the request. 36 | 37 | If you want to contribute an example, go for it! [The wiki](https://github.com/JohnMaguire/Cardinal/wiki) would be a great place to start. 38 | 39 | When contributing a new example or making a change to a library please keep your code style consistent with ours. We try to stick to the [PEP 8 guidelines](https://www.python.org/dev/peps/pep-0008/) for Python. 40 | 41 | #### Do 42 | 43 | * Do use PEP 8 style guidelines 44 | * Do comment your code where necessary 45 | * Do submit only a single plugin/feature/bugfix per pull-request 46 | * Do include a description of what your code is expected to do 47 | 48 | #### Don't 49 | 50 | * Don't include any license information in your code - our repositories are MIT licensed 51 | * Don't try to do too much at once - each pull request should contain a single idea 52 | 53 | ### Licensing 54 | 55 | When you submit code to our libraries, you implicitly and irrevocably agree to adopt the associated licenses. You should be able to find this in the file named `LICENSE`. 56 | 57 | We use the MIT license; which permits Commercial Use, Modification, Distribution and Private use of our code, and therefore also your contributions. It also provides good compatibility with other licenses, and is intended to make re-use of our code as painless as possible for all parties. 58 | 59 | You can learn more about the MIT license at Wikipedia: https://en.wikipedia.org/wiki/MIT_License 60 | 61 | ### Submitting your code 62 | 63 | Once you're ready to share your contribution with us you should submit it as a Pull Request. 64 | 65 | * Be ready to receive and embrace constructive feedback. 66 | * Be prepared for rejection; we can't always accept contributions. If you're unsure, ask first! 67 | * If you like, add your name to the `CONTRIBUTORS` file as part of your pull request! It's not a requirement, but we like to show thanks for contributions. 68 | 69 | ## Thank you! 70 | 71 | 72 | Happy hacking! 73 | 74 | -- Cardinal development 75 | -------------------------------------------------------------------------------- /cardinal/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from cardinal.config import ConfigParser, ConfigSpec 6 | 7 | FIXTURE_DIRECTORY = os.path.join( 8 | os.path.dirname(os.path.realpath(__file__)), 9 | 'fixtures', 10 | ) 11 | 12 | 13 | class TestConfigSpec: 14 | def setup_method(self): 15 | self.config_spec = ConfigSpec() 16 | 17 | @pytest.mark.parametrize("option", [ 18 | ('name', str, 'default'), 19 | (u'name', str, None), 20 | ('name', int, 3), 21 | ]) 22 | def test_add_option(self, option): 23 | self.config_spec.add_option(option[0], option[1], option[2]) 24 | 25 | def test_add_option_invalid_name(self): 26 | with pytest.raises(TypeError): 27 | self.config_spec.add_option(3, int) 28 | 29 | def test_add_option_invalid_type(self): 30 | with pytest.raises(TypeError): 31 | self.config_spec.add_option('name', 'string') 32 | 33 | def test_return_value_or_default_nonexistent(self): 34 | with pytest.raises(KeyError): 35 | self.config_spec.return_value_or_default('foobar', 30) 36 | 37 | def test_return_value_or_default_wrong_type(self): 38 | name = 'name' 39 | default = 'default' 40 | self.config_spec.add_option(name, str, default) 41 | assert self.config_spec.return_value_or_default(name, 3) == default 42 | 43 | def test_return_value_or_default_none(self): 44 | name = 'name' 45 | default = 'default' 46 | self.config_spec.add_option(name, str, default) 47 | assert self.config_spec.return_value_or_default(name, None) == default 48 | 49 | def test_return_value_or_default_value(self): 50 | name = 'name' 51 | default = 'default' 52 | self.config_spec.add_option(name, str, default) 53 | 54 | value = 'value' 55 | assert self.config_spec.return_value_or_default(name, value) == value 56 | 57 | 58 | class TestConfigParser: 59 | DEFAULT = '_default_' 60 | 61 | def setup_method(self): 62 | config_spec = self.config_spec = ConfigSpec() 63 | config_spec.add_option("not_in_json", str, default=self.DEFAULT) 64 | config_spec.add_option("string", str) 65 | config_spec.add_option("int", int) 66 | config_spec.add_option("bool", bool) 67 | config_spec.add_option("dict", dict) 68 | 69 | self.config_parser = ConfigParser(config_spec) 70 | 71 | def test_constructor(self): 72 | with pytest.raises(TypeError): 73 | ConfigParser("not a ConfigSpec") 74 | 75 | def test_load_config_nonexistent_file(self): 76 | # if the config file doesn't exist, error 77 | with pytest.raises(IOError): 78 | self.config_parser.load_config( 79 | os.path.join(FIXTURE_DIRECTORY, 'nonexistent.json')) 80 | 81 | # nothing loaded 82 | assert self.config_parser.config == {} 83 | 84 | def test_load_config_invalid_file(self): 85 | # if the config is invalid, error 86 | with pytest.raises(ValueError): 87 | self.config_parser.load_config( 88 | os.path.join(FIXTURE_DIRECTORY, 'invalid-json-config.json')) 89 | 90 | # nothing loaded 91 | assert self.config_parser.config == {} 92 | 93 | def test_load_config_picks_up_values(self): 94 | self.config_parser.load_config( 95 | os.path.join(FIXTURE_DIRECTORY, 'config.json')) 96 | 97 | assert self.config_parser.config['string'] == 'value' 98 | assert self.config_parser.config['int'] == 3 99 | assert self.config_parser.config['bool'] is False 100 | assert self.config_parser.config['dict'] == { 101 | 'dict': {'string': 'value'}, 102 | 'list': ['foo', 'bar', 'baz'], 103 | } 104 | 105 | # If not found in the file, default 106 | assert self.config_parser.config['not_in_json'] == self.DEFAULT 107 | 108 | # This was in the file but not the spec and should not appear in config 109 | assert 'ignored_string' not in self.config_parser.config 110 | -------------------------------------------------------------------------------- /plugins/sed/plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import defaultdict 3 | 4 | from cardinal.decorators import event 5 | from cardinal.util import ( 6 | is_action, 7 | parse_action, 8 | ) 9 | 10 | ESCAPE_PLACEHOLDER = '!EsCaPeD SlASh!' 11 | 12 | 13 | class SedPlugin: 14 | def __init__(self): 15 | self.history = defaultdict(dict) 16 | 17 | def substitute(self, user, channel, message): 18 | """Parse the message and return the substituted message or None. 19 | 20 | user -- The user_info tupled passed in to look up previous messages. 21 | message -- The message which may be a substitution. 22 | """ 23 | # need to allow escaping slashes 24 | message = message.replace('\\/', ESCAPE_PLACEHOLDER) 25 | 26 | # check for substitution 27 | match = re.match('^s/(.+?)/(.*?)(?:/([gi]*))?$', message) 28 | if match is None: 29 | return None 30 | 31 | if match.group(2).count('/') > 0: 32 | # if this was intended to be a substitution, the syntax is invalid 33 | return None 34 | 35 | # replace placeholders after separating pattern from replacement 36 | pattern = match.group(1).replace(ESCAPE_PLACEHOLDER, '/') 37 | replacement = match.group(2).replace(ESCAPE_PLACEHOLDER, '/') 38 | 39 | count = 1 40 | flags = 0 41 | 42 | if match.group(3) and 'g' in match.group(3): 43 | count = 0 44 | if match.group(3) and 'i' in match.group(3): 45 | flags = re.IGNORECASE 46 | 47 | try: 48 | new_message = re.sub( 49 | re.escape(pattern), # don't allow complex regex 50 | replacement, 51 | self.history[channel][user.nick], 52 | count, 53 | flags, 54 | ) 55 | except KeyError: 56 | return None 57 | 58 | return new_message 59 | 60 | @staticmethod 61 | def should_send_correction(old_message, new_message): 62 | if old_message == new_message: 63 | return False 64 | 65 | return True 66 | 67 | @event('irc.privmsg') 68 | def on_msg(self, cardinal, user, channel, message): 69 | new_message = self.substitute(user, channel, message) 70 | if new_message is not None: 71 | old_message = self.history[channel][user.nick] 72 | if self.should_send_correction(old_message, new_message): 73 | self.history[channel][user.nick] = new_message 74 | 75 | # In the 11th hour, make sure that we correctly handle /me 76 | if is_action(new_message): 77 | new_message = parse_action(new_message) 78 | 79 | cardinal.sendMsg(channel, '{} meant: {}'.format( 80 | user.nick, new_message)) 81 | else: 82 | self.history[channel][user.nick] = message 83 | 84 | @event('irc.part') 85 | def on_part(self, cardinal, leaver, channel, message): 86 | if leaver.nick == cardinal.nickname: 87 | try: 88 | del self.history[channel] 89 | except KeyError: 90 | pass 91 | else: 92 | try: 93 | del self.history[channel][leaver.nick] 94 | except KeyError: 95 | pass 96 | 97 | @event('irc.kick') 98 | def on_kick(self, cardinal, kicker, channel, nick, reason): 99 | if nick == cardinal.nickname: 100 | try: 101 | del self.history[channel] 102 | except KeyError: 103 | pass 104 | else: 105 | try: 106 | del self.history[channel][nick] 107 | except KeyError: 108 | pass 109 | 110 | @event('irc.quit') 111 | def on_quit(self, cardinal, quitter, message): 112 | if quitter.nick == cardinal.nickname: 113 | self.history = defaultdict(dict) 114 | 115 | else: 116 | for channel in self.history: 117 | try: 118 | del self.history[channel][quitter.nick] 119 | except KeyError: 120 | pass 121 | 122 | 123 | entrypoint = SedPlugin 124 | -------------------------------------------------------------------------------- /cardinal/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import inspect 4 | 5 | 6 | class ConfigSpec: 7 | """A class used to create a config spec for ConfigParser""" 8 | 9 | def __init__(self): 10 | """Initializes the logging""" 11 | self.logger = logging.getLogger(__name__) 12 | 13 | self.options = {} 14 | 15 | def add_option(self, name, type, default=None): 16 | """Adds an option to the spec 17 | 18 | Keyword arguments: 19 | name -- The name of the option to add to the spec. 20 | type -- An object representing the option's type. 21 | default -- Optionally, what the option should default to. 22 | 23 | Raises: 24 | TypeError -- If the option is not a string or type isn't a class. 25 | 26 | """ 27 | # Name must be a string 28 | if not isinstance(name, str): 29 | raise TypeError("Name must be str") 30 | 31 | if not inspect.isclass(type): 32 | raise TypeError("Type must be a class") 33 | 34 | self.options[name] = (type, default) 35 | 36 | def return_value_or_default(self, name, value): 37 | """Validates an option and returns it or the default 38 | 39 | If the value passed in passes validation for its option's type, then it 40 | will be returned. Otherwise, the default will. This is used for 41 | validation. 42 | 43 | Keyword arguments: 44 | name -- The name of the option to validate for. 45 | value -- The value to validate. 46 | 47 | Returns: 48 | string -- The value passed in or the option's default value 49 | 50 | Raises: 51 | KeyError -- When the option name doesn't exist in the spec. 52 | 53 | """ 54 | if name not in self.options: 55 | raise KeyError("%s is not a valid option" % name) 56 | 57 | # Separate the type and default from the tuple 58 | type_check, default = self.options[name] 59 | 60 | # Return the default if the value passed in was wrong, otherwise return 61 | # the value passed in 62 | if not isinstance(value, type_check): 63 | if value is not None: 64 | self.logger.warning( 65 | "Value passed in for option %s was invalid -- ignoring" % 66 | name 67 | ) 68 | else: 69 | self.logger.debug( 70 | "No value set for option %s -- using default" % name) 71 | 72 | return default 73 | else: 74 | return value 75 | 76 | 77 | class ConfigParser: 78 | """A class to make parsing of JSON configs easier. 79 | 80 | This class adds support for both the internal Cardinal config as well as 81 | config files for plugins. It helps to combine hard-coded defaults with 82 | values provided by a user (either through a JSON-encoded config file or 83 | command-line input.) 84 | """ 85 | 86 | def __init__(self, spec): 87 | """Initializes ConfigParser with a ConfigSpec and initializes logging 88 | 89 | Keyword arguments: 90 | spec -- Should be a built ConfigSpec 91 | 92 | Raises: 93 | TypeError -- If a valid config spec is not passed in. 94 | 95 | """ 96 | if not isinstance(spec, ConfigSpec): 97 | raise TypeError("Spec must be a config spec") 98 | 99 | self.logger = logging.getLogger(__name__) 100 | self.spec = spec 101 | self.config = {} 102 | 103 | def load_config(self, file_): 104 | """Attempts to load a JSON config file for Cardinal. 105 | 106 | Takes a file path, attempts to decode its contents from JSON, then 107 | validate known config options to see if they can safely be loaded in. 108 | their place. The final merged dictionary object is saved to the 109 | If they can't, the default value from the config spec is used in the 110 | instance and returned. 111 | 112 | Keyword arguments: 113 | file -- Path to a JSON config file. 114 | 115 | Returns: 116 | dict -- Dictionary object of the entire config. 117 | 118 | """ 119 | # Attempt to load and parse the config file 120 | f = open(file_, 'r') 121 | json_config = json.load(f) 122 | f.close() 123 | 124 | # For every option, 125 | for option in self.spec.options: 126 | # If the option wasn't defined in the config, default 127 | value = json_config[option] if option in json_config else None 128 | 129 | self.config[option] = self.spec.return_value_or_default( 130 | option, value) 131 | 132 | return self.config 133 | -------------------------------------------------------------------------------- /cardinal/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cardinal import decorators 4 | 5 | 6 | @pytest.mark.parametrize("input,expected", [ 7 | ('foo', ['foo']), 8 | (['foo'], ['foo']), 9 | (['foo', 'bar'], ['foo', 'bar']), 10 | ]) 11 | def test_command(input, expected): 12 | # ensure commands is a list with foo added 13 | @decorators.command(input) 14 | def foo(): 15 | pass 16 | 17 | assert foo.commands == expected 18 | 19 | 20 | def test_command_overwrites(): 21 | # test that only one decorator can add commands 22 | @decorators.command('foo') 23 | @decorators.command('bar') 24 | def foo(): 25 | pass 26 | 27 | assert foo.commands == ['foo'] 28 | 29 | 30 | def test_command_function_wrap(): 31 | # test that the decorator doesn't break the function 32 | @decorators.command('foo') 33 | def foo(bar, baz): 34 | return bar + baz 35 | 36 | assert foo(3, baz=4) == 7 37 | assert foo(5, 5) == 10 38 | 39 | 40 | @pytest.mark.parametrize("value", [ 41 | True, 42 | False, 43 | 5, 44 | 3.14, 45 | ('foo',), 46 | {'foo': 'bar'}, 47 | object(), 48 | ]) 49 | def test_command_exceptions(value): 50 | # only allow strings and lists 51 | with pytest.raises(TypeError): 52 | @decorators.command(value) 53 | def foo(): 54 | pass 55 | 56 | 57 | @pytest.mark.parametrize("input,expected", [ 58 | ('foo', 'foo'), 59 | (r'foo', r'foo'), 60 | ]) 61 | def test_regex(input, expected): 62 | # ensure events is a list with inputs added 63 | @decorators.regex(input) 64 | def regexCallback(): 65 | pass 66 | 67 | assert regexCallback.regex == expected 68 | 69 | 70 | def test_regex_overwrites(): 71 | @decorators.regex('foo') 72 | @decorators.regex('bar') 73 | def foo(): 74 | pass 75 | 76 | assert foo.regex == 'foo' 77 | 78 | 79 | def test_regex_function_wrap(): 80 | # test that the decorator doesn't break the function 81 | @decorators.regex('foo') 82 | def foo(bar, baz): 83 | return bar + baz 84 | 85 | assert foo(3, baz=4) == 7 86 | assert foo(5, 5) == 10 87 | 88 | 89 | @pytest.mark.parametrize("value", [ 90 | True, 91 | False, 92 | 5, 93 | 3.14, 94 | ('foo',), 95 | {'foo': 'bar'}, 96 | object(), 97 | ]) 98 | def test_regex_exceptions(value): 99 | # only allow strings and lists 100 | with pytest.raises(TypeError): 101 | @decorators.regex(value) 102 | def foo(): 103 | pass 104 | 105 | 106 | @pytest.mark.parametrize("input,expected", ( 107 | (["A help line", "A second help line"], 108 | ["A help line", "A second help line"]), 109 | (["A single help line"], ["A single help line"]), 110 | ("A real single help line", ["A real single help line"]), 111 | )) 112 | def test_help(input, expected): 113 | @decorators.help(input) 114 | def foo(x): 115 | return x 116 | 117 | # ensure help is a tuple with each param added 118 | assert foo.help == expected 119 | assert foo(1) == 1 120 | 121 | 122 | def test_help_multiple_calls(): 123 | @decorators.help("This is the first help line") 124 | @decorators.help("This is the second help line") 125 | def foo(): 126 | pass 127 | 128 | # test the order of the help lines 129 | assert foo.help == [ 130 | "This is the first help line", 131 | "This is the second help line", 132 | ] 133 | 134 | @pytest.mark.parametrize("value", [ 135 | True, 136 | False, 137 | 5, 138 | 3.14, 139 | ('foo',), 140 | {'foo': 'bar'}, 141 | object(), 142 | ]) 143 | def test_help_exceptions(value): 144 | # only allow strings and lists 145 | with pytest.raises(TypeError): 146 | @decorators.help(value) 147 | def foo(): 148 | pass 149 | 150 | 151 | @pytest.mark.parametrize("input,expected", [ 152 | ('irc.privmsg', ['irc.privmsg']), 153 | (['irc.privmsg'], ['irc.privmsg']), 154 | (['irc.privmsg', 'irc.notice'], ['irc.privmsg', 'irc.notice']), 155 | ]) 156 | def test_event(input, expected): 157 | # ensure events is a list with inputs added 158 | @decorators.event(input) 159 | def eventCallback(): 160 | pass 161 | 162 | assert eventCallback.events == expected 163 | 164 | 165 | def test_event_overwrites(): 166 | # test that only one decorator can add events 167 | @decorators.event('irc.privmsg') 168 | @decorators.event('irc.notice') 169 | def foo(): 170 | pass 171 | 172 | assert foo.events == ['irc.privmsg'] 173 | 174 | 175 | def test_event_function_wrap(): 176 | # test that the decorator doesn't break the function 177 | @decorators.event('foo') 178 | def foo(bar, baz): 179 | return bar + baz 180 | 181 | assert foo(3, baz=4) == 7 182 | assert foo(5, 5) == 10 183 | 184 | 185 | @pytest.mark.parametrize("value", [ 186 | True, 187 | False, 188 | 5, 189 | 3.14, 190 | ('foo',), 191 | {'foo': 'bar'}, 192 | object(), 193 | ]) 194 | def test_event_exceptions(value): 195 | # only allow strings and lists 196 | with pytest.raises(TypeError): 197 | @decorators.event(value) 198 | def foo(): 199 | pass 200 | -------------------------------------------------------------------------------- /plugins/help/plugin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from cardinal.decorators import command, help 3 | 4 | 5 | class HelpPlugin: 6 | # Gets a list of admins from the admin plugin instantiated within the 7 | # Cardinal instance, if exists 8 | def _get_admins(self, cardinal): 9 | admins = False 10 | admin_config = cardinal.config('admin') 11 | if admin_config is not None and 'admins' in admin_config: 12 | admins = [] 13 | for admin in admin_config['admins']: 14 | admins.append(admin['nick']) 15 | 16 | admins = sorted(list(set(admins))) 17 | 18 | return ', '.join(admins) if admins else '(no registered admins)' 19 | 20 | # Pulls a list of all commands from Cardinal instance, using either the 21 | # first defined command alias or failing that, the command's name 22 | def _get_commands(self, cardinal): 23 | commands = [] 24 | 25 | # Loop through commands registered in Cardinal 26 | for plugin in cardinal.plugin_manager: 27 | for cmd in plugin['commands']: 28 | if hasattr(cmd, 'commands'): 29 | commands.append(cmd.commands[0]) 30 | elif hasattr(cmd, 'name'): 31 | commands.append(cmd.name) 32 | 33 | return commands 34 | 35 | # Gets the help text out of the Cardinal instance for a given command 36 | def _get_command_help(self, cardinal, help_command): 37 | help_text = 'No help found for that command.' 38 | 39 | # Check each module for the command being searched for 40 | for plugin in cardinal.plugin_manager: 41 | found_command = False 42 | 43 | for cmd in plugin['commands']: 44 | # Check if the command responds with the command the user 45 | # requested 46 | if hasattr(cmd, 'commands') and \ 47 | help_command in cmd.commands: 48 | found_command = cmd 49 | break 50 | 51 | # If the command was found and has a help file, set the help_text 52 | if found_command and hasattr(found_command, 'help'): 53 | help_text = found_command.help 54 | 55 | # Return the help text for the command found if it exists 56 | if found_command: 57 | return help_text 58 | 59 | # Didn't find the command, so return a command does not exist error 60 | return 'Command does not exist.' 61 | 62 | # Pulls relevant meta information from the Cardinal instance 63 | def _get_meta(self, cardinal): 64 | return { 65 | 'uptime': cardinal.uptime, 66 | 'booted': cardinal.booted, 67 | } 68 | 69 | # Given a number of seconds, converts it to a readable uptime string 70 | def _pretty_uptime(self, seconds): 71 | days, seconds = divmod(seconds, 60 * 60 * 24) 72 | hours, seconds = divmod(seconds, 60 * 60) 73 | minutes, seconds = divmod(seconds, 60) 74 | uptime = "%d days " % days if days else "" 75 | uptime += "%02d:%02d:%02d" % (hours, minutes, seconds) 76 | 77 | return uptime 78 | 79 | # Give the user a list of valid commands in the bot if no command is 80 | # provided. If a valid command is provided, return its help text 81 | @command(['help']) 82 | @help("Shows loaded commands or a specific command's help.") 83 | @help("Syntax: .help [command]") 84 | def cmd_help(self, cardinal, user, channel, msg): 85 | parameters = msg.split() 86 | if len(parameters) == 1: 87 | cardinal.sendMsg( 88 | channel, 89 | "Loaded commands: %s" % ', '.join(self._get_commands(cardinal)) 90 | ) 91 | else: 92 | command = parameters[1] 93 | help = self._get_command_help(cardinal, command) 94 | if isinstance(help, list): 95 | for help_line in help: 96 | cardinal.sendMsg(channel, help_line) 97 | elif isinstance(help, str): 98 | cardinal.sendMsg(channel, help) 99 | else: 100 | cardinal.sendMsg( 101 | channel, 102 | "Unable to handle help string returned by module.") 103 | 104 | # Sends some basic meta information about the bot 105 | @command('info') 106 | @help("Gives some basic information about the bot.") 107 | @help("Syntax: .info") 108 | def cmd_info(self, cardinal, user, channel, msg): 109 | admins = self._get_admins(cardinal) 110 | meta = self._get_meta(cardinal) 111 | 112 | # Calculate uptime into readable format 113 | now = datetime.now() 114 | uptime = self._pretty_uptime((now - meta['uptime']).total_seconds()) 115 | booted = self._pretty_uptime((now - meta['booted']).total_seconds()) 116 | 117 | cardinal.sendMsg( 118 | channel, 119 | "I am a Python 3 IRC bot, online since {}. I initially connected " 120 | "{} ago. My admins are: {}. Use .help to list commands." 121 | .format(uptime, booted, admins) 122 | ) 123 | cardinal.sendMsg( 124 | channel, 125 | "Visit https://github.com/JohnMaguire/Cardinal to learn more." 126 | ) 127 | 128 | 129 | entrypoint = HelpPlugin 130 | -------------------------------------------------------------------------------- /cardinal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import argparse 6 | import logging 7 | import logging.config 8 | 9 | from twisted.internet import reactor 10 | 11 | from cardinal.config import ConfigParser, ConfigSpec 12 | from cardinal.bot import CardinalBotFactory 13 | 14 | 15 | def setup_logging(config=None): 16 | if config is None: 17 | logging.basicConfig( 18 | level=logging.INFO, 19 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 20 | ) 21 | else: 22 | logging.config.dictConfig(config) 23 | 24 | return logging.getLogger(__name__) 25 | 26 | 27 | if __name__ == "__main__": 28 | # Create a new instance of ArgumentParser with a description about Cardinal 29 | arg_parser = argparse.ArgumentParser(description=""" 30 | Cardinal IRC bot 31 | 32 | A Twisted IRC bot designed to be simple to use and and easy to extend. 33 | 34 | https://github.com/JohnMaguire/Cardinal 35 | """, formatter_class=argparse.RawDescriptionHelpFormatter) 36 | 37 | arg_parser.add_argument('config', metavar='config', 38 | help='custom config location') 39 | 40 | # Parse command-line arguments 41 | args = arg_parser.parse_args() 42 | config_file = args.config 43 | 44 | # Define the config spec and create a parser for our internal config 45 | spec = ConfigSpec() 46 | spec.add_option('nickname', str, 'Cardinal') 47 | spec.add_option('password', str, None) 48 | spec.add_option('username', str, None) 49 | spec.add_option('realname', str, None) 50 | spec.add_option('network', str, 'irc.darkscience.net') 51 | spec.add_option('port', int, 6697) 52 | spec.add_option('server_password', str, None) 53 | spec.add_option('server_commands', list, []) 54 | spec.add_option('ssl', bool, True) 55 | spec.add_option('storage', str, os.path.join( 56 | os.path.dirname(os.path.realpath(sys.argv[0])), 57 | 'storage' 58 | )) 59 | spec.add_option('channels', list, ['#bots']) 60 | spec.add_option('censored_words', dict, {}) 61 | spec.add_option('plugins', list, [ 62 | "admin", 63 | "github", 64 | "google", 65 | "help", 66 | "join_on_invite", 67 | "lastfm", 68 | "ping", 69 | "remind", 70 | "sed", 71 | "seen", 72 | "timezone", 73 | "urbandict", 74 | "urls", 75 | "weather", 76 | "wikipedia", 77 | "youtube" 78 | ]) 79 | spec.add_option('blacklist', dict, {}) 80 | spec.add_option('logging', dict, None) 81 | 82 | parser = ConfigParser(spec) 83 | 84 | # Load config file 85 | try: 86 | config = parser.load_config(config_file) 87 | except Exception: 88 | # Need to setup a logger early 89 | logger = setup_logging() 90 | logger.exception("Unable to load config: {}".format(config_file)) 91 | sys.exit(1) 92 | 93 | # Config loaded, setup the logger 94 | logger = setup_logging(config['logging']) 95 | 96 | logger.info("Config loaded: {}".format(config_file)) 97 | 98 | # Determine storage directory 99 | if config['storage'] is not None: 100 | if config['storage'].startswith('/'): 101 | config['storage'] = config['storage'] 102 | else: 103 | config['storage'] = os.path.join( 104 | os.path.dirname(os.path.realpath(__file__)), 105 | config['storage'] 106 | ) 107 | 108 | logger.info("Storage path: {}".format(config['storage'])) 109 | 110 | directories = [ 111 | os.path.join(config['storage'], 'database'), 112 | os.path.join(config['storage'], 'logs'), 113 | ] 114 | 115 | for directory in directories: 116 | if not os.path.exists(directory): 117 | logger.info( 118 | "Initializing storage directory: {}".format(directory)) 119 | os.makedirs(directory) 120 | 121 | # If no username is supplied, default to nickname 122 | if config['username'] is None: 123 | config['username'] = config['nickname'] 124 | 125 | # Instance a new factory, and connect with/without SSL 126 | logger.debug("Instantiating CardinalBotFactory") 127 | factory = CardinalBotFactory(config['network'], 128 | config['server_password'], 129 | config['server_commands'], 130 | config['channels'], 131 | config['nickname'], 132 | config['password'], 133 | config['username'], 134 | config['realname'], 135 | config['plugins'], 136 | config['censored_words'], 137 | config['blacklist'], 138 | config['storage']) 139 | 140 | if not config['ssl']: 141 | logger.info( 142 | "Connecting over plaintext to %s:%d" % 143 | (config['network'], config['port']) 144 | ) 145 | 146 | reactor.connectTCP(config['network'], config['port'], factory) 147 | else: 148 | logger.info( 149 | "Connecting over SSL to %s:%d" % 150 | (config['network'], config['port']) 151 | ) 152 | 153 | # For SSL, we need to import the SSL module from Twisted 154 | from twisted.internet import ssl 155 | reactor.connectSSL(config['network'], config['port'], factory, 156 | ssl.ClientContextFactory()) 157 | 158 | # Run the Twisted reactor 159 | reactor.run() 160 | -------------------------------------------------------------------------------- /plugins/crypto/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | import requests 5 | from twisted.internet import defer 6 | from twisted.internet.threads import deferToThread 7 | 8 | from cardinal import util 9 | from cardinal.bot import user_info 10 | from cardinal.decorators import command, regex, help 11 | from cardinal.util import F 12 | 13 | # CoinMarketCap API Endpoint 14 | CMC_QUOTE_API_URL = "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest" # noqa: E501 15 | 16 | # Regex pattern that matches PyLink relay bots (copied from ticker plugin) 17 | RELAY_REGEX = r'^(?:<(.+?)>\s+)' 18 | 19 | # For 'crypto' command - checking cryptocurrency price 20 | CRYPTO_REGEX = RELAY_REGEX + r'(\.crypto.*?)$' 21 | 22 | # Max simultaneous cryptocurrency results 23 | MAX_LOOKUPS = 5 24 | 25 | 26 | def colorize(percentage): 27 | message = '{:.2f}%'.format(percentage) 28 | if percentage > 0: 29 | return F.C.light_green(message) 30 | else: 31 | return F.C.light_red(message) 32 | 33 | 34 | class CMCError(Exception): 35 | """Represents a CoinMarketCap error""" 36 | pass 37 | 38 | 39 | class CryptoPlugin: 40 | def __init__(self, cardinal, config): 41 | self.logger = logging.getLogger(__name__) 42 | self.cardinal = cardinal 43 | 44 | self.config = config or {} 45 | self.config.setdefault('cmc_api_key', None) 46 | self.config.setdefault('default_crypto_price_currency', 'USD') 47 | self.config.setdefault('relay_bots', []) 48 | 49 | if not self.config["cmc_api_key"]: 50 | raise KeyError( 51 | "Missing cmc_api_key (CoinMarketCap) - crypto functions will " 52 | "be unavailable") 53 | 54 | self.relay_bots = [] 55 | for relay_bot in self.config['relay_bots']: 56 | user = user_info( 57 | relay_bot['nick'], 58 | relay_bot['user'], 59 | relay_bot['vhost']) 60 | self.relay_bots.append(user) 61 | 62 | @command('crypto') 63 | @help('Check the price of a cryptocurrency') 64 | @help('Syntax: .crypto [price currency]') 65 | @defer.inlineCallbacks 66 | def crypto(self, cardinal, user, channel, message): 67 | nick = user.nick 68 | 69 | try: 70 | parts = message.split(' ', 2) 71 | coin = parts[1] 72 | except IndexError: 73 | cardinal.sendMsg( 74 | channel, "Syntax: .crypto [price currency]") 75 | return 76 | 77 | if len(coin.split(',')) > MAX_LOOKUPS: 78 | cardinal.sendMsg( 79 | channel, "Please request no more than {} coins at once".format( 80 | MAX_LOOKUPS)) 81 | return 82 | 83 | try: 84 | currency = parts[2] 85 | except IndexError: 86 | currency = self.config['default_crypto_price_currency'] 87 | 88 | try: 89 | resp = yield self.make_cmc_request(coin, currency) 90 | except CMCError as e: 91 | self.logger.warning(e) 92 | 93 | # Probably safe to send? 94 | cardinal.sendMsg( 95 | channel, "{}".format(e)) 96 | return 97 | except Exception as exc: 98 | self.logger.warning( 99 | "Error trying to look up coin {} in currency {}: {}" 100 | .format(coin, currency, exc)) 101 | cardinal.sendMsg( 102 | channel, "{}: I couldn't look that coin up".format(nick)) 103 | return 104 | 105 | for coin in resp.values(): 106 | name = coin['name'] 107 | symbol = coin['symbol'] 108 | cmc_rank = coin['cmc_rank'] 109 | for quote_currency, quote in coin['quote'].items(): 110 | price = quote['price'] 111 | if price >= 1: 112 | price = round(price, 2) 113 | else: 114 | price = float("%.4g" % price) 115 | 116 | cardinal.sendMsg( 117 | channel, 118 | "{} ({}) = {} {} - Daily Change (24h): {} " 119 | "(Market Cap: {:,.2f} - Ranked #{})".format( 120 | name, F.bold(symbol), 121 | price, quote_currency, 122 | colorize(quote['percent_change_24h']), 123 | quote['market_cap'], 124 | cmc_rank)) 125 | 126 | @regex(CRYPTO_REGEX) 127 | @defer.inlineCallbacks 128 | def crypto_regex(self, cardinal, user, channel, message): 129 | """Hack to support relayed messages""" 130 | match = re.match(CRYPTO_REGEX, message) 131 | 132 | # this group should only be present when a relay bot is relaying a 133 | # message for another user 134 | if not match.group(1): 135 | return 136 | if not self.is_relay_bot(user): 137 | return 138 | 139 | user = user_info(util.strip_formatting(match.group(1)), 140 | user.user, 141 | user.vhost, 142 | ) 143 | 144 | yield self.crypto(cardinal, user, channel, match.group(2)) 145 | 146 | @defer.inlineCallbacks 147 | def make_cmc_request(self, coin, currency): 148 | r = yield deferToThread(requests.get, CMC_QUOTE_API_URL, params={ 149 | 'convert': currency, 150 | 'symbol': coin, 151 | }, headers={ 152 | 'Accepts': 'application/json', 153 | 'X-CMC_PRO_API_KEY': self.config['cmc_api_key'], 154 | }) 155 | 156 | resp = r.json() 157 | if resp['status']['error_code']: 158 | raise CMCError( 159 | "CoinMarketCap gave error {error_code}: {error_message}" 160 | .format( 161 | error_code=resp['status']['error_code'], 162 | error_message=resp['status']['error_message'], 163 | )) 164 | 165 | return resp['data'] 166 | 167 | def is_relay_bot(self, user): 168 | """Compares a user against the registered relay bots.""" 169 | for bot in self.relay_bots: 170 | if (bot.nick is None or bot.nick == user.nick) and \ 171 | (bot.user is None or bot.user == user.user) and \ 172 | (bot.vhost is None or bot.vhost == user.vhost): 173 | return True 174 | 175 | return False 176 | 177 | 178 | entrypoint = CryptoPlugin 179 | -------------------------------------------------------------------------------- /plugins/github/plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import requests 4 | 5 | from cardinal.decorators import command, event, help 6 | from cardinal.exceptions import EventRejectedMessage 7 | 8 | from twisted.internet import defer 9 | from twisted.internet.threads import deferToThread 10 | 11 | REPO_URL_REGEX = re.compile( 12 | r'https://(?:www\.)?github\..{2,4}/([^/]+)/([^/]+)', 13 | flags=re.IGNORECASE) 14 | ISSUE_URL_REGEX = re.compile( 15 | r'https://(?:www\.)?github\..{2,4}/([^/]+)/([^/]+)/(?:issues|pull)/([0-9]+)', # noqa: E501 16 | flags=re.IGNORECASE) 17 | REPO_NAME_REGEX = re.compile( 18 | r'^[a-z0-9-]+/[a-z0-9_-]+$', 19 | flags=re.IGNORECASE) 20 | 21 | 22 | class GithubPlugin: 23 | logger = None 24 | """Logging object for GithubPlugin""" 25 | 26 | default_repo = None 27 | """Default repository to select the issues from""" 28 | 29 | max_show_issues = 1 30 | """Max number of issues to show for a search. -1 means all""" 31 | 32 | def __init__(self, cardinal, config): 33 | # Initialize logging 34 | self.logger = logging.getLogger(__name__) 35 | 36 | if 'default_repo' in config and config['default_repo']: 37 | self.default_repo = config['default_repo'] 38 | 39 | if 'max_show_issues' in config and config['max_show_issues']: 40 | self.max_show_issues = config['max_show_issues'] 41 | 42 | @command('issue') 43 | @help("Find a Github repo or issue (or combination thereof)") 44 | @help("Syntax: .issue [username/repository] ") 45 | @defer.inlineCallbacks 46 | def search(self, cardinal, user, channel, msg): 47 | # Grab the search query 48 | try: 49 | repo = msg.split(' ', 2)[1] 50 | 51 | if not REPO_NAME_REGEX.match(repo): 52 | if not self.default_repo: 53 | cardinal.sendMsg( 54 | channel, 55 | "Syntax: .issue ") 57 | return 58 | 59 | repo = self.default_repo 60 | query = msg.split(' ', 1)[1] 61 | else: 62 | query = msg.split(' ', 2)[2] 63 | except IndexError: 64 | cardinal.sendMsg( 65 | channel, 66 | "Syntax: .issue [username/repository] ") 67 | return 68 | 69 | try: 70 | yield self._show_issue(cardinal, channel, repo, int(query)) 71 | except ValueError: 72 | res = yield self._form_request('search/issues', 73 | {'q': "repo:%s %s" % (repo, query)}) 74 | num = 0 75 | for issue in res['items']: 76 | cardinal.sendMsg(channel, self._format_issue(issue)) 77 | num += 1 78 | if num == self.max_show_issues: 79 | break 80 | if res['total_count'] > self.max_show_issues: 81 | cardinal.sendMsg(channel, 82 | "...and %d more" % 83 | (res['total_count'] - self.max_show_issues)) 84 | elif res['total_count'] == 0: 85 | cardinal.sendMsg(channel, 86 | "No matching issues found in %s" % repo) 87 | except requests.exceptions.HTTPError: 88 | cardinal.sendMsg(channel, 89 | "Couldn't find %s#%d" % (repo, int(query))) 90 | 91 | @event('urls.detection') 92 | @defer.inlineCallbacks 93 | def get_repo_info(self, cardinal, channel, url): 94 | match = re.match(ISSUE_URL_REGEX, url) 95 | if not match: 96 | match = re.match(REPO_URL_REGEX, url) 97 | if not match: 98 | raise EventRejectedMessage 99 | 100 | groups = match.groups() 101 | try: 102 | if len(groups) == 3: 103 | yield self._show_issue(cardinal, 104 | channel, 105 | '%s/%s' % (groups[0], groups[1]), 106 | int(groups[2])) 107 | elif len(groups) == 2: 108 | yield self._show_repo(cardinal, 109 | channel, 110 | '%s/%s' % (groups[0], groups[1])) 111 | except requests.exceptions.HTTPError: 112 | raise EventRejectedMessage 113 | 114 | def _format_issue(self, issue): 115 | message = "#%s: %s" % (issue['number'], issue['title']) 116 | 117 | if issue['state'] == 'closed': 118 | message = u"\u2713 %s" % message 119 | elif issue['state'] == 'open': 120 | message = "! " + message 121 | 122 | if issue['assignee']: 123 | message += " @%s" % issue['assignee']['login'] 124 | 125 | message += " - " + issue['html_url'] 126 | 127 | # Add labels, if there are any 128 | if issue['labels']: 129 | labels = ['[{}]'.format(label['name']) 130 | for label in issue['labels']] 131 | message += ' ' + ' '.join(labels) 132 | 133 | return message 134 | 135 | @defer.inlineCallbacks 136 | def _show_issue(self, cardinal, channel, repo, number): 137 | issue = yield self._form_request('repos/%s/issues/%d' % (repo, number)) 138 | cardinal.sendMsg(channel, self._format_issue(issue)) 139 | 140 | @defer.inlineCallbacks 141 | def _show_repo(self, cardinal, channel, repo): 142 | repo = yield self._form_request('repos/' + repo) 143 | message = "[ %s - %s " % (repo['full_name'], repo['description']) 144 | if repo['stargazers_count'] > 0: 145 | message += u"| \u2605 %s stars " % repo['stargazers_count'] 146 | 147 | if repo['forks_count'] > 0: 148 | message += u"| \u2934 %s forks " % repo['forks_count'] 149 | 150 | if repo['open_issues_count'] > 0: 151 | message += "| ! %s open issues " % repo['open_issues_count'] 152 | 153 | message += "]" 154 | 155 | cardinal.sendMsg(channel, message) 156 | 157 | @defer.inlineCallbacks 158 | def _form_request(self, endpoint, params=None): 159 | if params is None: 160 | params = {} 161 | 162 | r = yield deferToThread(requests.get, 163 | "https://api.github.com/" + endpoint, 164 | params=params) 165 | r.raise_for_status() 166 | 167 | return r.json() 168 | 169 | 170 | entrypoint = GithubPlugin 171 | -------------------------------------------------------------------------------- /plugins/weather/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from twisted.internet import defer 5 | from twisted.internet.threads import deferToThread 6 | 7 | from cardinal.decorators import command, help 8 | 9 | 10 | class Forecast: 11 | def __init__( 12 | self, 13 | location, 14 | condition, 15 | temperature_f, 16 | humidity, 17 | winds_mph 18 | ): 19 | self.location = location 20 | self.condition = condition 21 | self.temperature_f = temperature_f 22 | self.temperature_c = round((temperature_f - 32) * 5 / 9, 1) 23 | self.humidity = humidity 24 | self.winds_mph = winds_mph 25 | self.winds_k = round(winds_mph * 1.609344, 2) 26 | 27 | 28 | class OpenWeatherClient: 29 | API_ENDPOINT = "https://api.openweathermap.org/data/2.5/weather" 30 | 31 | def __init__(self, api_key): 32 | self.api_key = api_key 33 | 34 | @defer.inlineCallbacks 35 | def get_forecast(self, location) -> Forecast: 36 | params = { 37 | 'q': location, 38 | 'appid': self.api_key, 39 | 'units': 'imperial', 40 | 'lang': 'en', 41 | } 42 | 43 | r = yield deferToThread( 44 | requests.get, 45 | self.API_ENDPOINT, 46 | params=params 47 | ) 48 | 49 | return self.parse_forecast(r.json()) 50 | 51 | def parse_forecast(self, res): 52 | location = "{}, {}".format(res['name'].strip(), 53 | res['sys']['country'].strip()) 54 | condition = res['weather'][0]['main'] 55 | temperature = int(res['main']['temp']) 56 | humidity = int(res['main']['humidity']) 57 | winds = float(res['wind']['speed']) 58 | 59 | return Forecast(location, condition, temperature, humidity, winds) 60 | 61 | 62 | class WeatherAPIClient: 63 | API_ENDPOINT = "https://api.weatherapi.com/v1/current.json" 64 | 65 | def __init__(self, api_key): 66 | self.api_key = api_key 67 | 68 | @defer.inlineCallbacks 69 | def get_forecast(self, location) -> Forecast: 70 | params = { 71 | 'q': location, 72 | 'key': self.api_key, 73 | } 74 | 75 | r = yield deferToThread( 76 | requests.get, 77 | self.API_ENDPOINT, 78 | params=params 79 | ) 80 | 81 | return self.parse_forecast(r.json()) 82 | 83 | def parse_forecast(self, res): 84 | location = res['location']['name'] 85 | if res['location']['region']: 86 | location += ", {}, {}".format(res['location']['region'], 87 | res['location']['country']) 88 | else: 89 | location += ", {}".format(res['location']['country']) 90 | 91 | return Forecast( 92 | location=location, 93 | condition=res['current']['condition']['text'], 94 | temperature_f=res['current']['temp_f'], 95 | humidity=res['current']['humidity'], 96 | winds_mph=res['current']['wind_mph'], 97 | ) 98 | 99 | 100 | class WeatherPlugin: 101 | def __init__(self, cardinal, config): 102 | self.logger = logging.getLogger(__name__) 103 | self.db = cardinal.get_db('weather') 104 | 105 | if config is None: 106 | config = {} 107 | 108 | self.provider = config.get('provider', 'weatherapi') 109 | self.api_key = config.get('api_key', None) 110 | 111 | if self.provider == 'openweather': 112 | self.client = OpenWeatherClient(self.api_key) 113 | elif self.provider == 'weatherapi': 114 | self.client = WeatherAPIClient(self.api_key) 115 | else: 116 | raise Exception(f"Unknown weather provider: {self.provider}") 117 | 118 | @command(['setweather','setw']) 119 | @help("Set your default weather location.") 120 | @help("Syntax: .setw ") 121 | @defer.inlineCallbacks 122 | def set_weather(self, cardinal, user, channel, msg): 123 | try: 124 | location = msg.split(' ', 1)[1] 125 | except IndexError: 126 | cardinal.sendMsg(channel, "Syntax: .setw ") 127 | return 128 | 129 | try: 130 | res = yield self.client.get_forecast(location) 131 | except Exception: 132 | cardinal.sendMsg(channel, "Sorry, I can't find that location.") 133 | self.logger.exception( 134 | "Error test fetching for location: '{}'".format(location) 135 | ) 136 | return 137 | 138 | with self.db() as db: 139 | db[user.nick] = location 140 | 141 | cardinal.sendMsg(channel, '{}: Your default weather location is now ' 142 | 'set to {}. Next time you want the weather ' 143 | 'at this location, just use .weather or .w!' 144 | .format(user.nick, res.location)) 145 | 146 | @command(['weather', 'w']) 147 | @help("Retrieves the weather using the OpenWeatherMap API.") 148 | @help("Syntax: .weather [location]") 149 | @defer.inlineCallbacks 150 | def weather(self, cardinal, user, channel, msg): 151 | if self.api_key is None: 152 | cardinal.sendMsg( 153 | channel, 154 | "Weather plugin is not configured correctly. " 155 | "Please set API key." 156 | ) 157 | 158 | try: 159 | location = msg.split(' ', 1)[1] 160 | except IndexError: 161 | with self.db() as db: 162 | try: 163 | location = db[user.nick] 164 | except KeyError: 165 | cardinal.sendMsg( 166 | channel, 167 | "Syntax: .weather " 168 | "(.setw to make it permanent)" 169 | ) 170 | return 171 | 172 | try: 173 | res = yield self.client.get_forecast(location) 174 | except Exception: 175 | cardinal.sendMsg(channel, "Error fetching weather data.") 176 | self.logger.exception( 177 | "Error fetching forecast for location '{}'".format(location)) 178 | return 179 | 180 | cardinal.sendMsg( 181 | channel, 182 | "[ {} | {} | Temp: {} °F ({} °C) | Humidity: {}% |" 183 | " Winds: {} mph ({} km/h) ]".format( 184 | res.location, 185 | res.condition, 186 | res.temperature_f, 187 | res.temperature_c, 188 | res.humidity, 189 | res.winds_mph, 190 | res.winds_k) 191 | ) 192 | 193 | 194 | entrypoint = WeatherPlugin 195 | -------------------------------------------------------------------------------- /plugins/youtube/plugin.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import logging 4 | 5 | import requests 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | from cardinal.decorators import command, event, help 10 | from cardinal.exceptions import EventRejectedMessage 11 | 12 | VIDEO_URL_REGEX = re.compile(r'https?:\/\/(?:www\.)?youtube\..{2,4}\/watch\?.*(?:v=(.+?))(?:(?:&.*)|$)', flags=re.IGNORECASE) # noqa: E501 13 | VIDEO_URL_SHORT_REGEX = re.compile(r'https?:\/\/(?:www\.)?youtu\.be\/(.+?)(?:(?:\?.*)|$)', flags=re.IGNORECASE) # noqa: E501 14 | SHORTS_URL_REGEX = re.compile(r'https?:\/\/(?:www\.)?youtube\..{2,4}\/shorts\/(.+?)(?:(?:\?.*)|$)', flags=re.IGNORECASE) # noqa: E501 15 | 16 | # Fetched from the YouTube API on 2021-06-04, hopefully it doesn't change. 17 | MUSIC_CATEGORY_ID = 10 18 | 19 | 20 | # The following two functions were borrowed from Stack Overflow: 21 | # https://stackoverflow.com/a/64232786/242129 22 | def get_isosplit(s, split): 23 | if split in s: 24 | n, s = s.split(split) 25 | else: 26 | n = 0 27 | return n, s 28 | 29 | 30 | def parse_isoduration(s): 31 | # Remove prefix 32 | s = s.split('P')[-1] 33 | 34 | # Step through letter dividers 35 | days, s = get_isosplit(s, 'D') 36 | _, s = get_isosplit(s, 'T') 37 | hours, s = get_isosplit(s, 'H') 38 | minutes, s = get_isosplit(s, 'M') 39 | seconds, s = get_isosplit(s, 'S') 40 | 41 | # Convert all to seconds 42 | dt = datetime.timedelta( 43 | days=int(days), 44 | hours=int(hours), 45 | minutes=int(minutes), 46 | seconds=int(seconds), 47 | ) 48 | return dt 49 | 50 | 51 | class YouTubePlugin: 52 | logger = None 53 | """Logging object for YouTubePlugin""" 54 | 55 | api_key = None 56 | """API key for Youtube API""" 57 | 58 | def __init__(self, cardinal, config): 59 | # Initialize logging 60 | self.logger = logging.getLogger(__name__) 61 | 62 | if config is None: 63 | return 64 | 65 | if 'api_key' in config: 66 | self.api_key = config['api_key'] 67 | 68 | @command(['youtube', 'yt']) 69 | @help("Get the first YouTube result for a given search.") 70 | @help("Syntax: .youtube ") 71 | @defer.inlineCallbacks 72 | def search(self, cardinal, user, channel, msg): 73 | # Before we do anything, let's make sure we'll be able to query YouTube 74 | if self.api_key is None: 75 | cardinal.sendMsg( 76 | channel, 77 | "YouTube plugin is not configured correctly. " 78 | "Please set API key." 79 | ) 80 | 81 | # Grab the search query 82 | try: 83 | search_query = msg.split(' ', 1)[1] 84 | except IndexError: 85 | cardinal.sendMsg(channel, "Syntax: .youtube ") 86 | return 87 | 88 | try: 89 | result = yield self._search(search_query) 90 | except Exception: 91 | self.logger.exception("Failed to search YouTube") 92 | cardinal.sendMsg(channel, "Error while searching YouTube") 93 | return 94 | 95 | if result is None: 96 | cardinal.sendMsg(channel, "No videos found matching that search.") 97 | return 98 | 99 | try: 100 | message = yield self._get_formatted_details( 101 | result['id']['videoId'] 102 | ) 103 | except Exception: 104 | self.logger.exception("Error finding search result details") 105 | cardinal.sendMsg(channel, "Error while searching YouTube") 106 | return 107 | 108 | cardinal.sendMsg(channel, message) 109 | 110 | @defer.inlineCallbacks 111 | def _get_formatted_details(self, video_id): 112 | params = { 113 | 'id': video_id, 114 | 'maxResults': 1, 115 | 'part': 'snippet,statistics,contentDetails' 116 | } 117 | 118 | result = (yield self._form_request("videos", params))['items'][0] 119 | return self._parse_item(result) 120 | 121 | @defer.inlineCallbacks 122 | def _search(self, search_query): 123 | params = { 124 | 'q': search_query, 125 | 'part': 'snippet', 126 | 'maxResults': 1, 127 | 'type': 'video', 128 | } 129 | 130 | result = yield self._form_request("search", params) 131 | 132 | if 'error' in result: 133 | raise Exception("Error searching Youtube: %s" % result['error']) 134 | 135 | try: 136 | return result['items'][0] 137 | except IndexError: 138 | return None 139 | 140 | @event('urls.detection') 141 | @defer.inlineCallbacks 142 | def _get_video_info(self, cardinal, channel, url): 143 | match = re.match(VIDEO_URL_REGEX, url) 144 | if not match: 145 | match = re.match(VIDEO_URL_SHORT_REGEX, url) 146 | if not match: 147 | match = re.match(SHORTS_URL_REGEX, url) 148 | if not match: 149 | raise EventRejectedMessage 150 | 151 | video_id = match.group(1) 152 | params = { 153 | 'id': video_id, 154 | 'maxResults': 1, 155 | 'part': 'snippet,statistics,contentDetails', 156 | } 157 | 158 | try: 159 | result = yield self._form_request("videos", params) 160 | except Exception: 161 | self.logger.exception("Failed to fetch info for %s'" % video_id) 162 | raise EventRejectedMessage 163 | 164 | try: 165 | message = self._parse_item(result['items'][0]) 166 | cardinal.sendMsg(channel, message) 167 | except Exception: 168 | self.logger.exception("Failed to parse info for %s'" % video_id) 169 | raise EventRejectedMessage 170 | 171 | @defer.inlineCallbacks 172 | def _form_request(self, endpoint, params): 173 | # Add API key to all requests 174 | params['key'] = self.api_key 175 | 176 | r = yield deferToThread( 177 | requests.get, 178 | "https://www.googleapis.com/youtube/v3/" + endpoint, 179 | params=params, 180 | ) 181 | 182 | return r.json() 183 | 184 | def _parse_item(self, item): 185 | title = str(item['snippet']['title']) 186 | views = int(item['statistics']['viewCount']) 187 | uploader = str(item['snippet']['channelTitle']) 188 | if len(uploader) == 0: 189 | uploader = "(not available)" 190 | dt = parse_isoduration(item['contentDetails']['duration']) 191 | 192 | video_id = str(item['id']) 193 | 194 | # Decorate music videos 195 | category = int(item['snippet']['categoryId']) 196 | if category == MUSIC_CATEGORY_ID: 197 | title = '♫ ' + title + ' ♫' 198 | 199 | message_parts = [ 200 | "Title: {}".format(title), 201 | "Uploaded by: {}".format(uploader), 202 | "Duration: {}".format(dt), 203 | "{:,} views".format(views), 204 | "https://youtube.com/watch?v={}".format(video_id), 205 | ] 206 | return "[ {} ]".format(' | '.join(message_parts)) 207 | 208 | 209 | entrypoint = YouTubePlugin 210 | -------------------------------------------------------------------------------- /plugins/sed/test_plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import Mock 3 | 4 | from cardinal.bot import user_info 5 | from plugins.sed.plugin import SedPlugin 6 | 7 | 8 | @pytest.mark.parametrize("message,new_message", [ 9 | ('s/i/X', 'thXs is a test message'), 10 | ('s/i/X/g', 'thXs Xs a test message'), 11 | ('s/I/X', 'this is a test message'), 12 | ('s/I/X/i', 'thXs is a test message'), 13 | ('s/I/X/ig', 'thXs Xs a test message'), 14 | ('s/I/X/gi', 'thXs Xs a test message'), 15 | ('s/I/X/ggii', 'thXs Xs a test message'), 16 | ]) 17 | def test_substitute_modifiers(message, new_message): 18 | user = user_info('user', None, None) 19 | channel = '#channel' 20 | 21 | plugin = SedPlugin() 22 | plugin.history[channel][user.nick] = 'this is a test message' 23 | 24 | assert plugin.substitute(user, channel, message) == new_message 25 | 26 | 27 | def test_on_msg_correction(): 28 | user = user_info('user', None, None) 29 | channel = '#channel' 30 | 31 | plugin = SedPlugin() 32 | cardinal = Mock() 33 | 34 | plugin.history[channel][user.nick] = 'yo, foo matters' 35 | 36 | # make sure this doesn't raise 37 | plugin.on_msg(cardinal, user, channel, 's/foo/bar/') 38 | cardinal.sendMsg.assert_called_with( 39 | channel, 40 | "{} meant: yo, bar matters".format(user.nick), 41 | ) 42 | 43 | 44 | def test_on_msg_no_history(): 45 | user = user_info('user', None, None) 46 | channel = '#channel' 47 | 48 | plugin = SedPlugin() 49 | 50 | # make sure this doesn't raise 51 | plugin.on_msg(Mock(), user, channel, 's/foo/bar/') 52 | 53 | 54 | def test_on_msg_failed_correction(): 55 | user = user_info('user', None, None) 56 | channel = '#channel' 57 | 58 | plugin = SedPlugin() 59 | cardinal = Mock() 60 | 61 | plugin.history[channel][user.nick] = 'doesnt matter' 62 | 63 | # make sure this doesn't raise 64 | plugin.on_msg(cardinal, user, channel, 's/foo/bar/') 65 | cardinal.sendMsg.assert_not_called() 66 | 67 | 68 | @pytest.mark.parametrize("message,new_message", [ 69 | (r's/\//X', 'hiXhey/hello'), 70 | (r's/\//X/g', 'hiXheyXhello'), 71 | (r's/hi/\//', '//hey/hello'), 72 | ('s/hi/hey//', None), 73 | ]) 74 | def test_substitute_escaping(message, new_message): 75 | user = user_info('user', None, None) 76 | channel = '#channel' 77 | 78 | plugin = SedPlugin() 79 | plugin.history[channel][user.nick] = 'hi/hey/hello' 80 | 81 | assert plugin.substitute(user, channel, message) == new_message 82 | 83 | 84 | def test_not_a_substitute(): 85 | user = user_info('user', None, None) 86 | channel = '#channel' 87 | 88 | plugin = SedPlugin() 89 | plugin.history[channel][user.nick] = 'doesnt matter' 90 | 91 | assert plugin.substitute(user, channel, 'foobar') is None 92 | 93 | 94 | def test_substitution_doesnt_match(): 95 | user = user_info('user', None, None) 96 | channel = '#channel' 97 | 98 | plugin = SedPlugin() 99 | plugin.history[channel][user.nick] = 'doesnt matter' 100 | 101 | assert plugin.substitute(user, channel, 's/foo/bar/') == 'doesnt matter' 102 | 103 | 104 | def test_should_send_correction(): 105 | assert SedPlugin.should_send_correction('a', 'b') 106 | assert not SedPlugin.should_send_correction('a', 'a') 107 | 108 | 109 | def test_on_part(): 110 | channel1 = '#channel1' 111 | channel2 = '#channel2' 112 | user = user_info('nick', None, None) 113 | msg = 'msg' 114 | 115 | plugin = SedPlugin() 116 | cardinal = Mock() 117 | 118 | plugin.on_msg(cardinal, user, channel1, msg) 119 | plugin.on_msg(cardinal, user, channel2, msg) 120 | assert plugin.history[channel1] == { 121 | user.nick: msg 122 | } 123 | assert plugin.history[channel2] == { 124 | user.nick: msg 125 | } 126 | 127 | plugin.on_part(cardinal, user, channel1, 'message') 128 | assert plugin.history[channel1] == {} 129 | assert plugin.history[channel2] == { 130 | user.nick: msg 131 | } 132 | 133 | plugin.on_part(cardinal, user, channel2, 'message') 134 | assert plugin.history[channel2] == {} 135 | 136 | 137 | def test_on_part_no_history(): 138 | channel = '#channel' 139 | user = user_info('nick', None, None) 140 | 141 | plugin = SedPlugin() 142 | cardinal = Mock() 143 | 144 | # make sure this doesn't raise 145 | plugin.on_part(cardinal, user, channel, 'message') 146 | 147 | 148 | def test_on_part_self_no_history(): 149 | cardinal = Mock() 150 | cardinal.nickname = 'Cardinal' 151 | 152 | channel = '#channel' 153 | user = user_info(cardinal.nickname, None, None) 154 | 155 | plugin = SedPlugin() 156 | 157 | # make sure this doesn't raise 158 | plugin.on_part(cardinal, user, channel, 'message') 159 | 160 | 161 | def test_on_kick(): 162 | channel1 = '#channel1' 163 | channel2 = '#channel2' 164 | user = user_info('nick', None, None) 165 | msg = 'msg' 166 | 167 | plugin = SedPlugin() 168 | cardinal = Mock() 169 | 170 | plugin.on_msg(cardinal, user, channel1, msg) 171 | plugin.on_msg(cardinal, user, channel2, msg) 172 | assert plugin.history[channel1] == { 173 | user.nick: msg 174 | } 175 | assert plugin.history[channel2] == { 176 | user.nick: msg 177 | } 178 | 179 | plugin.on_kick(cardinal, user, channel1, user.nick, 'message') 180 | assert plugin.history[channel1] == {} 181 | assert plugin.history[channel2] == { 182 | user.nick: msg 183 | } 184 | 185 | plugin.on_kick(cardinal, user, channel2, user.nick, 'message') 186 | assert plugin.history[channel2] == {} 187 | 188 | 189 | def test_on_kick_no_history(): 190 | channel = '#channel' 191 | user = user_info('nick', None, None) 192 | 193 | plugin = SedPlugin() 194 | cardinal = Mock() 195 | 196 | # make sure this doesn't raise 197 | plugin.on_kick(cardinal, user, channel, user.nick, 'message') 198 | 199 | 200 | def test_on_kick_self_no_history(): 201 | cardinal = Mock() 202 | cardinal.nickname = 'Cardinal' 203 | 204 | channel = '#channel' 205 | user = user_info(cardinal.nickname, None, None) 206 | 207 | plugin = SedPlugin() 208 | 209 | # make sure this doesn't raise 210 | plugin.on_kick(cardinal, user, channel, user.nick, 'message') 211 | 212 | 213 | def test_on_quit(): 214 | channel1 = '#channel1' 215 | channel2 = '#channel2' 216 | user = user_info('nick', None, None) 217 | msg = 'msg' 218 | 219 | plugin = SedPlugin() 220 | cardinal = Mock() 221 | 222 | plugin.on_msg(cardinal, user, channel1, msg) 223 | plugin.on_msg(cardinal, user, channel2, msg) 224 | assert plugin.history[channel1] == { 225 | user.nick: msg 226 | } 227 | assert plugin.history[channel2] == { 228 | user.nick: msg 229 | } 230 | 231 | plugin.on_quit(cardinal, user, 'message') 232 | assert plugin.history[channel1] == {} 233 | assert plugin.history[channel2] == {} 234 | 235 | 236 | def test_on_quit_no_history(): 237 | channel = '#channel' 238 | user = user_info('nick', None, None) 239 | 240 | plugin = SedPlugin() 241 | assert plugin.history[channel] == {} 242 | cardinal = Mock() 243 | 244 | # make sure this doesn't raise 245 | plugin.on_quit(cardinal, user, 'message') 246 | assert plugin.history[channel] == {} 247 | -------------------------------------------------------------------------------- /plugins/urls/plugin.py: -------------------------------------------------------------------------------- 1 | # coding: iso-8859-15 2 | import re 3 | import html 4 | import logging 5 | import requests 6 | import unicodedata 7 | from datetime import datetime 8 | from urllib import request 9 | 10 | from twisted.internet import defer 11 | from twisted.internet.threads import deferToThread 12 | 13 | from cardinal.decorators import command, help, regex 14 | 15 | # Some notes about this regex - it will attempt to capture URLs prefixed by a 16 | # space, a control character (e.g. for formatting), or the beginning of the 17 | # string. 18 | URL_REGEX = re.compile(r"(?:^|\s|[\x00-\x1f\x7f-\x9f])((?:https?://)?(?:[a-z0-9.\-]+[.][a-z]{2,4}/?)(?:[^\s()<>]*|\((?:[^\s()<>]+|(?:\([^\s()<>]+\)))*\))+(?:\((?:[^\s()<>]+|(?:\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'\".,<>?]))", # noqa: E501 19 | flags=re.IGNORECASE | re.DOTALL) 20 | TITLE_REGEX = re.compile(r'(.*?)', 21 | flags=re.IGNORECASE | re.DOTALL) 22 | 23 | 24 | def get_urls(message): 25 | urls = re.findall(URL_REGEX, message) 26 | # strip any control characters that remain on the right side of the string 27 | # we don't need to worry about the left side, since the regex won't capture 28 | # any strings that don't begin "http" 29 | for i in range(len(urls)): 30 | url = urls[i] 31 | 32 | idx_r = len(url) 33 | for j in range(len(url)): 34 | if unicodedata.category(url[len(url) - 1 - j])[0] == "C": 35 | idx_r -= 1 36 | else: 37 | break 38 | 39 | urls[i] = url[0:idx_r] 40 | 41 | return urls 42 | 43 | 44 | class URLsPlugin: 45 | TIMEOUT = 5 46 | """Timeout in seconds before bailing on loading page""" 47 | 48 | READ_BYTES = 524288 49 | """Bytes to read before bailing on loading page (512KB)""" 50 | 51 | LOOKUP_COOLOFF = 10 52 | """Timeout in seconds before looking up the same URL again""" 53 | 54 | def __init__(self, cardinal, config): 55 | # Initialize logger 56 | self.logger = logging.getLogger(__name__) 57 | 58 | # Holds the last URL looked up, for cooloff 59 | self.last_url = None 60 | 61 | # Holds time last URL was looked up, for cooloff 62 | self.last_url_at = None 63 | 64 | # If config doesn't exist, use an empty dict 65 | config = config or {} 66 | 67 | self.timeout = config.get('timeout', self.TIMEOUT) 68 | self.read_bytes = config.get('read_bytes', self.READ_BYTES) 69 | self.lookup_cooloff = config.get('lookup_cooloff', self.LOOKUP_COOLOFF) 70 | self.shorten_links = config.get('shorten_links', False) 71 | self.api_key = config.get('crdnlxyz_api_key', None) 72 | # Whether to attempt to grab a title if no other plugin handles it 73 | self.generic_handler_enabled = config.get( 74 | 'handle_generic_urls', True) 75 | 76 | cardinal.event_manager.register('urls.detection', 2) 77 | 78 | def close(self, cardinal): 79 | cardinal.event_manager.remove('urls.detection') 80 | 81 | @regex(URL_REGEX) 82 | @defer.inlineCallbacks 83 | def get_title(self, cardinal, user, channel, msg): 84 | # Find every URL within the message 85 | urls = get_urls(msg) 86 | 87 | # Loop through the URLs, and make them valid 88 | for url in urls: 89 | if url[:7].lower() != "http://" and url[:8].lower() != "https://": 90 | url = "http://" + url 91 | 92 | if (url == self.last_url and self.last_url_at and 93 | (datetime.now() - self.last_url_at).seconds < 94 | self.lookup_cooloff): 95 | return 96 | 97 | self.last_url = url 98 | self.last_url_at = datetime.now() 99 | 100 | # Check if another plugin has hooked into this URL and wants to 101 | # provide information itself 102 | hooked = yield cardinal.event_manager.fire( 103 | 'urls.detection', channel, url) 104 | 105 | # Move to the next URL if a plugin has handled it or generic 106 | # handling is disabled 107 | if hooked or not self.generic_handler_enabled: 108 | continue 109 | 110 | try: 111 | o = request.build_opener() 112 | # User agent helps combat some bot checks 113 | o.addheaders = [ 114 | ('User-agent', 'Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/32.0.1667.0 Safari/537.36') # noqa: E501 115 | ] 116 | f = yield deferToThread(o.open, url, timeout=self.timeout) 117 | except Exception: 118 | self.logger.exception("Unable to load URL: %s" % url) 119 | return 120 | 121 | # Attempt to find the title 122 | content_type = f.info()['content-type'] 123 | if not ('text/html' in content_type or 124 | 'text/xhtml' in content_type): 125 | return 126 | content = f.read(self.read_bytes).decode('utf-8') 127 | f.close() 128 | 129 | title = re.search(TITLE_REGEX, content) 130 | if title: 131 | if len(title.group(2).strip()) > 0: 132 | title = re.sub(r'\s+', ' ', title.group(2)).strip() 133 | 134 | title = html.unescape(title) 135 | 136 | # Truncate long titles to the first 200 characters. 137 | title_to_send = title[:200] if len(title) >= 200 else title 138 | 139 | message = "URL Found: %s" % title_to_send 140 | 141 | if self.shorten_links: 142 | try: 143 | url = self.shorten_url(url) 144 | except Exception as e: 145 | self.logger.exception( 146 | "Unable to shorten URL: %s" % url) 147 | else: 148 | message = "^ %s: %s" % ( 149 | title_to_send, url) 150 | 151 | cardinal.sendMsg(channel, message) 152 | 153 | def shorten_url(self, url): 154 | if not self.api_key: 155 | raise Exception("No API key provided for URL shortening") 156 | 157 | data = { 158 | 'url': url, 159 | 'token': self.api_key, 160 | } 161 | 162 | response = requests.post('https://crdnl.xyz/add', json=data) 163 | response.raise_for_status() 164 | response = response.json() 165 | 166 | return response['url'] 167 | 168 | @command("shorten") 169 | @help("Syntax: .shorten ") 170 | def shorten(self, cardinal, user, channel, msg): 171 | try: 172 | url = msg.split(" ")[1] 173 | except IndexError: 174 | cardinal.sendMsg(channel, "Syntax: .shorten ") 175 | return 176 | 177 | try: 178 | url = self.shorten_url("http://example.com") 179 | except Exception as e: 180 | self.logger.exception("Unable to shorten URL: %s" % url) 181 | cardinal.sendMsg(channel, "Error shortening URL") 182 | return 183 | 184 | cardinal.sendMsg(channel, "Shortened URL: %s" % url) 185 | 186 | 187 | entrypoint = URLsPlugin 188 | -------------------------------------------------------------------------------- /plugins/tv/plugin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | 4 | from twisted.internet import defer 5 | from twisted.internet.threads import deferToThread 6 | import requests 7 | 8 | from cardinal.decorators import command 9 | from cardinal.decorators import help 10 | 11 | 12 | class ShowNotFoundException(Exception): 13 | pass 14 | 15 | 16 | @defer.inlineCallbacks 17 | def fetch_show(show): 18 | r = yield deferToThread( 19 | requests.get, 20 | "https://api.tvmaze.com/singlesearch/shows", 21 | params={"q": show} 22 | ) 23 | 24 | if r.status_code == 404: 25 | raise ShowNotFoundException 26 | r.raise_for_status() 27 | 28 | data = r.json() 29 | 30 | network = None 31 | if data['network']: 32 | network = data['network']['name'] 33 | country = data['network']['country'] 34 | if country: 35 | country = country['code'] 36 | elif data['webChannel']: 37 | network = data['webChannel']['name'] 38 | country = data['webChannel']['country'] 39 | if country: 40 | country = country['code'] 41 | 42 | schedule = None 43 | if data['schedule']: 44 | schedule = ', '.join(data['schedule']['days']) 45 | 46 | time = data['schedule']['time'] 47 | if time: 48 | am_pm = "AM" 49 | hour, minute = data['schedule']['time'].split(":", 1) 50 | hour = int(hour) 51 | if hour >= 13: 52 | hour -= 12 53 | am_pm = "PM" 54 | time = "{}:{} {}".format(hour, minute, am_pm) 55 | 56 | schedule += " @ {} EST".format(time) 57 | 58 | next_episode = data.get('_links', {}) \ 59 | .get('nextepisode', {}) \ 60 | .get('href', None) 61 | previous_episode = data.get('_links', {}) \ 62 | .get('previousepisode', {}) \ 63 | .get('href', None) 64 | 65 | imdb_url = None 66 | if data['externals']['imdb']: 67 | imdb_url = "https://imdb.com/title/{}".format( 68 | data['externals']['imdb'] 69 | ) 70 | 71 | return { 72 | 'name': data['name'], 73 | 'network': network, 74 | 'country': country, 75 | 'status': data['status'], 76 | 'schedule': schedule, 77 | 'imdb_url': imdb_url, 78 | '_links': { 79 | 'next_episode': next_episode, 80 | 'previous_episode': previous_episode, 81 | } 82 | } 83 | 84 | 85 | def format_data_full(show, next_episode, previous_episode): 86 | # Format header 87 | header = show['name'] 88 | if show['network'] and show['country']: 89 | header += " [{} - {}]".format(show['country'], show['network']) 90 | elif show['network'] or show['country']: 91 | header += " [{}]".format( 92 | show['network'] if show['network'] else show['country'] 93 | ) 94 | # don't show schedule if the next episode isn't announced 95 | if show['schedule'] and next_episode is not None: 96 | header += " - {}".format(show['schedule']) 97 | header += " - [{}]".format(show['status']) 98 | 99 | # Build messages 100 | messages = [header] 101 | messages.append("Last Episode: {}".format( 102 | format_episode(previous_episode) 103 | )) 104 | if show['status'] != 'Ended': 105 | messages.append("Next Episode: {}".format( 106 | format_episode(next_episode) 107 | )) 108 | if show['imdb_url']: 109 | messages.append(show['imdb_url']) 110 | 111 | return messages 112 | 113 | 114 | def format_data_short(show, next_episode): 115 | title = show['name'] 116 | if show['network']: 117 | title += " - " + show['network'] 118 | 119 | if show['status'] == 'Ended': 120 | next_ep = "Show Ended" 121 | else: 122 | next_ep = "Next Episode: {}".format( 123 | format_episode(next_episode) 124 | ) 125 | 126 | return "[ {} | {} ]".format(title, next_ep) 127 | 128 | 129 | @defer.inlineCallbacks 130 | def fetch_episode(uri): 131 | r = yield deferToThread( 132 | requests.get, 133 | uri, 134 | ) 135 | r.raise_for_status() 136 | 137 | data = r.json() 138 | return { 139 | 'name': data['name'], 140 | 'season': data['season'], 141 | 'episode': data['number'], 142 | 'airdate': (datetime.fromisoformat(data['airdate']) 143 | if data['airdate'] else 144 | None), 145 | } 146 | 147 | 148 | def format_episode(data): 149 | if data is None: 150 | return 'TBA' 151 | 152 | if data['season'] and data['episode']: 153 | ep_marker = "S{:0>2}E{:0>2}".format(data['season'], data['episode']) 154 | # hopefully nothing is missing a season also... 155 | else: 156 | ep_marker = "Season {:0>2} Special".format(data['season']) 157 | 158 | airdate = data['airdate'].strftime("%d %b %Y") \ 159 | if data['airdate'] else \ 160 | "TBA" 161 | 162 | return "{} - {} [{}]".format(ep_marker, data['name'], airdate) 163 | 164 | 165 | class TVPlugin: 166 | def __init__(self, cardinal, config): 167 | self.logger = logging.getLogger(__name__) 168 | self.cardinal = cardinal 169 | 170 | if config is None: 171 | config = {} 172 | 173 | self.default_output = config.get('default_output', 'short') 174 | self.private_output = config.get('private_output', 'full') 175 | self.channels = config.get('channels', {}) 176 | 177 | def get_output_format(self, channel): 178 | chantypes = self.cardinal.supported.getFeature("CHANTYPES") or ('#',) 179 | if channel[0] not in chantypes: 180 | return self.private_output 181 | 182 | # Fetch channel-specific output format, or default 183 | return self.channels.get(channel, {}) \ 184 | .get('output', self.default_output) 185 | 186 | @command('ep') 187 | @help('Get air date info for a TV show.') 188 | @help('Syntax: .ep ') 189 | @defer.inlineCallbacks 190 | def next_air_date(self, cardinal, user, channel, msg): 191 | try: 192 | show = msg.split(' ', 1)[1].strip() 193 | except IndexError: 194 | cardinal.sendMsg(channel, "Syntax: .ep ") 195 | return 196 | 197 | try: 198 | show = yield fetch_show(show) 199 | except ShowNotFoundException: 200 | cardinal.sendMsg( 201 | channel, 202 | "Couldn't find anything for '{}'".format(show) 203 | ) 204 | return 205 | except Exception: 206 | self.logger.exception("Error reaching TVMaze") 207 | cardinal.sendMsg(channel, "Error reaching TVMaze") 208 | return 209 | 210 | # Fetch next & previous episode info 211 | next_episode = None 212 | if show['_links']['next_episode']: 213 | next_episode = yield fetch_episode( 214 | show['_links']['next_episode']) 215 | 216 | if self.get_output_format(channel) == 'short': 217 | messages = [format_data_short(show, next_episode)] 218 | else: 219 | previous_episode = None 220 | if show['_links']['previous_episode']: 221 | previous_episode = yield fetch_episode( 222 | show['_links']['previous_episode']) 223 | 224 | messages = format_data_full(show, next_episode, previous_episode) 225 | 226 | # Show Name [Network] - [Status] 227 | # - or - 228 | # Show Name [UK - Network] - Date @ Time EST - [Status] 229 | # Last Episode: S05E10 - Nemesis Games [12 May 2021] 230 | # Next Episode: S05E11 - Mourning [19 May 2021] 231 | # https://imdb.com/tt1234123 232 | for message in messages: 233 | cardinal.sendMsg(channel, message) 234 | 235 | 236 | entrypoint = TVPlugin 237 | -------------------------------------------------------------------------------- /plugins/lastfm/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import logging 4 | 5 | import requests 6 | from twisted.internet import defer 7 | from twisted.internet.threads import deferToThread 8 | 9 | from cardinal.decorators import command, help 10 | 11 | 12 | class LastfmPlugin: 13 | def __init__(self, cardinal, config): 14 | # Initialize logger 15 | self.logger = logging.getLogger(__name__) 16 | 17 | self.cardinal = cardinal 18 | 19 | self.config = config or {} 20 | if 'api_key' not in self.config: 21 | raise Exception("Missing required api_key in config") 22 | 23 | # Connect to or create the database - raises on failure 24 | self._connect_or_create_db(cardinal) 25 | 26 | @property 27 | def api_key(self): 28 | return self.config['api_key'] 29 | 30 | def _connect_or_create_db(self, cardinal): 31 | self.conn = None 32 | self.conn = sqlite3.connect(os.path.join( 33 | cardinal.storage_path, 34 | 'database', 35 | 'lastfm-%s.db' % cardinal.network 36 | )) 37 | 38 | c = self.conn.cursor() 39 | c.execute( 40 | "CREATE TABLE IF NOT EXISTS users (" 41 | " nick text collate nocase," 42 | " vhost text," 43 | " username text" 44 | ")" 45 | ) 46 | self.conn.commit() 47 | 48 | @command('setlastfm') 49 | @help(["Sets the default Last.fm username for your nick.", 50 | "Syntax: .setlastfm "]) 51 | def set_user(self, cardinal, user, channel, msg): 52 | if not self.conn: 53 | cardinal.sendMsg( 54 | channel, 55 | "Unable to access local Last.fm database." 56 | ) 57 | self.logger.error( 58 | "Attempt to set username failed, no database connection" 59 | ) 60 | return 61 | 62 | message = msg.split() 63 | if len(message) != 2: 64 | cardinal.sendMsg(channel, "Syntax: .setlastfm ") 65 | return 66 | 67 | nick = user.nick 68 | vhost = user.vhost 69 | username = message[1] 70 | 71 | try: 72 | self._get_or_update_username(nick, vhost, username) 73 | except Exception: 74 | self.logger.exception("Error updating Last.fm username") 75 | cardinal.sendMsg(channel, "An unknown error occurred.") 76 | return 77 | 78 | cardinal.sendMsg( 79 | channel, 80 | "Your Last.fm username is now set to %s." % username 81 | ) 82 | 83 | def _get_or_update_username(self, nick, vhost, username): 84 | # Check if a user already exists 85 | c = self.conn.cursor() 86 | c.execute( 87 | "SELECT username FROM users WHERE nick=? OR vhost=?", 88 | (nick, vhost) 89 | ) 90 | result = c.fetchone() 91 | 92 | # Update or insert 93 | if result: 94 | c.execute( 95 | "UPDATE users SET username=? WHERE nick=? OR vhost=?", 96 | (username, nick, vhost) 97 | ) 98 | else: 99 | c.execute( 100 | "INSERT INTO users (nick, vhost, username) VALUES(?, ?, ?)", 101 | (nick, vhost, username) 102 | ) 103 | 104 | self.conn.commit() 105 | 106 | @command(['np', 'nowplaying']) 107 | @help("Get the Last.fm track currently played by a user (defaults to " 108 | "username set with .setlastfm)") 109 | @help("Syntax: .np [Last.fm username]") 110 | @defer.inlineCallbacks 111 | def now_playing(self, cardinal, user, channel, msg): 112 | # Open the cursor for the query to find a saved Last.fm username 113 | c = self.conn.cursor() 114 | 115 | message = msg.split() 116 | if len(message) > 2: 117 | cardinal.sendMsg(channel, "Syntax: .np [Last.fm username]") 118 | return 119 | 120 | # If they supplied user parameter, use that for the query instead 121 | if len(message) == 2: 122 | nick = message[1] 123 | c.execute("SELECT username FROM users WHERE nick=?", (nick,)) 124 | else: 125 | nick = user.nick 126 | vhost = user.vhost 127 | c.execute( 128 | "SELECT username FROM users WHERE nick=? OR vhost=?", 129 | (nick, vhost) 130 | ) 131 | result = c.fetchone() 132 | 133 | # Use the returned username, or the entered/user's nick otherwise 134 | try: 135 | username = message[1] 136 | except IndexError: 137 | username = user.nick 138 | 139 | if result: 140 | username = result[0] 141 | 142 | try: 143 | msg = yield self._get_np_result(username) 144 | except Exception: 145 | self.logger.exception("Error communicating with Last.fm") 146 | cardinal.sendMsg(channel, "Error communicating with Last.fm") 147 | return 148 | 149 | cardinal.sendMsg(channel, msg) 150 | 151 | @defer.inlineCallbacks 152 | def _get_np_result(self, username): 153 | r = yield deferToThread( 154 | requests.get, 155 | "http://ws.audioscrobbler.com/2.0/", 156 | params={ 157 | "method": "user.getrecenttracks", 158 | "user": username, 159 | "api_key": self.api_key, 160 | "limit": 1, 161 | "format": "json", 162 | } 163 | ) 164 | 165 | if r.status_code == 404: 166 | return "Last.fm user '{}' does not exist".format(username) 167 | # any other error code is unexpected 168 | r.raise_for_status() 169 | 170 | # check for known errors 171 | content = r.json() 172 | if 'error' in content and content['error'] == 10: 173 | self.logger.error( 174 | "Attempt to get now playing failed, API key incorrect" 175 | ) 176 | return "Last.fm plugin is not configured. Please set API key." 177 | elif 'error' in content and content['error'] == 6: 178 | return ( 179 | "Your Last.fm username is incorrect. No user exists by the " 180 | "username %s." % str(username)) 181 | elif 'error' in content: 182 | self.logger.error("Unknown error in API response: {}".format( 183 | content['error'] 184 | )) 185 | return "Unknown error while communicating with Last.fm" 186 | 187 | # finally, give successful result 188 | try: 189 | song = content['recenttracks']['track'][0]['name'] 190 | artist = content['recenttracks']['track'][0]['artist']['#text'] 191 | except IndexError: 192 | return "Last.fm user '{}' hasn't listened to anything yet".format( 193 | username) 194 | 195 | msg = "%s is now listening to: %s by %s" % ( 196 | str(username), str(song), str(artist)) 197 | 198 | try: 199 | yt_url = yield self._get_yt_url(song, artist) 200 | if yt_url is not None: 201 | msg = msg + " - YouTube: {}".format(yt_url) 202 | except Exception: 203 | pass 204 | 205 | return msg 206 | 207 | @defer.inlineCallbacks 208 | def _get_yt_url(self, song, artist): 209 | # XXX does this look safe to you? 210 | try: 211 | yt = self.cardinal.plugin_manager.plugins['youtube']['instance'] 212 | except KeyError: 213 | return None 214 | 215 | video = yield yt._search("{} {}".format(song, artist)) 216 | if video is None: 217 | return 218 | 219 | return "https://youtu.be/watch?v={}".format(video['id']['videoId']) 220 | 221 | def close(self): 222 | if self.conn: 223 | self.conn.close() 224 | 225 | 226 | entrypoint = LastfmPlugin 227 | -------------------------------------------------------------------------------- /plugins/seen/plugin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from cardinal.decorators import command, help, event 4 | from cardinal.util import ( 5 | is_action, 6 | parse_action, 7 | strip_formatting, 8 | ) 9 | 10 | PRIVMSG = 'PRIVMSG' # [channel, message] 11 | NOTICE = 'NOTICE' # [channel, message] 12 | NICK = 'NICK' # [new_nick] 13 | MODE = 'MODE' # [channel, mode] 14 | TOPIC = 'TOPIC' # [channel, topic] 15 | JOIN = 'JOIN' # [channel] 16 | PART = 'PART' # [channel, message] 17 | QUIT = 'QUIT' # [message] 18 | 19 | EPOCH = datetime.utcfromtimestamp(0) 20 | 21 | 22 | class SeenPlugin: 23 | def __init__(self, cardinal, config): 24 | if config is None: 25 | config = {} 26 | 27 | self.cardinal = cardinal 28 | self.ignored_channels = config.get('ignored_channels', []) 29 | 30 | self.db = cardinal.get_db('seen') 31 | with self.db() as db: 32 | if 'users' not in db: 33 | db['users'] = {} 34 | 35 | if 'tells' not in db: 36 | db['tells'] = {} 37 | 38 | # Fix case for old databases 39 | users = dict() 40 | for k, v in db['users'].items(): 41 | users[k.lower()] = v 42 | db['users'] = users 43 | 44 | def update_user(self, nick, action, params): 45 | if not isinstance(params, list): 46 | raise TypeError("params must be a list") 47 | 48 | with self.db() as db: 49 | db['users'][nick.lower()] = { 50 | 'timestamp': datetime.now(tz=timezone.utc).timestamp(), 51 | 'action': action, 52 | 'params': params, 53 | } 54 | 55 | @event('irc.privmsg') 56 | def irc_privmsg(self, cardinal, user, channel, message): 57 | if channel != cardinal.nickname and \ 58 | channel not in self.ignored_channels: 59 | self.update_user(user.nick, PRIVMSG, [channel, message]) 60 | 61 | self.do_tell(user.nick) 62 | 63 | @event('irc.notice') 64 | def irc_notice(self, cardinal, user, channel, message): 65 | if channel != cardinal.nickname and \ 66 | channel not in self.ignored_channels: 67 | self.update_user(user.nick, NOTICE, [channel, message]) 68 | 69 | self.do_tell(user.nick) 70 | 71 | @event('irc.mode') 72 | def irc_mode(self, cardinal, user, channel, mode): 73 | if channel not in self.ignored_channels: 74 | self.update_user(user.nick, MODE, [channel, mode]) 75 | 76 | self.do_tell(user.nick) 77 | 78 | @event('irc.topic') 79 | def irc_topic(self, cardinal, user, channel, topic): 80 | if channel not in self.ignored_channels: 81 | self.update_user(user.nick, TOPIC, [channel, topic]) 82 | 83 | self.do_tell(user.nick) 84 | 85 | @event('irc.join') 86 | def irc_join(self, cardinal, user, channel): 87 | if channel not in self.ignored_channels: 88 | self.update_user(user.nick, JOIN, [channel]) 89 | 90 | self.do_tell(user.nick) 91 | 92 | @event('irc.part') 93 | def irc_part(self, cardinal, user, channel, reason): 94 | if channel not in self.ignored_channels: 95 | self.update_user(user.nick, PART, [channel, reason]) 96 | 97 | self.do_tell(user.nick) 98 | 99 | @event('irc.nick') 100 | def irc_nick(self, cardinal, user, new_nick): 101 | self.update_user(user.nick, NICK, [new_nick]) 102 | 103 | self.do_tell(user.nick) 104 | 105 | @event('irc.quit') 106 | def irc_quit(self, cardinal, user, reason): 107 | self.update_user(user.nick, QUIT, [reason]) 108 | 109 | # TODO Add irc_kick/irc_kicked 110 | 111 | def do_tell(self, nick): 112 | nick = nick.lower() 113 | 114 | with self.db() as db: 115 | if nick in db['tells']: 116 | for message in db['tells'][nick]: 117 | self.cardinal.sendMsg( 118 | nick, 119 | f"{message['sender']} left a message: " 120 | f"{message['message']}" 121 | ) 122 | 123 | self.cardinal.sendMsg( 124 | nick, 125 | "You can send a message to an offline user with " 126 | ".tell " 127 | ) 128 | 129 | del db['tells'][nick] 130 | 131 | @staticmethod 132 | def _pretty_seconds(seconds): 133 | """Borrowed from the help plugin (_pretty_uptime)""" 134 | days, seconds = divmod(seconds, 60 * 60 * 24) 135 | hours, seconds = divmod(seconds, 60 * 60) 136 | minutes, seconds = divmod(seconds, 60) 137 | retval = "%d days " % days if days else "" 138 | retval += "%02d:%02d:%02d" % (hours, minutes, seconds) 139 | return retval 140 | 141 | def format_seen(self, nick): 142 | with self.db() as db: 143 | if nick.lower() not in db['users']: 144 | return "Sorry, I haven't seen {}.".format(nick) 145 | 146 | entry = db['users'][nick.lower()] 147 | 148 | dt_timestamp = datetime.fromtimestamp( 149 | entry['timestamp'], 150 | tz=timezone.utc, 151 | ) 152 | t_seen = dt_timestamp.strftime("%Y-%m-%d %H:%M:%S") 153 | t_ago = self._pretty_seconds((datetime 154 | .now(tz=timezone.utc) 155 | .replace(microsecond=0) 156 | - dt_timestamp).total_seconds()) 157 | 158 | message = "I last saw {} {} ago ({}). ".format(nick, t_ago, t_seen) 159 | 160 | action, params = entry['action'], entry['params'] 161 | if action == PRIVMSG: 162 | last_msg = params[1] 163 | if is_action(last_msg): 164 | last_msg = parse_action(nick, last_msg) 165 | 166 | message += "{} sent \"{}\" to {}.".format( 167 | nick, 168 | strip_formatting(last_msg), 169 | params[0], 170 | ) 171 | elif action == NOTICE: 172 | message += "{} sent notice \"{}\" to {}.".format( 173 | nick, 174 | strip_formatting(params[1]), 175 | params[0], 176 | ) 177 | elif action == JOIN: 178 | message += "{} joined {}.".format(nick, params[0]) 179 | elif action == PART: 180 | message += "{} left {}{}.".format( 181 | nick, 182 | params[0], 183 | (" ({})".format(strip_formatting(params[1])) 184 | if params[1] else 185 | ""), 186 | ) 187 | elif action == NICK: 188 | message += "{} renamed themselves {}.".format(nick, params[0]) 189 | elif action == MODE: 190 | message += "{} set mode {} on channel {}.".format( 191 | nick, 192 | params[1], 193 | params[0], 194 | ) 195 | elif action == TOPIC: 196 | message += "{} set {}'s topic to \"{}\".".format( 197 | nick, 198 | params[0], 199 | strip_formatting(params[1]), 200 | ) 201 | elif action == QUIT: 202 | message += "{} quit{}.".format( 203 | nick, 204 | (" ({})".format(strip_formatting(params[0])) 205 | if params[0] else 206 | ""), 207 | ) 208 | 209 | return message 210 | 211 | @command('seen') 212 | @help("Returns the last time a user was seen, and their last action.") 213 | @help("Syntax: .seen ") 214 | def seen(self, cardinal, user, channel, msg): 215 | try: 216 | nick = msg.split(' ')[1] 217 | except IndexError: 218 | return cardinal.sendMsg(channel, 'Syntax: .seen ') 219 | 220 | if nick.lower() == user.nick.lower(): 221 | cardinal.sendMsg(channel, "{}: Don't be daft.".format(user.nick)) 222 | return 223 | 224 | cardinal.sendMsg(channel, self.format_seen(nick)) 225 | 226 | @command('tell') 227 | @help("Tell an offline user something when they come online.") 228 | @help("Syntax: .tell ") 229 | def tell(self, cardinal, user, channel, msg): 230 | try: 231 | nick, message = msg.split(' ', 2)[1:] 232 | except IndexError: 233 | cardinal.sendMsg(channel, "Syntax: .tell ") 234 | return 235 | 236 | if nick.lower() == user.nick.lower(): 237 | cardinal.sendMsg(channel, "{}: Don't be daft.".format(user.nick)) 238 | return 239 | 240 | nick = nick.lower() 241 | 242 | with self.db() as db: 243 | tells = db['tells'].get(nick, []) 244 | tells.append({ 245 | 'sender': user.nick, 246 | 'message': message, 247 | }) 248 | db['tells'][nick] = tells 249 | 250 | cardinal.sendMsg(channel, f"{user.nick}: I'll let them know.") 251 | 252 | 253 | entrypoint = SeenPlugin 254 | -------------------------------------------------------------------------------- /plugins/admin/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cardinal.bot import user_info 4 | from cardinal.decorators import command, help 5 | 6 | 7 | class AdminPlugin: 8 | def __init__(self, cardinal, config): 9 | self.logger = logging.getLogger(__name__) 10 | 11 | self.admins = [] 12 | 13 | if config is None or not config.get('admins', False): 14 | self.logger.warning("No admins configured for admin plugin -- " 15 | "copy config.example.json to config.json and " 16 | "add your information.") 17 | return 18 | 19 | for admin in config['admins']: 20 | user = user_info( 21 | admin.get('nick', None), 22 | admin.get('user', None), 23 | admin.get('vhost', None), 24 | ) 25 | 26 | if user.nick is None and user.user is None and user.vhost is None: 27 | self.logger.error( 28 | "Invalid admin listed in admin plugin config -- at least " 29 | "one of nick, user, or vhost must be present.") 30 | continue 31 | 32 | self.admins.append(user) 33 | 34 | def is_admin(self, user): 35 | """Compares a user against the registered admins.""" 36 | for admin in self.admins: 37 | if (admin.nick is None or admin.nick == user.nick) and \ 38 | (admin.user is None or admin.user == user.user) and \ 39 | (admin.vhost is None or admin.vhost == user.vhost): 40 | return True 41 | 42 | return False 43 | 44 | @command('eval') 45 | @help("A super dangerous command that runs eval() on the input. " 46 | "(admin only)") 47 | @help("Syntax: .eval ") 48 | def eval(self, cardinal, user, channel, msg): 49 | if self.is_admin(user): 50 | command = ' '.join(msg.split()[1:]) 51 | if len(command) > 0: 52 | try: 53 | output = str(eval(command)) 54 | cardinal.sendMsg(channel, output) 55 | except Exception as e: 56 | cardinal.sendMsg(channel, 'Exception %s: %s' % 57 | (e.__class__, e)) 58 | raise 59 | 60 | @command('exec') 61 | @help("A dangerous command that runs exec() on the input. " + 62 | "(admin only)") 63 | @help("Syntax: .exec ") 64 | def execute(self, cardinal, user, channel, msg): 65 | if self.is_admin(user): 66 | command = ' '.join(msg.split()[1:]) 67 | if len(command) > 0: 68 | try: 69 | exec(command) 70 | cardinal.sendMsg(channel, "Ran exec() on input.") 71 | except Exception as e: 72 | cardinal.sendMsg(channel, 'Exception %s: %s' % 73 | (e.__class__, e)) 74 | raise 75 | 76 | @command(['load', 'reload']) 77 | @help("If no plugins are given after the command, reload all plugins. " 78 | "Otherwise, load (or reload) the selected plugins. (admin only)") 79 | @help("Syntax: .load [plugin [plugin ...]]") 80 | def load_plugins(self, cardinal, user, channel, msg): 81 | if self.is_admin(user): 82 | cardinal.sendMsg(channel, "%s: Loading plugins..." % user.nick) 83 | 84 | plugins = msg.split() 85 | plugins.pop(0) 86 | 87 | if len(plugins) == 0: 88 | plugins = [] 89 | for plugin in cardinal.plugin_manager: 90 | plugins.append(plugin['name']) 91 | 92 | failed = cardinal.plugin_manager.load(plugins) 93 | 94 | successful = [ 95 | plugin for plugin in plugins if plugin not in failed 96 | ] 97 | 98 | if len(successful) > 0: 99 | cardinal.sendMsg(channel, "Plugins loaded succesfully: %s." % 100 | ', '.join(sorted(successful))) 101 | 102 | if len(failed) > 0: 103 | cardinal.sendMsg(channel, "Plugins failed to load: %s." % 104 | ', '.join(sorted(failed))) 105 | 106 | @command('unload') 107 | @help("Unload selected plugins. (admin only)") 108 | @help("Syntax: .unload ") 109 | def unload_plugins(self, cardinal, user, channel, msg): 110 | nick = user.nick 111 | 112 | if self.is_admin(user): 113 | plugins = msg.split() 114 | plugins.pop(0) 115 | 116 | if len(plugins) == 0: 117 | cardinal.sendMsg(channel, "%s: No plugins to unload." % nick) 118 | return 119 | 120 | cardinal.sendMsg(channel, "%s: Unloading plugins..." % nick) 121 | 122 | # Returns a list of plugins that weren't loaded to begin with 123 | unknown = cardinal.plugin_manager.unload(plugins) 124 | successful = [ 125 | plugin for plugin in plugins if plugin not in unknown 126 | ] 127 | 128 | if len(successful) > 0: 129 | cardinal.sendMsg(channel, "Plugins unloaded succesfully: %s." % 130 | ', '.join(sorted(successful))) 131 | 132 | if len(unknown) > 0: 133 | cardinal.sendMsg(channel, "Unknown plugins: %s." % 134 | ', '.join(sorted(unknown))) 135 | 136 | @command('disable') 137 | @help("Disable plugins in a channel. (admin only)") 138 | @help("Syntax: .disable ") 139 | def disable_plugins(self, cardinal, user, channel, msg): 140 | if not self.is_admin(user): 141 | return 142 | 143 | channels = msg.split() 144 | channels.pop(0) 145 | 146 | if len(channels) < 2: 147 | cardinal.sendMsg( 148 | channel, 149 | "Syntax: .disable ") 150 | return 151 | 152 | cardinal.sendMsg(channel, "%s: Disabling plugins..." % user.nick) 153 | 154 | # First argument is plugin 155 | plugin = channels.pop(0) 156 | 157 | blacklisted = cardinal.plugin_manager.blacklist(plugin, channels) 158 | if not blacklisted: 159 | cardinal.sendMsg(channel, "Plugin %s does not exist" % plugin) 160 | return 161 | 162 | cardinal.sendMsg(channel, "Added to blacklist: %s." % 163 | ', '.join(sorted(channels))) 164 | 165 | @command('enable') 166 | @help("Enable plugins in a channel. (admin only)") 167 | @help("Syntax: .enable ") 168 | def enable_plugins(self, cardinal, user, channel, msg): 169 | if not self.is_admin(user): 170 | return 171 | 172 | channels = msg.split() 173 | channels.pop(0) 174 | 175 | if len(channels) < 2: 176 | cardinal.sendMsg( 177 | channel, 178 | "Syntax: .enable ") 179 | return 180 | 181 | cardinal.sendMsg(channel, "%s: Enabling plugins..." % user.nick) 182 | 183 | # First argument is plugin 184 | plugin = channels.pop(0) 185 | 186 | not_blacklisted = cardinal.plugin_manager.unblacklist(plugin, channels) 187 | if not_blacklisted is False: 188 | cardinal.sendMsg("Plugin %s does not exist" % plugin) 189 | 190 | successful = [ 191 | channel_ for channel_ in channels 192 | if channel_ not in not_blacklisted 193 | ] 194 | 195 | if len(successful) > 0: 196 | cardinal.sendMsg(channel, "Removed from blacklist: %s." % 197 | ', '.join(sorted(successful))) 198 | 199 | if len(not_blacklisted) > 0: 200 | cardinal.sendMsg(channel, "Wasn't in blacklist: %s." % 201 | ', '.join(sorted(not_blacklisted))) 202 | 203 | @command('join') 204 | @help("Joins selected channels. (admin only)") 205 | @help("Syntax: .join ") 206 | def join(self, cardinal, user, channel, msg): 207 | if self.is_admin(user): 208 | channels = msg.split() 209 | channels.pop(0) 210 | for channel in channels: 211 | cardinal.join(channel) 212 | 213 | @command('part') 214 | @help("Parts selected channels. (admin only)") 215 | @help("Syntax: .join ") 216 | def part(self, cardinal, user, channel, msg): 217 | if not self.is_admin(user): 218 | return 219 | 220 | channels = msg.split() 221 | channels.pop(0) 222 | if len(channels) > 0: 223 | for channel in channels: 224 | cardinal.part(channel) 225 | elif channel != user: 226 | cardinal.part(channel) 227 | 228 | @command('quit') 229 | @help("Quits the network with a quit message, if one is defined. " 230 | "(admin only)") 231 | @help("Syntax: .quit [message]") 232 | def quit(self, cardinal, user, channel, msg): 233 | if self.is_admin(user): 234 | cardinal.disconnect(' '.join(msg.split(' ')[1:])) 235 | 236 | @command('dbg_quit') 237 | @help("Quits the network without setting disconnect flag " 238 | "(for testing reconnection, admin only)") 239 | @help("Syntax: .dbg_quit") 240 | def debug_quit(self, cardinal, user, channel, msg): 241 | if self.is_admin(user): 242 | cardinal.quit('Debug disconnect') 243 | 244 | 245 | entrypoint = AdminPlugin 246 | -------------------------------------------------------------------------------- /plugins/movies/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from urllib.parse import urlparse 4 | 5 | from twisted.internet import defer 6 | from twisted.internet.threads import deferToThread 7 | import requests 8 | 9 | from cardinal.decorators import command, event, help 10 | from cardinal.exceptions import EventRejectedMessage 11 | from cardinal.util import F 12 | 13 | _indexes = {1: 'a', 2: 'b', 3: 'c', 4: 'd', 5: 'e'} 14 | _numerals = {v: k for k, v in _indexes.items()} 15 | 16 | 17 | class SearchCache: 18 | def __init__(self, max_length): 19 | self.max_length = max_length 20 | 21 | self._cache = dict() 22 | self._keys = list() 23 | 24 | def add(self, channel, results): 25 | # don't duplicate channel in list 26 | try: 27 | self._keys.remove(channel) 28 | except ValueError: 29 | pass 30 | 31 | self._keys.append(channel) 32 | self._cache[channel] = results 33 | 34 | # remove oldest item in list 35 | while len(self._keys) > self.max_length: 36 | key = self._keys.pop(0) 37 | del self._cache[key] 38 | 39 | def get(self, channel): 40 | return self._cache[channel] 41 | 42 | 43 | def get_imdb_link(id): 44 | return "https://imdb.com/title/{}".format(id) 45 | 46 | 47 | def format_data_short(data): 48 | if data['imdbRating'] == "N/A": 49 | rating = "" 50 | else: 51 | rating = "Rating: {} | ".format(float(data['imdbRating'])) 52 | 53 | return "[ IMDb: {title} ({year}) - {runtime} | {maybe_rating}Plot: {plot} | {link} ]".format( # noqa: E501 54 | title=data['Title'], 55 | year=data['Year'], 56 | runtime=data['Runtime'], 57 | maybe_rating=rating, 58 | plot=data['Plot'], 59 | link=get_imdb_link(data['imdbID']), 60 | ) 61 | 62 | 63 | def format_data_full(data): 64 | if data['imdbRating'] == "N/A": 65 | maybe_rating = "" 66 | else: 67 | rating = float(data['imdbRating']) 68 | stars = '*' * round(rating) 69 | stars += '.' * (10 - round(rating)) 70 | 71 | maybe_rating = "{}: {} [{}] ".format( 72 | F.bold("Rating"), data['imdbRating'], stars 73 | ) 74 | 75 | link = get_imdb_link(data['imdbID']) 76 | return [ 77 | "[IMDb] {title} ({year}) - {link}" 78 | .format( 79 | title=F.bold(data['Title']), 80 | year=data['Year'], 81 | link=link 82 | ), 83 | "{}{}: {} {}: {} {}: {}".format( 84 | maybe_rating, 85 | F.bold("Runtime"), data['Runtime'], 86 | F.bold("Genre"), data['Genre'], 87 | F.bold("Released"), data['Released'], 88 | ), 89 | "{}: {} {}: {}".format( 90 | F.bold("Director"), data['Director'], 91 | F.bold("Cast"), data['Actors'], 92 | ), 93 | "{}: {}".format(F.bold("Plot"), data['Plot']), 94 | ] 95 | 96 | 97 | class MoviePlugin: 98 | def __init__(self, cardinal, config): 99 | self.logger = logging.getLogger(__name__) 100 | self.cardinal = cardinal 101 | 102 | if config is None: 103 | raise Exception("Movie plugin requires configuration") 104 | 105 | self.api_key = config.get('api_key', None) 106 | self.default_output = config.get('default_output', 'short') 107 | self.private_output = config.get('private_output', 'full') 108 | self.channels = config.get('channels', {}) 109 | self.max_search_results = config.get('max_search_results', 5) 110 | if self.max_search_results > 5: 111 | raise Exception("max_search_results must be between 1-5") 112 | 113 | # Stores results for quick lookup 114 | self._search_cache = SearchCache(5) 115 | 116 | def search_allowed(self, channel): 117 | chantypes = self.cardinal.supported.getFeature("CHANTYPES") or ('#',) 118 | if channel[0] not in chantypes: 119 | return True 120 | 121 | return self.channels.get(channel, {}) \ 122 | .get('allow_search', False) 123 | 124 | def get_output_format(self, channel): 125 | chantypes = self.cardinal.supported.getFeature("CHANTYPES") or ('#',) 126 | if channel[0] not in chantypes: 127 | return self.private_output 128 | 129 | # Fetch channel-specific output format, or default 130 | return self.channels.get(channel, {}) \ 131 | .get('output', self.default_output) 132 | 133 | @command('movie') 134 | @help('Get the first movie IMDb result for a given search') 135 | @help('Syntax: .movie ') 136 | @defer.inlineCallbacks 137 | def movie(self, cardinal, user, channel, msg): 138 | yield self.imdb(cardinal, user, channel, msg, result_type='movie') 139 | 140 | @command('show') 141 | @help('Get the first TV show IMDb result for a given search') 142 | @help('Syntax: .show ') 143 | @defer.inlineCallbacks 144 | def show(self, cardinal, user, channel, msg): 145 | yield self.imdb(cardinal, user, channel, msg, result_type='series') 146 | 147 | @command(['omdb', 'imdb']) 148 | @help('Get the first IMDb result for a given search') 149 | @help('Syntax: .imdb ') 150 | @defer.inlineCallbacks 151 | def imdb(self, cardinal, user, channel, msg, result_type=None): 152 | # Before we do anything, let's make sure we'll be able to query omdb. 153 | if self.api_key is None: 154 | cardinal.sendMsg( 155 | channel, 156 | "Movie plugin is not configured correctly. Please set API key." 157 | ) 158 | return 159 | 160 | try: 161 | search_query = msg.split(' ', 1)[1].strip() 162 | except IndexError: 163 | command = 'imdb' 164 | if result_type == 'series': 165 | command = 'show' 166 | elif result_type == 'movie': 167 | command = 'movie' 168 | cardinal.sendMsg(channel, f"Syntax: .{command} ") 169 | return 170 | 171 | # first, check if this is just an IMDB id 172 | imdb_id = None 173 | if re.match(r'^tt\d{7,8}$', search_query): 174 | imdb_id = search_query 175 | # next, check if this is is a search selection 176 | elif search_query.isnumeric() \ 177 | and int(search_query) in _indexes: 178 | try: 179 | res_id = int(search_query) - 1 180 | res = self._search_cache.get(channel)[res_id] 181 | except KeyError: 182 | pass 183 | else: 184 | imdb_id = res['imdbID'] 185 | elif search_query in _numerals: 186 | try: 187 | res_id = _numerals[search_query] - 1 188 | res = self._search_cache.get(channel)[res_id] 189 | except KeyError: 190 | pass 191 | else: 192 | imdb_id = res['imdbID'] 193 | 194 | # otherwise, try to find the best match 195 | if not imdb_id: 196 | try: 197 | results = yield self._search(search_query, result_type) 198 | 199 | # Take the first result 200 | imdb_id = results[0]['imdbID'] 201 | 202 | # Unless there's an exact title match 203 | for result in results: 204 | if result['Title'].lower() == search_query: 205 | imdb_id = result['imdbID'] 206 | except RuntimeError as e: 207 | cardinal.sendMsg(channel, str(e)) 208 | return 209 | except Exception: 210 | self.logger.exception("Unknown error while searching") 211 | return 212 | 213 | try: 214 | yield self._send_result(cardinal, channel, imdb_id) 215 | except Exception: 216 | cardinal.sendMsg(channel, "Error fetching movie info.") 217 | self.logger.exception("Failed to parse info for %s", imdb_id) 218 | return 219 | 220 | @command('search') 221 | @help('Return IMDb search results (use .imdb for a single title)') 222 | @help('Syntax: .search ') 223 | @defer.inlineCallbacks 224 | def search(self, cardinal, user, channel, msg): 225 | if self.api_key is None: 226 | cardinal.sendMsg( 227 | channel, 228 | "Movie plugin is not configured correctly. Please set API key." 229 | ) 230 | return 231 | 232 | if not self.search_allowed(channel): 233 | cardinal.sendMsg(channel, 234 | "Movie search is not allowed in this channel. " 235 | "Try messaging me directly.") 236 | return 237 | 238 | try: 239 | search_query = msg.split(' ', 1)[1].strip() 240 | except IndexError: 241 | cardinal.sendMsg(channel, "Syntax: .search ") 242 | return 243 | 244 | try: 245 | results = yield self._search(search_query) 246 | except RuntimeError as e: 247 | cardinal.sendMsg(channel, str(e)) 248 | return 249 | if not results: 250 | cardinal.sendMsg(channel, "No results found.") 251 | return 252 | 253 | # Store these for quick lookup in imdb command 254 | self._search_cache.add(channel, results) 255 | 256 | i = 0 257 | for result in results: 258 | i += 1 259 | type_ = result['Type'].capitalize() 260 | link = get_imdb_link(result['imdbID']) 261 | cardinal.sendMsg( 262 | channel, 263 | f"{i}. {result['Title']} ({result['Year']}) " 264 | f"[{type_}] - {link}" 265 | ) 266 | if i >= self.max_search_results: 267 | break 268 | 269 | cardinal.sendMsg( 270 | channel, 271 | "Use .imdb to view more. (1/a, 2/b, etc.)" 272 | ) 273 | 274 | @defer.inlineCallbacks 275 | def _search(self, search_query, result_type=None): 276 | params = {} 277 | if result_type: 278 | params['type'] = result_type 279 | 280 | # separate out year if search ends in 4 digits 281 | if len(search_query) > 5 and search_query[-5] == " " \ 282 | and search_query[-4:].isnumeric(): 283 | params['y'] = search_query[-4:] 284 | search_query = search_query[:-5] 285 | params['s'] = search_query 286 | 287 | try: 288 | result = yield self._form_request(params) 289 | except Exception: 290 | self.logger.exception("Unable to connect to OMDb") 291 | raise RuntimeError("Failed to connect to OMDb") 292 | 293 | if result['Response'] == 'False': 294 | if "Error" in result: 295 | self.logger.error( 296 | "Error attempting to search OMDb: %s" % result['Error'] 297 | ) 298 | 299 | raise RuntimeError("Error searching OMDb: %s" % result['Error']) 300 | 301 | return result['Search'] 302 | 303 | @defer.inlineCallbacks 304 | def _send_result(self, cardinal, channel, imdb_id): 305 | params = { 306 | "i": imdb_id, 307 | "plot": self.get_output_format(channel), 308 | } 309 | 310 | try: 311 | result = yield self._form_request(params) 312 | except Exception: 313 | self.logger.exception("Unable to connect to OMDb") 314 | raise RuntimeError("Failed to connect to OMDb") 315 | 316 | if result['Response'] == 'False': 317 | if "Error" in result: 318 | self.logger.error( 319 | "Error attempting to search OMDb: %s" % result['Error'] 320 | ) 321 | 322 | cardinal.sendMsg( 323 | channel, 324 | "Error searching OMDb: %s" % result['Error'] 325 | ) 326 | return 327 | 328 | for message in self._format_data(channel, result): 329 | cardinal.sendMsg(channel, message) 330 | 331 | @defer.inlineCallbacks 332 | def _form_request(self, payload): 333 | payload.update({ 334 | 'apikey': self.api_key, 335 | 'v': 1, 336 | 'r': 'json', 337 | }) 338 | 339 | return (yield deferToThread( 340 | requests.get, 341 | 'https://www.omdbapi.com', 342 | params=payload 343 | )).json() 344 | 345 | def _format_data(self, channel, data): 346 | if self.get_output_format(channel) == 'short': 347 | return [format_data_short(data)] 348 | else: 349 | return format_data_full(data) 350 | 351 | @event('urls.detection') 352 | @defer.inlineCallbacks 353 | def imdb_handler(self, cardinal, channel, url): 354 | if self.api_key is None: 355 | raise EventRejectedMessage 356 | 357 | o = urlparse(url) 358 | 359 | if o.netloc not in ('imdb.com', 'www.imdb.com'): 360 | raise EventRejectedMessage 361 | 362 | match = re.match(r'^/title/(tt\d{7,8})(?:$|/.*)', o.path) 363 | if not match: 364 | raise EventRejectedMessage 365 | 366 | try: 367 | yield self._send_result(cardinal, channel, match.group(1)) 368 | except Exception: 369 | self.logger.exception("Error parsing IMDB ID %s", match.group(1)) 370 | raise EventRejectedMessage 371 | 372 | 373 | entrypoint = MoviePlugin 374 | -------------------------------------------------------------------------------- /_assets/cardinal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 18 | 20 | 24 | 28 | 29 | 31 | 35 | 39 | 40 | 42 | 46 | 50 | 51 | 53 | 57 | 61 | 62 | 64 | 68 | 72 | 73 | 75 | 79 | 83 | 84 | 86 | 90 | 94 | 95 | 98 | 100 | 104 | 108 | 109 | 111 | 115 | 119 | 120 | 122 | 126 | 130 | 131 | 133 | 137 | 141 | 142 | 149 | 152 | 153 | 160 | 163 | 164 | 173 | 182 | 190 | 199 | 208 | 216 | 225 | 234 | 244 | 252 | 260 | 261 | 263 | 264 | 266 | image/svg+xml 267 | 269 | 270 | 271 | 272 | 273 | 276 | 280 | 284 | 288 | 292 | 296 | 300 | 304 | 308 | 312 | 316 | 320 | 324 | 327 | 332 | 337 | 338 | 342 | 346 | 350 | 354 | 358 | 362 | 363 | 364 | --------------------------------------------------------------------------------