diff --git a/.gitignore b/.gitignore index ba92e59..1bc83ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /.ruff_cache /.venv -__pycache__ diff --git a/commands/bottleclip.py b/bottleclip.py similarity index 62% rename from commands/bottleclip.py rename to bottleclip.py index 198a9fb..c6fc2e6 100644 --- a/commands/bottleclip.py +++ b/bottleclip.py @@ -1,11 +1,9 @@ import os import subprocess -from os.path import abspath, join, splitext +from os.path import abspath, join from tempfile import NamedTemporaryFile from typing import List -from discord import File - def resource_dir() -> str: p = os.getenv("BOTTLECLIP_RESOURCES") @@ -44,21 +42,3 @@ 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 deleted file mode 100755 index c92eabe..0000000 --- a/discordbot.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/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()) diff --git a/ircbot.py b/ircbot.py deleted file mode 100755 index 918ff62..0000000 --- a/ircbot.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -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 -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 - 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, 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)) - - -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.notice(self._channel, f"{message.title} {message.url}") - else: - 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. - if source == self.nickname: - return - if not message.startswith(self._cmd_prefix): - return - - name = message.removeprefix(self._cmd_prefix).split(" ", 1)[0] - cmd_fn = self._cmds.get(name) - 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__ - - 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 - - -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, tls_verify=False) - - -if __name__ == "__main__": - 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_server, - channel=irc_channel, - nick=irc_nick, - mqtt_host=mqtt_host, - ) diff --git a/commands/__init__.py b/main.py old mode 100644 new mode 100755 similarity index 51% rename from commands/__init__.py rename to main.py index 9d7fa47..6020168 --- a/commands/__init__.py +++ b/main.py @@ -1,41 +1,58 @@ +#!/usr/bin/env python3 + import asyncio +import os +import sys +from os.path import splitext +from time import sleep import aiomqtt -from discord_webhook import DiscordEmbed +import pytz +from discord import File, Intents +from discord.ext import commands +from discord_webhook import DiscordEmbed, DiscordWebhook -import commands.bottleclip as bottleclip +import bottleclip as clip +mqtt_host = os.getenv("MQTT_HOST") +if not mqtt_host: + print("MQTT_HOST unset") + sys.exit(1) -_mqtt_host = None +token = os.getenv("DISCORD_TOKEN") +if not token: + print("DISCORD_TOKEN unset") + sys.exit(1) -def setup(bot, mqtt_host): - global _mqtt_host - _mqtt_host = mqtt_host +webhook_url = os.getenv("DISCORD_WEBHOOK_URL") +if not webhook_url: + print("DISCORD_WEBHOOK_URL unset") + sys.exit(1) - # !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) +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) 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: @@ -50,6 +67,8 @@ async def state(ctx): raise err +# !co2 +@HobbyBot.command(description="co2 levels") async def co2(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -60,6 +79,8 @@ async def co2(ctx, where="hoofdruimte"): raise err +# !temp +@HobbyBot.command(description="Temperature") async def temp(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -70,6 +91,8 @@ async def temp(ctx, where="hoofdruimte"): raise err +# !humid +@HobbyBot.command(description="Humidity") async def humid(ctx, where="hoofdruimte"): async with ctx.typing(): try: @@ -80,12 +103,39 @@ 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") -async def run_events(mqtt_host): +# !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(): retained = { "bitlair/alarm", "bitlair/photos", @@ -104,15 +154,27 @@ async def run_events(mqtt_host): payload = msg.payload.decode("ascii") if msg.topic.matches("bitlair/alarm"): - yield f"Alarm: {payload}" + webhook_message(f"Alarm: {payload}") elif msg.topic.matches("bitlair/state"): - yield f"Bitlair is now {payload.upper()}" + webhook_message(f"Bitlair is now {payload.upper()}") elif msg.topic.matches("bitlair/state/djo"): - yield f"DJO is now {payload.upper()}" + webhook_message(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}") - yield embed + webhook.add_embed(embed) + webhook.execute() 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/pyproject.toml b/pyproject.toml index 3f1316c..cc655ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,5 @@ 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 f336bad..d7615c5 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,7 +38,9 @@ aiohttp==3.11.18 \ --hash=sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261 \ --hash=sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000 \ --hash=sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1 - # via discord-py + # via + # discord-bot + # discord-py aiomqtt==2.4.0 \ --hash=sha256:721296e2b79df5f6c7c4dfc91700ae0166953a4127735c92637859619dbd84e4 \ --hash=sha256:ab0f18fc5b7ffaa57451c407417d674db837b00a9c7d953cccd02be64f046c17 @@ -306,11 +308,6 @@ 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 2c06120..173362c 100644 --- a/uv.lock +++ b/uv.lock @@ -189,7 +189,6 @@ dependencies = [ { name = "aiomqtt" }, { name = "discord-py" }, { name = "discord-webhook" }, - { name = "pydle", extra = ["sasl"] }, { name = "pytz" }, ] @@ -198,7 +197,6 @@ 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" }, ] @@ -422,22 +420,6 @@ 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"