From 78f30b19feb824249dd9eb52249efcfc2ef09d5e Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Fri, 16 May 2025 20:04:18 +0200 Subject: [PATCH 1/6] Separate Discord API and command implementations --- main.py => commands/__init__.py | 116 ++++++------------------ bottleclip.py => commands/bottleclip.py | 22 ++++- discordbot.py | 61 +++++++++++++ 3 files changed, 109 insertions(+), 90 deletions(-) rename main.py => commands/__init__.py (51%) mode change 100755 => 100644 rename bottleclip.py => commands/bottleclip.py (62%) create mode 100755 discordbot.py diff --git a/main.py b/commands/__init__.py old mode 100755 new mode 100644 similarity index 51% rename from main.py rename to commands/__init__.py index 6020168..9d7fa47 --- a/main.py +++ b/commands/__init__.py @@ -1,58 +1,41 @@ -#!/usr/bin/env python3 - import asyncio -import os -import sys -from os.path import splitext -from time import sleep import aiomqtt -import pytz -from discord import File, Intents -from discord.ext import commands -from discord_webhook import DiscordEmbed, DiscordWebhook +from discord_webhook import DiscordEmbed -import bottleclip as clip +import commands.bottleclip as bottleclip -mqtt_host = os.getenv("MQTT_HOST") -if not mqtt_host: - print("MQTT_HOST unset") - sys.exit(1) -token = os.getenv("DISCORD_TOKEN") -if not token: - print("DISCORD_TOKEN unset") - sys.exit(1) +_mqtt_host = None -webhook_url = os.getenv("DISCORD_WEBHOOK_URL") -if not webhook_url: - print("DISCORD_WEBHOOK_URL unset") - sys.exit(1) +def setup(bot, mqtt_host): + global _mqtt_host + _mqtt_host = mqtt_host -timezone = pytz.timezone("Europe/Amsterdam") - -# Discord bot stuff -intents = Intents.default() -intents.message_content = True -intents.members = True -HobbyBot = commands.Bot(command_prefix="!", description="Bitlair Bot", intents=intents) + # !state + bot.command(description="Bitlair Space State")(state) + # !co2 + bot.command(description="co2 levels")(co2) + # !temp + bot.command(description="Temperature")(temp) + # !humid + bot.command(description="Humidity")(humid) + # !np + bot.command(description="Now Playing")(np) + # !bottleclip + bot.command( + name="bottleclip", + description="Generate a bottle-clip STL file suitable for printing", + )(bottleclip.command) async def mqtt_get_one(topic, timeout=20): async with asyncio.timeout(timeout): - async with aiomqtt.Client(mqtt_host) as mq: + async with aiomqtt.Client(_mqtt_host) as mq: await mq.subscribe(topic) return await anext(mq.messages) -# Define bot commands -@HobbyBot.event -async def on_ready(): - print(f"Logged in as {HobbyBot.user} (ID: {HobbyBot.user.id})") - - -# !state -@HobbyBot.command(description="Bitlair Space State") async def state(ctx): async with ctx.typing(): try: @@ -67,8 +50,6 @@ async def state(ctx): raise err -# !co2 -@HobbyBot.command(description="co2 levels") async def co2(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -79,8 +60,6 @@ async def co2(ctx, where="hoofdruimte"): raise err -# !temp -@HobbyBot.command(description="Temperature") async def temp(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -91,8 +70,6 @@ async def temp(ctx, where="hoofdruimte"): raise err -# !humid -@HobbyBot.command(description="Humidity") async def humid(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -103,39 +80,12 @@ async def humid(ctx, where="hoofdruimte"): raise err -# !np -@HobbyBot.command(description="Now Playing") async def np(ctx): async with ctx.typing(): await ctx.reply("Now playing: Darude - Sandstorm") -# !bottleclip -@HobbyBot.command(description="Generate a bottle-clip STL file suitable for printing") -async def bottleclip(ctx, icon: str = "", ears: bool = False): - icons = clip.list_icons() - if icon not in icons: - await ctx.reply( - f"usage: `!bottleclip []`\n* `icon` must be one of {', '.join(icons)}" - ) - return - - async with ctx.typing(): - label = ctx.author.nick or ctx.author.global_name - stl_file = clip.create_stl(label, icon, ears) - - icon_name, _ = splitext(icon) - with_ears = '_ears' if ears else '' - attach = File(stl_file.name, filename=f"{label}_{icon_name}{with_ears}.stl") - await ctx.reply("Ok! Hier is je flessenclip", file=attach) - - -def webhook_message(msg): - webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True, content=msg) - webhook.execute() - - -async def event_task(): +async def run_events(mqtt_host): retained = { "bitlair/alarm", "bitlair/photos", @@ -154,27 +104,15 @@ async def event_task(): payload = msg.payload.decode("ascii") if msg.topic.matches("bitlair/alarm"): - webhook_message(f"Alarm: {payload}") + yield f"Alarm: {payload}" elif msg.topic.matches("bitlair/state"): - webhook_message(f"Bitlair is now {payload.upper()}") + yield f"Bitlair is now {payload.upper()}" elif msg.topic.matches("bitlair/state/djo"): - webhook_message(f"DJO is now {payload.upper()}") + yield f"DJO is now {payload.upper()}" elif msg.topic.matches("bitlair/photos"): - webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True) embed = DiscordEmbed(title="WIP Cam", color="fc5d1d") embed.set_url(f"https://bitlair.nl/fotos/view/{payload}") embed.set_image(f"https://bitlair.nl/fotos/photos/{payload}") - webhook.add_embed(embed) - webhook.execute() + yield embed else: continue - sleep(1) # Prevent triggering rate limits. - - -async def main(): - t1 = asyncio.create_task(HobbyBot.start(token)) - t2 = asyncio.create_task(event_task()) - await asyncio.gather(t1, t2) - - -asyncio.run(main()) diff --git a/bottleclip.py b/commands/bottleclip.py similarity index 62% rename from bottleclip.py rename to commands/bottleclip.py index c6fc2e6..198a9fb 100644 --- a/bottleclip.py +++ b/commands/bottleclip.py @@ -1,9 +1,11 @@ import os import subprocess -from os.path import abspath, join +from os.path import abspath, join, splitext from tempfile import NamedTemporaryFile from typing import List +from discord import File + def resource_dir() -> str: p = os.getenv("BOTTLECLIP_RESOURCES") @@ -42,3 +44,21 @@ def create_stl(label: str, icon: str, ears: bool) -> NamedTemporaryFile: stl.seek(0) return stl + + +async def command(ctx, icon: str = "", ears: bool = False): + icons = list_icons() + if icon not in icons: + await ctx.reply( + f"usage: `!bottleclip []`\n* `icon` must be one of {', '.join(icons)}" + ) + return + + async with ctx.typing(): + label = ctx.author.nick or ctx.author.global_name + stl_file = create_stl(label, icon, ears) + + icon_name, _ = splitext(icon) + with_ears = "_ears" if ears else "" + attach = File(stl_file.name, filename=f"{label}_{icon_name}{with_ears}.stl") + await ctx.reply("Ok! Hier is je flessenclip", file=attach) diff --git a/discordbot.py b/discordbot.py new file mode 100755 index 0000000..c92eabe --- /dev/null +++ b/discordbot.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +import asyncio +import os +import sys + +import pytz +from discord import Intents +from discord.ext import commands +from discord_webhook import DiscordEmbed, DiscordWebhook + +import commands as botcommands + +mqtt_host = os.getenv("MQTT_HOST") +if not mqtt_host: + print("MQTT_HOST unset") + sys.exit(1) +token = os.getenv("DISCORD_TOKEN") +if not token: + print("DISCORD_TOKEN unset") + sys.exit(1) +webhook_url = os.getenv("DISCORD_WEBHOOK_URL") +if not webhook_url: + print("DISCORD_WEBHOOK_URL unset") + sys.exit(1) + +timezone = pytz.timezone("Europe/Amsterdam") + +intents = Intents.default() +intents.message_content = True +intents.members = True +bot = commands.Bot(command_prefix="!", description="Bitlair Bot", intents=intents) +botcommands.setup(bot, mqtt_host) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + + +async def event_task(): + async for message in botcommands.run_events(mqtt_host): + webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True) + if type(message) is str: + webhook.content = message + elif type(message) is DiscordEmbed: + webhook.add_embed(message) + else: + print(f"invalid message type: {str(message)}") + continue + webhook.execute() + + +async def main(): + t1 = asyncio.create_task(bot.start(token)) + t2 = asyncio.create_task(event_task()) + await asyncio.gather(t1, t2) + + +if __name__ == "__main__": + asyncio.run(main()) From 5dccc2d4bb834d4364989eaff80b0cb3fa4a9bf2 Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Sun, 1 Jun 2025 16:41:04 +0200 Subject: [PATCH 2/6] Add a IRC wrapper for Discord command implementations --- .gitignore | 1 + ircbot.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 ++ requirements.txt | 11 +++-- uv.lock | 18 ++++++++ 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100755 ircbot.py diff --git a/.gitignore b/.gitignore index 1bc83ee..ba92e59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.ruff_cache /.venv +__pycache__ diff --git a/ircbot.py b/ircbot.py new file mode 100755 index 0000000..38baf1f --- /dev/null +++ b/ircbot.py @@ -0,0 +1,115 @@ +import asyncio +import os +import sys + +import pydle +from discord_webhook import DiscordEmbed + +import commands as botcommands + + +class DiscordAuthor: + def __init__(self, nick): + self.nick = nick + self.global_name = nick + + +class DiscordContext: + def __init__(self, bot, target, source, message): + self._bot = bot + self._target = target + self._source = source + self._message = message + self.author = DiscordAuthor(source) + + def typing(self): + class NilTyping: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + return NilTyping() + + async def reply(self, m): + lines = m.strip().split("\n") + lines = [f"{self._source}: {line}" for line in lines] + await self._bot.message(self._target, "\n".join(lines)) + + +class DiscordImplBot(pydle.Client): + def __init__(self, channel, nickname, *, push_messages=None, prefix="!"): + super().__init__(nickname, realname=nickname) + self._channel = channel + self._cmd_prefix = prefix + self._cmds = {} + self._push_messages = push_messages + self._push_messages_task = None + + async def on_connect(self): + await self.join(self._channel) + if self._push_messages is not None: + self._push_messages_task = asyncio.create_task(self._handle_push_messages()) + + async def on_disconnect(self): + if self._push_messages_task is not None: + self._push_messages_task.cancel() + self._push_messages_task = None + + async def _handle_push_messages(self): + async for message in self._push_messages(): + if isinstance(message, DiscordEmbed): + await self.message(self._channel, f"{message.title} {message.url}") + else: + await self.message(self._channel, message) + + async def on_message(self, target, source, message): + # Don't respond to our own messages, as this leads to a positive feedback loop. + if source == self.nickname: + return + if not message.startswith(self._cmd_prefix): + return + + cmd_fn = self._cmds.get(message.removeprefix(self._cmd_prefix)) + if not cmd_fn: + return + + ctx = DiscordContext(self, target, source, message) + await cmd_fn(ctx) + + # Discord API: Register a new command + def command(self, *, name=None, description=None): + def _reg_cmd(handler): + nonlocal name + name = name or handler.__name__ + self._cmds[name] = handler + + return _reg_cmd + + +def main(*, server, channel, nick, mqtt_host): + def push_messages(): + return botcommands.run_events(mqtt_host) + + bot = DiscordImplBot( + channel, + nick, + push_messages=push_messages, + ) + botcommands.setup(bot, mqtt_host) + bot.run(server, tls=True) + + +if __name__ == "__main__": + mqtt_host = os.getenv("MQTT_HOST") + if not mqtt_host: + print("MQTT_HOST unset") + sys.exit(1) + + main( + server="irc.libera.chat", + channel="#bitlair-bot-test", + nick="Bitlair", + mqtt_host=mqtt_host, + ) diff --git a/pyproject.toml b/pyproject.toml index cc655ca..3f1316c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,9 @@ dependencies = [ "aiomqtt>=2.4.0", "discord-py>=2.5.2", "discord-webhook>=1.4.1", + "pydle[sasl]", "pytz>=2025.2", ] + +[tool.uv.sources] +pydle = { git = "https://github.com/TheHolyRoger/pydle", branch = "fix-py10" } diff --git a/requirements.txt b/requirements.txt index d7615c5..f336bad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv export --format requirements-txt +# uv export --format=requirements.txt aiohappyeyeballs==2.6.1 \ --hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558 \ --hash=sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8 @@ -38,9 +38,7 @@ aiohttp==3.11.18 \ --hash=sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261 \ --hash=sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000 \ --hash=sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1 - # via - # discord-bot - # discord-py + # via discord-py aiomqtt==2.4.0 \ --hash=sha256:721296e2b79df5f6c7c4dfc91700ae0166953a4127735c92637859619dbd84e4 \ --hash=sha256:ab0f18fc5b7ffaa57451c407417d674db837b00a9c7d953cccd02be64f046c17 @@ -308,6 +306,11 @@ propcache==0.3.1 \ # via # aiohttp # yarl +pure-sasl==0.6.2 \ + --hash=sha256:53c1355f5da95e2b85b2cc9a6af435518edc20c81193faa0eea65fdc835138f4 + # via pydle +pydle @ git+https://github.com/TheHolyRoger/pydle@befd10fb340a1934fbdbc9c8ccaca68620cba0f6 + # via discord-bot pytz==2025.2 \ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 diff --git a/uv.lock b/uv.lock index 173362c..2c06120 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,7 @@ dependencies = [ { name = "aiomqtt" }, { name = "discord-py" }, { name = "discord-webhook" }, + { name = "pydle", extra = ["sasl"] }, { name = "pytz" }, ] @@ -197,6 +198,7 @@ requires-dist = [ { name = "aiomqtt", specifier = ">=2.4.0" }, { name = "discord-py", specifier = ">=2.5.2" }, { name = "discord-webhook", specifier = ">=1.4.1" }, + { name = "pydle", extras = ["sasl"], git = "https://github.com/TheHolyRoger/pydle?branch=fix-py10" }, { name = "pytz", specifier = ">=2025.2" }, ] @@ -420,6 +422,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" }, ] +[[package]] +name = "pure-sasl" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/b7/a0d688f86c869073cc28c0640899394a1cf68a6d87ee78a09565e9037da6/pure-sasl-0.6.2.tar.gz", hash = "sha256:53c1355f5da95e2b85b2cc9a6af435518edc20c81193faa0eea65fdc835138f4", size = 11617, upload-time = "2019-10-14T21:43:57.13Z" } + +[[package]] +name = "pydle" +version = "1.0.1" +source = { git = "https://github.com/TheHolyRoger/pydle?branch=fix-py10#befd10fb340a1934fbdbc9c8ccaca68620cba0f6" } + +[package.optional-dependencies] +sasl = [ + { name = "pure-sasl" }, +] + [[package]] name = "pytz" version = "2025.2" From 24088f3ba871b71176ba97b823150d8254ffea31 Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Sun, 1 Jun 2025 16:52:35 +0200 Subject: [PATCH 3/6] irc: Use notice for push events --- ircbot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index 38baf1f..b03df49 100755 --- a/ircbot.py +++ b/ircbot.py @@ -60,9 +60,9 @@ class DiscordImplBot(pydle.Client): async def _handle_push_messages(self): async for message in self._push_messages(): if isinstance(message, DiscordEmbed): - await self.message(self._channel, f"{message.title} {message.url}") + await self.notice(self._channel, f"{message.title} {message.url}") else: - await self.message(self._channel, message) + await self.notice(self._channel, message) async def on_message(self, target, source, message): # Don't respond to our own messages, as this leads to a positive feedback loop. From 2c8bda61510c2d06fbd3ce00f0bd5bd86e0aa45c Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Sun, 1 Jun 2025 17:36:39 +0200 Subject: [PATCH 4/6] irc: Parse command args --- ircbot.py | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/ircbot.py b/ircbot.py index b03df49..87c93af 100755 --- a/ircbot.py +++ b/ircbot.py @@ -1,5 +1,14 @@ +""" +This module implements an IRC bot that aims to be compatible with the discord.py API in order to +facilitate re-use of the commands for that bot. + +The supported features are the subset of what is needed for this project and not more than that. +""" + import asyncio +import inspect import os +import shlex import sys import pydle @@ -8,6 +17,24 @@ from discord_webhook import DiscordEmbed import commands as botcommands +def command_args(handler, sig): + parse = [] + for par in list(sig.parameters.values())[1:]: + if par.annotation in (str, inspect._empty): + parse.append(lambda v: v) + elif par.annotation is bool: + parse.append(lambda v: bool(v)) + else: + raise Exception(f"unsuported type {par.annotation}") + + async def _proxy(ctx): + args = shlex.split(ctx._message)[1:] + args = [parse[i](v) for i, v in enumerate(args)] + await handler(ctx, *args) + + return _proxy + + class DiscordAuthor: def __init__(self, nick): self.nick = nick @@ -32,7 +59,11 @@ class DiscordContext: return NilTyping() - async def reply(self, m): + async def reply(self, m, file=None): + if file: + # TODO: Host the file somewhere so it can be downloaded. + m += f" {file.filename}" + lines = m.strip().split("\n") lines = [f"{self._source}: {line}" for line in lines] await self._bot.message(self._target, "\n".join(lines)) @@ -71,7 +102,8 @@ class DiscordImplBot(pydle.Client): if not message.startswith(self._cmd_prefix): return - cmd_fn = self._cmds.get(message.removeprefix(self._cmd_prefix)) + name = message.removeprefix(self._cmd_prefix).split(" ", 1)[0] + cmd_fn = self._cmds.get(name) if not cmd_fn: return @@ -83,7 +115,13 @@ class DiscordImplBot(pydle.Client): def _reg_cmd(handler): nonlocal name name = name or handler.__name__ - self._cmds[name] = handler + + sig = inspect.signature(handler) + if len(sig.parameters) == 1: + # Just the DiscordContext argument. + self._cmds[name] = handler + else: + self._cmds[name] = command_args(handler, sig) return _reg_cmd From d067be38b4c4cbe1da065ba90577e9e3dbd3a8c3 Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Sun, 1 Jun 2025 17:59:06 +0200 Subject: [PATCH 5/6] irc: Make connection params configurable --- ircbot.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ircbot.py b/ircbot.py index 87c93af..729654c 100755 --- a/ircbot.py +++ b/ircbot.py @@ -140,14 +140,22 @@ def main(*, server, channel, nick, mqtt_host): if __name__ == "__main__": - mqtt_host = os.getenv("MQTT_HOST") - if not mqtt_host: + if not (mqtt_host := os.getenv("MQTT_HOST")): print("MQTT_HOST unset") sys.exit(1) + if not (irc_server := os.getenv("IRC_SERVER")): + print("IRC_SERVER unset") + sys.exit(1) + if not (irc_channel := os.getenv("IRC_CHANNEL")): + print("IRC_CHANNEL unset") + sys.exit(1) + if not (irc_nick := os.getenv("IRC_NICK")): + print("IRC_NICK unset") + sys.exit(1) main( - server="irc.libera.chat", - channel="#bitlair-bot-test", - nick="Bitlair", + server=irc_server, + channel=irc_channel, + nick=irc_nick, mqtt_host=mqtt_host, ) From 704b623b2906ea9b70d95da057804a2e3a3a7b2f Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Sun, 1 Jun 2025 18:08:41 +0200 Subject: [PATCH 6/6] irc: Set tls_verify to false for now --- ircbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index 729654c..918ff62 100755 --- a/ircbot.py +++ b/ircbot.py @@ -136,7 +136,7 @@ def main(*, server, channel, nick, mqtt_host): push_messages=push_messages, ) botcommands.setup(bot, mqtt_host) - bot.run(server, tls=True) + bot.run(server, tls=True, tls_verify=False) if __name__ == "__main__":