commit a901c2351ab5179ad0d0cf181500f4f044426d13 Author: Alek Ratzloff Date: Mon May 23 18:47:28 2022 -0700 Initial commit with functional framework(!) and example plugin Signed-off-by: Alek Ratzloff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd4f21c --- /dev/null +++ b/.gitignore @@ -0,0 +1,286 @@ +# Don't add production config.toml to the repo +config.toml + + +## Standard gitignore for tools follows. + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/python,django,vim,visualstudiocode diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..e6c4997 --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +async-irc = "*" +toml = "*" +types-toml = "*" + +[dev-packages] +mypy = "*" +black = "*" + +[requires] +python_version = "3.10" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..d949aab --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,158 @@ +{ + "_meta": { + "hash": { + "sha256": "383b4f4eb921d1fb5480b141cb699db971c38445f9f5debb5e13de4a878b9ace" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "async-irc": { + "hashes": [ + "sha256:4da55d270405446cd6a48c2766416a026b832502139cb9469f1d998d74ac39e9", + "sha256:9f96e2cdd9c42a20c8bb3a9f668071d49357546c3e9d91477d0a0d1cb965f0a6" + ], + "index": "pypi", + "version": "==0.1.7" + }, + "py-irclib": { + "hashes": [ + "sha256:2236c42883d218501bd59d302f486c740e89ad47703212ce35f0ae78b9cd7321", + "sha256:9ac8a092f47a7517352b2cf83d3004bbe174b1ba8261c5da95495e4857f91b55" + ], + "markers": "python_version >= '3.5'", + "version": "==0.3.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "index": "pypi", + "version": "==0.10.2" + }, + "types-toml": { + "hashes": [ + "sha256:05a8da4bfde2f1ee60e90c7071c063b461f74c63a9c3c1099470c08d6fa58615", + "sha256:a567fe2614b177d537ad99a661adc9bfc8c55a46f95e66370a4ed2dd171335f9" + ], + "index": "pypi", + "version": "==0.10.7" + } + }, + "develop": { + "black": { + "hashes": [ + "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b", + "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176", + "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09", + "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a", + "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015", + "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79", + "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb", + "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20", + "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464", + "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968", + "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82", + "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21", + "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0", + "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265", + "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b", + "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a", + "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72", + "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce", + "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0", + "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a", + "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163", + "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad", + "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d" + ], + "index": "pypi", + "version": "==22.3.0" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "mypy": { + "hashes": [ + "sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d", + "sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8", + "sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de", + "sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038", + "sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed", + "sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334", + "sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff", + "sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2", + "sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22", + "sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2", + "sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2", + "sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605", + "sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb", + "sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519", + "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0", + "sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc", + "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b", + "sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f", + "sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075", + "sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef", + "sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb", + "sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a", + "sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b" + ], + "index": "pypi", + "version": "==0.950" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "pathspec": { + "hashes": [ + "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", + "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" + ], + "version": "==0.9.0" + }, + "platformdirs": { + "hashes": [ + "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788", + "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19" + ], + "markers": "python_version >= '3.7'", + "version": "==2.5.2" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708", + "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376" + ], + "markers": "python_version >= '3.7'", + "version": "==4.2.0" + } + } +} diff --git a/omnibot/__init__.py b/omnibot/__init__.py new file mode 100644 index 0000000..58c56b0 --- /dev/null +++ b/omnibot/__init__.py @@ -0,0 +1 @@ +from . import bot diff --git a/omnibot/__main__.py b/omnibot/__main__.py new file mode 100644 index 0000000..2bf69d5 --- /dev/null +++ b/omnibot/__main__.py @@ -0,0 +1,24 @@ +import asyncio +import logging + +from .config import ServerConfig +from .bot import Bot + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)-12s - %(levelname)-8s - %(message)s", +) +log = logging.getLogger(__name__) + + +async def main(): + log.debug("Loading config") + config = ServerConfig() + config.load("config.toml") + log.debug("Using configuration: %s", config) + + server = Bot(config) + await server.run() + + +asyncio.run(main()) diff --git a/omnibot/__pycache__/__init__.cpython-310.pyc b/omnibot/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fc888fd Binary files /dev/null and b/omnibot/__pycache__/__init__.cpython-310.pyc differ diff --git a/omnibot/__pycache__/__main__.cpython-310.pyc b/omnibot/__pycache__/__main__.cpython-310.pyc new file mode 100644 index 0000000..71c70f4 Binary files /dev/null and b/omnibot/__pycache__/__main__.cpython-310.pyc differ diff --git a/omnibot/bot.py b/omnibot/bot.py new file mode 100644 index 0000000..7654da9 --- /dev/null +++ b/omnibot/bot.py @@ -0,0 +1,129 @@ +import asyncio +import logging +from typing import Sequence, Set + +from asyncirc.protocol import IrcProtocol +from asyncirc.server import Server +from irclib.parser import Message + +from .config import ServerConfig +from . import plugin + + +log = logging.getLogger(__name__) + + +class Bot: + def __init__(self, server_config: ServerConfig): + self.__server_config = server_config + self.__plugins = [ + plugin.load_plugin(server_config, config) + for config in server_config.plugins + ] + # TODO - this may not be needed + self.__channels: Set[str] = set() + + @property + def server_config(self) -> ServerConfig: + return self.__server_config + + @property + def plugins(self) -> Sequence[plugin.Plugin]: + return self.__plugins + + def channel_plugins(self, channel: str) -> Sequence[plugin.Plugin]: + return [plugin for plugin in self.plugins if channel in plugin.channels] + + async def run(self): + loop = asyncio.get_running_loop() + server = Server( + self.server_config.server, + self.server_config.port, + self.server_config.use_ssl, + ) + self.connection = IrcProtocol([server], self.server_config.nick, loop=loop) + # Register events + # self.connection.register("*", self.on_message) + self.connection.register("001", self.on_connect) + self.connection.register("JOIN", self.on_join) + self.connection.register("PART", self.on_part) + self.connection.register("KICK", self.on_kick) + self.connection.register("PRIVMSG", self.on_message) + # Connect + log.info("Connecting to %s", self.server_config.server) + await self.connection.connect() + # Keepalive loop + await self.keepalive() + + async def on_connect(self, conn: IrcProtocol, message: Message): + # Join rooms + for ch in self.server_config.all_channels: + msg = Message(None, None, "JOIN", ch) + conn.send(str(msg)) + + async def on_join(self, conn: IrcProtocol, message: Message): + channel = message.parameters[0] + who = message.prefix + if who.nick == self.server_config.nick: + self.__channels |= {channel} + if channel not in self.server_config.all_channels: + # Try to leave this channel that we were forced to join like some kind of dog + msg = Message(None, None, "PART", channel) + conn.send(str(msg)) + + # Pass the message along to available plugins + plugins = self.channel_plugins(channel) + await asyncio.gather( + *[plugin.on_join(conn, channel, who) for plugin in plugins] + ) + + async def __on_part(self, conn: IrcProtocol, message: Message): + "This is the common logic between on_part and on_kick. Don't call this." + channel = message.parameters[0] + who = message.prefix + if who.nick == self.server_config.nick: + self.__channels -= {channel} + if channel not in self.server_config.all_channels: + # Try to rejoin this channel that we were force-parted from + msg = Message(None, None, "JOIN", channel) + conn.send(str(msg)) + + async def on_part(self, conn: IrcProtocol, message: Message): + await self.__on_part(conn, message) + # Pass the message along to available plugins + channel = message.parameters[0] + who = message.prefix + plugins = self.channel_plugins(channel) + await asyncio.gather( + *[plugin.on_part(conn, channel, who) for plugin in plugins] + ) + + async def on_kick(self, conn: IrcProtocol, message: Message): + await self.__on_part(conn, message) + # Pass the message along to available plugins + channel = message.parameters[0] + who = message.prefix + plugins = self.channel_plugins(channel) + await asyncio.gather( + *[plugin.on_kick(conn, channel, who) for plugin in plugins] + ) + + async def on_message(self, conn: IrcProtocol, message: Message): + # Pass the message to the plugins + log.debug("%s", message) + channel = message.parameters[0] + who = message.prefix + if who.nick == self.server_config.nick: + # Don't raise on_message events for ourselves. + return + line = message.parameters[1] + plugins = self.channel_plugins(channel) + await asyncio.gather( + *[plugin.on_message(conn, channel, who, line) for plugin in plugins] + ) + + async def keepalive(self): + # loop while we're connected, check every second + log.info("Starting keepalive loop") + while self.connection.connected: + await asyncio.sleep(1.0) diff --git a/omnibot/config.py b/omnibot/config.py new file mode 100644 index 0000000..79b0ba2 --- /dev/null +++ b/omnibot/config.py @@ -0,0 +1,85 @@ +import dataclasses +from pathlib import Path +from typing import Any, Mapping, Sequence, Set + +import toml + + +PluginConfig = Mapping[str, Any] + + +class ConfigError(Exception): + def __init__(self, which: str, hint: str | None, plugin: str | None = None): + self.which = which + self.hint = hint + self.plugin = plugin + msg = f"{self.which}" + if self.hint: + msg = f"{msg}: {self.hint}" + if self.plugin: + msg = f"in config for plugin {plugin}: {msg}" + else: + msg = f"in server config: {msg}" + super(ConfigError, self).__init__(msg) + + +@dataclasses.dataclass +class ServerConfig: + server: str = "" + use_ssl: bool = False + port: int = 6667 + plugins: Sequence[PluginConfig] = dataclasses.field(default_factory=list) + channels: Sequence[str] = dataclasses.field(default_factory=list) + nick: str = "omnibot" + + def load(self, path: Path | str): + if isinstance(path, str): + path = Path(path) + with open(path) as fp: + obj = toml.load(fp) + + if "server" not in obj: + raise ConfigError("server", "must be present") + if not isinstance(obj["server"], str): + raise ConfigError("server", "must be a string") + self.server = obj["server"] + + if "use_ssl" in obj: + if not isinstance(obj["use_ssl"], bool): + raise ConfigError("use_ssl", "must be a boolean") + self.use_ssl = obj["use_ssl"] + else: + # Don't use SSL by default + self.use_ssl = False + + if "port" in obj: + if not isinstance(obj["port"], int): + raise ConfigError("port", "must be an integer") + if not (0 < obj["port"] <= 65535): + raise ConfigError("port", "must be between 0 and 65535") + self.port = obj["port"] + else: + if self.use_ssl: + self.port = 6697 + else: + self.port = 6667 + + if "plugins" in obj: + if not isinstance(obj["plugins"], Sequence): + raise ConfigError( + "plugins", "must be a mapping of configuration values" + ) + self.plugins = obj["plugins"] + + if "nick" in obj: + if not isinstance(obj["nick"], str): + raise ConfigError("nick", "must be a string") + self.nick = obj["nick"] + + @property + def all_channels(self) -> Set[str]: + channels = set(self.channels) + for plugin in self.plugins: + if "channels" in plugin: + channels |= set(plugin["channels"]) + return channels diff --git a/omnibot/plugin.py b/omnibot/plugin.py new file mode 100644 index 0000000..474e785 --- /dev/null +++ b/omnibot/plugin.py @@ -0,0 +1,60 @@ +import importlib +import logging +from typing import Sequence + +from asyncirc.protocol import IrcProtocol +from irclib.parser import Message, Prefix + +from .config import PluginConfig, ServerConfig + + +log = logging.getLogger(__name__) + + +class Plugin: + def __init__(self, server_config: ServerConfig, plugin_config: PluginConfig): + self.__server_config = server_config + self.__plugin_config = plugin_config + + @property + def channels(self) -> Sequence[str]: + if "channels" in self.plugin_config: + return self.plugin_config["channels"] + else: + return self.server_config.channels + + @property + def plugin_config(self) -> PluginConfig: + return self.__plugin_config + + @property + def server_config(self) -> ServerConfig: + return self.__server_config + + @property + def nick(self) -> str: + return self.server_config.nick + + def send_to(self, conn: IrcProtocol, who: str, message: str): + message = Message(None, None, "PRIVMSG", who, message) + conn.send(str(message)) + + async def on_join(self, conn: IrcProtocol, channel: str, who: Prefix): + pass + + async def on_part(self, conn: IrcProtocol, channel: str, who: Prefix): + pass + + async def on_kick(self, conn: IrcProtocol, channel: str, who: Prefix): + pass + + async def on_message(self, conn: IrcProtocol, channel: str, who: Prefix, line: str): + pass + + +def load_plugin(server_config: ServerConfig, plugin_config: PluginConfig) -> Plugin: + name = plugin_config["module"] + log.info("Loading plugin %s", name) + plugin_module = importlib.import_module(name) + PluginType = plugin_module.PLUGIN_TYPE + return PluginType(server_config, plugin_config) diff --git a/omnibot/server.py b/omnibot/server.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fortune.py b/plugins/fortune.py new file mode 100644 index 0000000..33596a7 --- /dev/null +++ b/plugins/fortune.py @@ -0,0 +1,41 @@ +import logging +import random +from typing import Sequence + +from asyncirc.protocol import IrcProtocol +from irclib.parser import Message, Prefix +from omnibot.plugin import Plugin +from omnibot.config import ConfigError + + +log = logging.getLogger(__name__) + + +class Fortune(Plugin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if "fortunes" not in self.plugin_config: + raise ConfigError("fortunes", "must be supplied to Fortune plugin") + fortunes = self.plugin_config["fortunes"] + if isinstance(fortunes, str): + with open(fortunes) as fp: + self.fortunes = [line.strip() for line in fp if line] + elif isinstance(fortunes, Sequence): + self.fortunes = fortunes + else: + raise ConfigError( + "fortunes", + "must be either a list or a path to a file containing the fortunes", + ) + + async def on_message(self, conn: IrcProtocol, channel: str, who: Prefix, line: str): + parts = line.split() + if not parts: + return + word = parts[0] + if word == "!fortune": + fortune = random.choice(self.fortunes) + self.send_to(conn, channel, fortune) + + +PLUGIN_TYPE = Fortune