Compare commits

..

6 commits

8 changed files with 300 additions and 94 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/.ruff_cache /.ruff_cache
/.venv /.venv
__pycache__

116
main.py → commands/__init__.py Executable file → Normal file
View file

@ -1,58 +1,41 @@
#!/usr/bin/env python3
import asyncio import asyncio
import os
import sys
from os.path import splitext
from time import sleep
import aiomqtt import aiomqtt
import pytz from discord_webhook import DiscordEmbed
from discord import File, Intents
from discord.ext import commands
from discord_webhook import DiscordEmbed, DiscordWebhook
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") _mqtt_host = None
if not token:
print("DISCORD_TOKEN unset")
sys.exit(1)
webhook_url = os.getenv("DISCORD_WEBHOOK_URL") def setup(bot, mqtt_host):
if not webhook_url: global _mqtt_host
print("DISCORD_WEBHOOK_URL unset") _mqtt_host = mqtt_host
sys.exit(1)
timezone = pytz.timezone("Europe/Amsterdam") # !state
bot.command(description="Bitlair Space State")(state)
# Discord bot stuff # !co2
intents = Intents.default() bot.command(description="co2 levels")(co2)
intents.message_content = True # !temp
intents.members = True bot.command(description="Temperature")(temp)
HobbyBot = commands.Bot(command_prefix="!", description="Bitlair Bot", intents=intents) # !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 def mqtt_get_one(topic, timeout=20):
async with asyncio.timeout(timeout): 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) await mq.subscribe(topic)
return await anext(mq.messages) 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 def state(ctx):
async with ctx.typing(): async with ctx.typing():
try: try:
@ -67,8 +50,6 @@ async def state(ctx):
raise err raise err
# !co2
@HobbyBot.command(description="co2 levels")
async def co2(ctx, where="hoofdruimte"): async def co2(ctx, where="hoofdruimte"):
async with ctx.typing(): async with ctx.typing():
try: try:
@ -79,8 +60,6 @@ async def co2(ctx, where="hoofdruimte"):
raise err raise err
# !temp
@HobbyBot.command(description="Temperature")
async def temp(ctx, where="hoofdruimte"): async def temp(ctx, where="hoofdruimte"):
async with ctx.typing(): async with ctx.typing():
try: try:
@ -91,8 +70,6 @@ async def temp(ctx, where="hoofdruimte"):
raise err raise err
# !humid
@HobbyBot.command(description="Humidity")
async def humid(ctx, where="hoofdruimte"): async def humid(ctx, where="hoofdruimte"):
async with ctx.typing(): async with ctx.typing():
try: try:
@ -103,39 +80,12 @@ async def humid(ctx, where="hoofdruimte"):
raise err raise err
# !np
@HobbyBot.command(description="Now Playing")
async def np(ctx): async def np(ctx):
async with ctx.typing(): async with ctx.typing():
await ctx.reply("Now playing: Darude - Sandstorm") await ctx.reply("Now playing: Darude - Sandstorm")
# !bottleclip async def run_events(mqtt_host):
@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 <icon> [<ears y|n>]`\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 = { retained = {
"bitlair/alarm", "bitlair/alarm",
"bitlair/photos", "bitlair/photos",
@ -154,27 +104,15 @@ async def event_task():
payload = msg.payload.decode("ascii") payload = msg.payload.decode("ascii")
if msg.topic.matches("bitlair/alarm"): if msg.topic.matches("bitlair/alarm"):
webhook_message(f"Alarm: {payload}") yield f"Alarm: {payload}"
elif msg.topic.matches("bitlair/state"): 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"): 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"): elif msg.topic.matches("bitlair/photos"):
webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True)
embed = DiscordEmbed(title="WIP Cam", color="fc5d1d") embed = DiscordEmbed(title="WIP Cam", color="fc5d1d")
embed.set_url(f"https://bitlair.nl/fotos/view/{payload}") embed.set_url(f"https://bitlair.nl/fotos/view/{payload}")
embed.set_image(f"https://bitlair.nl/fotos/photos/{payload}") embed.set_image(f"https://bitlair.nl/fotos/photos/{payload}")
webhook.add_embed(embed) yield embed
webhook.execute()
else: else:
continue 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())

View file

@ -1,9 +1,11 @@
import os import os
import subprocess import subprocess
from os.path import abspath, join from os.path import abspath, join, splitext
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import List from typing import List
from discord import File
def resource_dir() -> str: def resource_dir() -> str:
p = os.getenv("BOTTLECLIP_RESOURCES") p = os.getenv("BOTTLECLIP_RESOURCES")
@ -42,3 +44,21 @@ def create_stl(label: str, icon: str, ears: bool) -> NamedTemporaryFile:
stl.seek(0) stl.seek(0)
return stl 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 <icon> [<ears y|n>]`\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)

61
discordbot.py Executable file
View file

@ -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())

161
ircbot.py Executable file
View file

@ -0,0 +1,161 @@
"""
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,
)

View file

@ -8,5 +8,9 @@ dependencies = [
"aiomqtt>=2.4.0", "aiomqtt>=2.4.0",
"discord-py>=2.5.2", "discord-py>=2.5.2",
"discord-webhook>=1.4.1", "discord-webhook>=1.4.1",
"pydle[sasl]",
"pytz>=2025.2", "pytz>=2025.2",
] ]
[tool.uv.sources]
pydle = { git = "https://github.com/TheHolyRoger/pydle", branch = "fix-py10" }

View file

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv export --format requirements-txt # uv export --format=requirements.txt
aiohappyeyeballs==2.6.1 \ aiohappyeyeballs==2.6.1 \
--hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558 \ --hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558 \
--hash=sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8 --hash=sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8
@ -38,9 +38,7 @@ aiohttp==3.11.18 \
--hash=sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261 \ --hash=sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261 \
--hash=sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000 \ --hash=sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000 \
--hash=sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1 --hash=sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1
# via # via discord-py
# discord-bot
# discord-py
aiomqtt==2.4.0 \ aiomqtt==2.4.0 \
--hash=sha256:721296e2b79df5f6c7c4dfc91700ae0166953a4127735c92637859619dbd84e4 \ --hash=sha256:721296e2b79df5f6c7c4dfc91700ae0166953a4127735c92637859619dbd84e4 \
--hash=sha256:ab0f18fc5b7ffaa57451c407417d674db837b00a9c7d953cccd02be64f046c17 --hash=sha256:ab0f18fc5b7ffaa57451c407417d674db837b00a9c7d953cccd02be64f046c17
@ -308,6 +306,11 @@ propcache==0.3.1 \
# via # via
# aiohttp # aiohttp
# yarl # 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 \ pytz==2025.2 \
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00

18
uv.lock generated
View file

@ -189,6 +189,7 @@ dependencies = [
{ name = "aiomqtt" }, { name = "aiomqtt" },
{ name = "discord-py" }, { name = "discord-py" },
{ name = "discord-webhook" }, { name = "discord-webhook" },
{ name = "pydle", extra = ["sasl"] },
{ name = "pytz" }, { name = "pytz" },
] ]
@ -197,6 +198,7 @@ requires-dist = [
{ name = "aiomqtt", specifier = ">=2.4.0" }, { name = "aiomqtt", specifier = ">=2.4.0" },
{ name = "discord-py", specifier = ">=2.5.2" }, { name = "discord-py", specifier = ">=2.5.2" },
{ name = "discord-webhook", specifier = ">=1.4.1" }, { 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" }, { 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" }, { 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]] [[package]]
name = "pytz" name = "pytz"
version = "2025.2" version = "2025.2"