29 Commits
Author SHA1 Message Date
rich fe26d9e2ed update link at top 2026-07-02 13:06:24 +00:00
rich 831ee9cbae printstat now shows status for both printers, old printstat function preserved as octostat. updated info command and readme as well 2026-02-28 16:39:05 -05:00
rich 545958b474 changing default bot status flip to 7 seconds 2026-02-28 16:24:19 -05:00
rich 367ef5b7af reworking the bot status display when two print jobs are running 2026-02-28 16:01:54 -05:00
rich 2a156b3a3c terminating ffpmeg caputre in event of timeout reached 2026-02-27 16:37:51 -05:00
rich 8ac70c645b updating "3D Print" to OctoPi Print 2026-02-27 14:28:08 -05:00
rich 4bdf61a018 matrix bot will auto join upgraded rooms 2026-02-22 12:13:27 -05:00
rich cd6955a567 readme update 2026-02-21 18:59:14 -05:00
rich eb922b4271 formatting 2026-02-21 17:55:28 -05:00
rich f51025d5f7 prettifying 2026-02-21 17:50:07 -05:00
rich 1f06251ca4 cant send raw 2026-02-21 17:46:17 -05:00
rich 70ea6508e2 fix for printer images 2026-02-21 17:44:32 -05:00
rich e79abf7715 fixing crash after bambustat call in matrix 2026-02-21 17:40:23 -05:00
rich 945e2464f7 what a buggy mess 2026-02-21 17:34:49 -05:00
rich 93b6995554 verify sessions 2026-02-21 17:32:36 -05:00
rich b45f617c73 more fixes 2026-02-21 17:22:22 -05:00
rich d872725af0 change to direct login 2026-02-21 17:18:27 -05:00
rich c18c89ac41 update for e2e 2026-02-21 17:06:20 -05:00
rich 787b0a2fb1 reworking to support e2e rooms 2026-02-21 17:03:44 -05:00
rich 31270db12b debugging 2026-02-21 16:56:17 -05:00
rich a2e1a42fe6 more matrix debugging 2026-02-21 16:45:57 -05:00
rich 1572eabac5 more matrix debugging 2026-02-21 16:41:24 -05:00
rich baf4905beb adding debugging 2026-02-21 16:38:58 -05:00
rich 123aa22abf trying to fix matrix 2026-02-21 16:32:28 -05:00
rich c2490d2159 adding safe handling of sync error 2026-02-21 16:25:32 -05:00
rich 36b8eccb6c initial add of matrix compatibility 2026-02-21 15:15:10 -05:00
rich e104538b5f removing ds_store 2026-02-20 18:20:14 -05:00
rich 516ba09548 mor minor removals for cleanup 2026-02-20 18:19:34 -05:00
rich 6f984b3d4e adding .gitignore 2026-02-20 18:16:07 -05:00
8 changed files with 770 additions and 135 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+4
View File
@@ -1 +1,5 @@
.DS_Store
.vscode/launch.json
__pycache__
bot_config.conf
.claude
-9
View File
@@ -1,9 +0,0 @@
stages:
- deploy
deploy:
stage: deploy
script: /var/lib/gitlab-runner/deploy_r0b3bot.sh
only:
- dev
- master
+34 -10
View File
@@ -1,22 +1,24 @@
*** This project lives @ https://gitlab.rickelobe.com/Bots/R0b3BOT ***
[![pipeline status](http://gitlab.rickelobe.com/Bots/r0b3BOT/badges/master/pipeline.svg)](http://gitlab.rickelobe.com/Bots/r0b3BOT/commits/master)
*** This project lives @ https://git.rickelobe.com/rich/R0b3BOT ***
# Description
This is R0b3's Discord Bot. It's creation was basically a challenge posed a coworker who said "Hey, you can write Discord Bots in Python... Make one to display your 3D printer status!".
This is R0b3's bot, originally built as a Discord bot to display 3D printer status. It now also runs as a Matrix bot, both operating simultaneously in the same process.
# Features
* Query & Monitor OctoPrint 3D Printer Status using the `$printstat` command (Requires that your printer is connected to [Octoprint](https://octoprint.org/))<br>
* Query & Monitor OctoPrint 3D Printer Status using the `$octostat` command (Requires that your printer is connected to [Octoprint](https://octoprint.org/))<br>
![](images/Discord_Printstat_Command.png)
* Query & Monitor Bambu Lab Printer Status using the `$bambustat` command (Requires a Bambu Lab printer accessible on the local network via MQTT)<br>
* Displays print state, progress percentage, time remaining, current layer, bed temp, nozzle temp, and chamber temp
* Optionally captures and attaches a camera snapshot from the printer's RTSPS stream (requires [ffmpeg](https://ffmpeg.org/) on the host)
* Updates the bot's Discord status to show live Bambu print progress while a print is running
* Play a selection of sound clips to voice channel members using the various commands (`$torololo`, `$oops`, `$promoted`, etc)
* Query & Monitor Bambu Lab Printer Status using the `$bambustat` command (Requires a Bambu Lab printer accessible on the local network via MQTT)
* Displays print state, progress percentage, time remaining, current layer, bed temp, nozzle temp, and chamber temp
* Optionally captures and attaches a camera snapshot from the printer's RTSPS stream (requires [ffmpeg](https://ffmpeg.org/) on the host)
* Updates the bot's Discord status to show live print progress; alternates between OctoPi and Bambu status every 30 seconds when both printers are running simultaneously
* Query both printers at once using the `$printstat` command
* Play a selection of sound clips to voice channel members using the various commands (`$trololo`, `$oops`, `$promoted`, etc) — Discord only
* **Matrix support** — the bot can connect to a self-hosted Matrix homeserver (Synapse/Dendrite) and respond to the same text commands in E2E-encrypted rooms
# Commands
![Screenshot of $help](images/command_list.png)
All text commands are available on both Discord and Matrix. Sound/voice commands are Discord-only.
# Requirements
Install all Python dependencies using the provided `requirements.txt`:
@@ -30,11 +32,19 @@ Dependencies:
- `requests` >= 2.32.3 — HTTP client for HomeAssistant REST API calls
- `bambu-connect` >= 0.3.1 — Bambu Lab printer integration via local MQTT
- `paho-mqtt` >= 2.0 — MQTT client library (required by bambu-connect)
- `matrix-nio[e2e]` >= 0.21 — Matrix bot client with E2E encryption support (optional, only needed if using Matrix)
**Optional: Camera snapshot support**
To enable camera snapshots with `$bambustat`, [ffmpeg](https://ffmpeg.org/) must be installed on the host running the bot. Set `bambu_camera_enable = true` in `bot_config.conf` once ffmpeg is available.
**Optional: Matrix E2E encryption**
Matrix support with E2E encryption requires the `libolm` system library:
```
apt-get install libolm3 # Debian/Ubuntu
```
# Configuration
1. Create a Discord Bot via the [Discord Developer Portal](https://discord.com/developers/applications)
2. Copy `bot_config.conf.example` to `bot_config.conf` and fill in your values (see below)
@@ -42,6 +52,8 @@ To enable camera snapshots with `$bambustat`, [ffmpeg](https://ffmpeg.org/) must
## bot_config.conf Keys
### [bot-config]
| Key | Description |
|-----|-------------|
| `discord_auth_token` | Bot token from the Discord Developer Console |
@@ -60,3 +72,15 @@ To enable camera snapshots with `$bambustat`, [ffmpeg](https://ffmpeg.org/) must
| `mmr_checker_enable` | Set to `true` to enable MMR checker |
| `mmr_checker_interval` | Seconds between MMR checks |
| `mmr_api_url` | MMR API URL |
### [matrix-config] (optional)
Adding this section enables the Matrix bot. All five keys are required to activate it.
| Key | Description |
|-----|-------------|
| `matrix_homeserver` | Full URL of your Matrix homeserver (e.g. `https://matrix.example.org`) |
| `matrix_user_id` | Full Matrix user ID for the bot account (e.g. `@r0b3bot:example.org`) |
| `matrix_password` | Password for the bot account. Used on first run to register the device; credentials are then saved to `matrix_store_path` and reused on subsequent restarts |
| `matrix_room_ids` | Comma-separated list of room IDs to listen in (e.g. `!abc123:example.org,!def456:example.org`) — use room IDs, not aliases |
| `matrix_store_path` | Directory for the E2E crypto store (Olm sessions, device keys). Default: `./matrix_store/` |
+22
View File
@@ -32,3 +32,25 @@ statping_api_key = <Statping API Key>
mmr_checker_enable = <true/false>
mmr_checker_interval = <number of seconds between checks>
mmr_api_url = <MMR API URL>
[matrix-config]
#
# Optional Matrix bot support. All four keys must be present to enable the Matrix bot.
# The bot will join the specified rooms and respond to the same command prefix as Discord.
# Voice/sound commands are Discord-only and will return a friendly message on Matrix.
#
# matrix_homeserver - Full URL of your Matrix homeserver (e.g. https://matrix.example.org)
# matrix_user_id - Full Matrix user ID for the bot account (e.g. @r0b3bot:example.org)
# matrix_password - Password for the bot account. The bot logs in via matrix-nio on first
# start and saves the access token + device ID to matrix_store_path so
# the same Matrix device is reused across restarts (required for E2E).
# matrix_room_ids - Comma-separated list of Matrix room IDs the bot should listen in.
# Use room IDs (!abc123:example.org), not aliases — IDs are stable.
# matrix_store_path - Directory for the E2E crypto store (Olm sessions, device keys).
# Default: ./matrix_store/
#
matrix_homeserver = https://matrix.example.org
matrix_user_id = @r0b3bot:example.org
matrix_password = <Matrix account password>
matrix_room_ids = !abc123:example.org,!def456:example.org
matrix_store_path = ./matrix_store/
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/python3
"""
Matrix bot support for r0b3BOT.
Runs alongside the Discord bot in the same asyncio event loop (via asyncio.gather).
Enabled by adding a [matrix-config] section to bot_config.conf.
Supported commands (same prefix as Discord):
greetings, cat, explain, holyshit, smudge, printstat, bambustat, info, help, last_error
Voice/sound commands respond with a friendly Discord-only message.
Discord embeds are rendered as formatted HTML (org.matrix.custom.html).
"""
import asyncio
import io
import json
import os
import random
import sys
import time
import urllib.request
import octoapi
import requests
from nio import AsyncClient, AsyncClientConfig, JoinError, LoginError, MatrixRoom, MegolmEvent, RoomMessage, RoomMessageText, RoomUpgradeEvent, SyncError, UploadResponse
class MatrixBot:
def __init__(
self,
homeserver,
user_id,
password,
command_prefix,
room_ids,
get_bambu_status,
get_last_error,
hass_url,
hass_token,
hass_light,
octoprint_ip,
bambu_ip,
bambu_access_code,
bambu_camera_enable,
capture_bambu_frame=None,
store_path="./matrix_store/",
):
# Create the crypto store directory if it doesn't exist.
# matrix-nio[e2e] uses this to persist Olm/Megolm sessions and device keys
# across restarts so the bot can decrypt messages in E2E-encrypted rooms.
os.makedirs(store_path, exist_ok=True)
nio_config = AsyncClientConfig(store_sync_tokens=True)
self.client = AsyncClient(homeserver, user_id, store_path=store_path, config=nio_config)
self.prefix = command_prefix
self.room_ids = set(room_ids)
self.password = password
# Credentials file — persists access_token + device_id across restarts so we
# reuse the same Matrix device (and its Olm keys) rather than registering a new
# one on every bot startup.
self.credentials_file = os.path.join(store_path, "r0b3bot_credentials.json")
# Shared state — lambdas that read from r0b3bot.py module-level globals
self._get_bambu_status = get_bambu_status
self._get_last_error = get_last_error
# Configuration
self.hass_url = hass_url
self.hass_token = hass_token
self.hass_light = hass_light
self.octoprint_ip = octoprint_ip
self.bambu_ip = bambu_ip
self.bambu_access_code = bambu_access_code
self.bambu_camera_enable = bambu_camera_enable
self._capture_bambu_frame = capture_bambu_frame
self._start_time = time.time()
async def _login(self):
"""Authenticate with the homeserver.
Tries to restore a previously saved session first (same device_id, same Olm
keys). Falls back to a fresh password login if no credentials are saved or if
the saved token has expired. On a fresh login the credentials are persisted to
disk so the same device is reused on subsequent restarts — this is essential for
E2E encryption: the homeserver and other clients must be able to establish a
stable Olm session with a consistent device.
"""
if os.path.exists(self.credentials_file):
try:
with open(self.credentials_file) as f:
creds = json.load(f)
self.client.access_token = creds["access_token"]
self.client.user_id = creds["user_id"]
self.client.device_id = creds["device_id"]
self.client.load_store()
print(f">> [Matrix] Restored session: {creds['user_id']} / device {creds['device_id']}")
return True
except Exception as e:
print(f" ! [Matrix] Could not restore session ({e}), doing fresh login")
# Remove the stale credentials so we don't retry a broken session
try:
os.remove(self.credentials_file)
except OSError:
pass
# Fresh password login — matrix-nio registers a new device and uploads Olm keys
resp = await self.client.login(self.password, device_name="r0b3bot")
if isinstance(resp, LoginError):
print(f" !! [Matrix] Login failed: {resp.message}")
print(f" !! [Matrix] Check matrix_password in bot_config.conf")
return False
print(f">> [Matrix] Logged in as {self.client.user_id} / device {self.client.device_id}")
self.client.load_store()
# Persist credentials for next restart
with open(self.credentials_file, "w") as f:
json.dump({
"access_token": self.client.access_token,
"user_id": self.client.user_id,
"device_id": self.client.device_id,
}, f, indent=2)
print(f">> [Matrix] Credentials saved to {self.credentials_file}")
return True
async def start(self):
print(">> [Matrix] Starting Matrix bot client")
if not await self._login():
print(" !! [Matrix] Matrix bot will NOT start.")
return
# Upload Olm one-time keys if this is the first run with a fresh Olm account.
# On subsequent restarts with a restored session this is a no-op.
if self.client.should_upload_keys:
print(">> [Matrix] Uploading Olm keys to homeserver")
await self.client.keys_upload()
# Initial sync with no timeout to consume backlog before registering callbacks,
# so we don't replay old messages on startup.
try:
resp = await self.client.sync(timeout=0)
if isinstance(resp, SyncError):
print(f" ! [Matrix] Initial sync error: {resp.message}")
else:
print(f">> [Matrix] Initial sync OK, next_batch={self.client.next_batch!r}")
except Exception as e:
print(f" ! [Matrix] Initial sync exception: {e}")
# Join all configured rooms. The bot account must be a member of a room
# to receive its events in the sync stream — having the room ID in config
# alone is not sufficient.
for room_id in self.room_ids:
resp = await self.client.join(room_id)
if isinstance(resp, JoinError):
print(f" ! [Matrix] Failed to join {room_id}: {resp.message}")
else:
print(f">> [Matrix] Joined (or already in) room {room_id}")
self.client.add_event_callback(self._on_message, RoomMessageText)
self.client.add_event_callback(self._on_any_message, RoomMessage)
self.client.add_event_callback(self._on_encrypted, MegolmEvent)
self.client.add_event_callback(self._on_room_upgrade, RoomUpgradeEvent)
print(">> [Matrix] Listening for commands")
# Use a short poll timeout (5 s) to stay well under any reverse-proxy
# idle timeout. A 30 s long-poll through nginx/caddy will often get a
# 504 which matrix-nio can't parse, causing the "next_batch required"
# loop and silencing all callbacks. 5 s is safe for all common setups.
await self.client.sync_forever(timeout=5000)
# -------------------------------------------------------------------------
# Event handler
# -------------------------------------------------------------------------
async def _on_any_message(self, room: MatrixRoom, event: RoomMessage):
"""DEBUG — fires for any m.room.message msgtype, not just m.text."""
print(f" [Matrix DEBUG RoomMessage] type={type(event).__name__} from {event.sender} in {room.room_id!r}")
async def _on_encrypted(self, room: MatrixRoom, event: MegolmEvent):
"""Fires for encrypted messages the bot can't yet decrypt (missing session key).
Normal on first run — the sender's client needs to establish an Olm session with
the bot's device and share the room key. Sending another message from Element
triggers this automatically. We also send a key request to speed it up."""
print(f" ! [Matrix] Could not decrypt message from {event.sender} in {room.room_id!r} "
f"(session {event.session_id!r} not yet received — send another message to trigger key sharing)")
try:
await self.client.request_room_key(event)
except Exception as e:
print(f" ! [Matrix] Key request failed: {e}")
async def _on_room_upgrade(self, room: MatrixRoom, event: RoomUpgradeEvent):
"""Handle a room upgrade (tombstone). Join the replacement room and swap
the room ID in the active set so commands keep working without a restart.
The old room ID is left in bot_config.conf — update it to the new one."""
new_room_id = event.replacement_room
print(f">> [Matrix] Room {room.room_id} upgraded → {new_room_id}, joining replacement")
resp = await self.client.join(new_room_id)
if isinstance(resp, JoinError):
print(f" ! [Matrix] Failed to join upgraded room {new_room_id}: {resp.message}")
return
self.room_ids.discard(room.room_id)
self.room_ids.add(new_room_id)
print(f">> [Matrix] Now listening in {new_room_id}")
print(f" ! [Matrix] Update matrix_room_ids in bot_config.conf: replace {room.room_id} with {new_room_id}")
async def _on_message(self, room: MatrixRoom, event: RoomMessageText):
# DEBUG — remove once commands are confirmed working
print(f" [Matrix DEBUG] event from {event.sender} in {room.room_id!r} body={event.body!r}")
print(f" [Matrix DEBUG] configured room_ids={self.room_ids}")
# Ignore own messages
if event.sender == self.client.user_id:
print(" [Matrix DEBUG] ignored: own message")
return
# Only respond in configured rooms
if room.room_id not in self.room_ids:
print(f" [Matrix DEBUG] ignored: room_id not in configured list")
return
# Only respond to messages that start with the command prefix
if not event.body.startswith(self.prefix):
print(f" [Matrix DEBUG] ignored: no command prefix {self.prefix!r}")
return
body = event.body[len(self.prefix):]
parts = body.split()
if not parts:
return
command = parts[0].lower()
print(f">> [Matrix] {event.sender} in {room.room_id} called {self.prefix}{command}")
await self._dispatch(room.room_id, command)
# -------------------------------------------------------------------------
# Command dispatcher
# -------------------------------------------------------------------------
_SOUND_COMMANDS = {
"aww", "bitch", "big", "boom", "breee", "cowbell", "eia", "heavy",
"iran", "leeroy", "nice", "oops", "promoted", "rs", "trololo",
"wow", "yaw",
}
async def _dispatch(self, room_id, command):
handlers = {
"greetings": self._cmd_greetings,
"cat": self._cmd_cat,
"explain": self._cmd_explain,
"holyshit": self._cmd_holyshit,
"smudge": self._cmd_smudge,
"printstat": self._cmd_printstat,
"bambustat": self._cmd_bambustat,
"info": self._cmd_info,
"help": self._cmd_help,
"last_error": self._cmd_last_error,
}
if command in handlers:
await handlers[command](room_id)
elif command in self._SOUND_COMMANDS:
await self._send_text(
room_id,
"Sound commands are only available on Discord (voice channels)."
)
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
async def _share_session(self, room_id):
"""Share the Megolm outbound group session with all current room members.
Must be called before every room_send() in encrypted rooms. This establishes
Olm sessions with each member's devices (claiming one-time keys as needed) and
sends them the Megolm session key so they can decrypt our messages.
ignore_unverified_devices=True lets the bot still send even when some devices
have unverifiable keys (e.g. stale bot devices with mismatched Olm keys).
"""
try:
await self.client.share_group_session(room_id, ignore_unverified_devices=True)
except Exception as e:
print(f" ! [Matrix] share_group_session for {room_id}: {e}")
async def _send_text(self, room_id, text, html=None):
await self._share_session(room_id)
content = {"msgtype": "m.text", "body": text}
if html:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html
await self.client.room_send(room_id, "m.room.message", content)
async def _send_image(self, room_id, file_path):
await self._share_session(room_id)
with open(file_path, "rb") as f:
data = f.read()
# Encrypted rooms require the attachment itself to be encrypted before upload.
# matrix-nio handles this via encrypt=True on upload(), which returns the key
# info needed to build the m.image "file" field so clients can decrypt it.
room = self.client.rooms.get(room_id)
encrypt = room is not None and room.encrypted
resp, keys = await self.client.upload(
io.BytesIO(data),
content_type="image/jpeg",
filename=os.path.basename(file_path),
encrypt=encrypt,
)
if not isinstance(resp, UploadResponse):
print(f" ! [Matrix] Image upload failed: {resp}")
return
if encrypt and keys:
content = {
"msgtype": "m.image",
"body": os.path.basename(file_path),
"file": {"url": resp.content_uri, **keys},
}
else:
content = {
"msgtype": "m.image",
"url": resp.content_uri,
"body": os.path.basename(file_path),
"info": {"mimetype": "image/jpeg"},
}
await self.client.room_send(room_id, "m.room.message", content)
def _format_html(self, title, description, fields):
"""Render a status block as a heading + two-column table."""
rows = "".join(
f"<tr><td><strong>{name}</strong></td><td>{value}</td></tr>"
for name, value in fields
)
desc_html = f"<p>{description}</p>" if description else ""
return f"<h4>{title}</h4>{desc_html}<table>{rows}</table>"
def _format_text(self, title, description, fields):
"""Plain-text fallback — pad label column for alignment."""
max_len = max((len(name) for name, _ in fields), default=0)
parts = [title]
if description:
parts.append(description)
for name, value in fields:
parts.append(f" {name:<{max_len}} {value}")
return "\n".join(parts)
# -------------------------------------------------------------------------
# Command implementations
# -------------------------------------------------------------------------
async def _cmd_greetings(self, room_id):
await self._send_text(room_id, "Hello there!")
async def _cmd_cat(self, room_id):
await self._send_text(room_id, "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif")
async def _cmd_explain(self, room_id):
await self._send_text(room_id, "https://media.giphy.com/media/A8NNZlVuA1LoY/giphy.gif")
async def _cmd_holyshit(self, room_id):
await self._send_text(room_id, "https://media.giphy.com/media/QcGTswMKyBNgA/giphy.gif")
async def _cmd_smudge(self, room_id):
print(" - [Matrix] Uploading Smudge to Matrix room")
await self._send_image(room_id, "./images/smudge.png")
async def _cmd_last_error(self, room_id):
await self._send_text(room_id, f"Last error recorded: {self._get_last_error()}")
async def _cmd_info(self, room_id):
# Uptime
elapsed = time.time() - self._start_time
m, s = divmod(int(elapsed), 60)
h, m = divmod(m, 60)
d, h = divmod(h, 24)
w, d = divmod(d, 7)
uptime = f"{w:02d}:{d:02d}:{h:02d}:{m:02d}:{s:02d}"
# Git version tag
try:
proc = await asyncio.create_subprocess_exec(
"git", "describe", "--tags",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
bot_version = stdout.decode().strip() or "unknown"
except Exception:
bot_version = "unknown"
source_url = "https://gitlab.rickelobe.com/Bots/r0b3BOT"
fields = [
("Author", "R0b3"),
("Uptime [WW:DD:HH:MM:SS]", uptime),
("Version", bot_version),
("Python", sys.version.split()[0]),
("Source", f'<a href="{source_url}">{source_url}</a>'),
]
await self._send_text(
room_id,
self._format_text("r0b3BOT — Derpy Derp of a Bot", None, fields),
html=self._format_html("r0b3BOT — Derpy Derp of a Bot", None, fields),
)
async def _cmd_help(self, room_id):
p = self.prefix
commands = [
(f"{p}greetings", "Hello!"),
(f"{p}cat", "GIF"),
(f"{p}explain", "GIF"),
(f"{p}holyshit", "GIF"),
(f"{p}smudge", "Sends the Smudge image"),
(f"{p}printstat", "OctoPrint status + snapshot"),
(f"{p}bambustat", "Bambu Lab printer status + camera"),
(f"{p}info", "Bot info"),
(f"{p}last_error", "Last recorded error"),
]
rows = "".join(
f"<tr><td><code>{cmd}</code></td><td>{desc}</td></tr>"
for cmd, desc in commands
)
html = (
f"<h4>r0b3BOT Commands</h4>"
f"<table>{rows}</table>"
f"<p><em>Sound commands are Discord-only.</em></p>"
)
text_lines = [f" {cmd:<18} {desc}" for cmd, desc in commands]
text = "r0b3BOT Commands\n" + "\n".join(text_lines) + "\n (Sound commands are Discord-only)"
await self._send_text(room_id, text, html=html)
async def _cmd_printstat(self, room_id):
loop = asyncio.get_running_loop()
hass_headers = {
"Authorization": f"Bearer {self.hass_token}",
"content-type": "application/json",
}
hass_state_url = self.hass_url + "/api/states/" + self.hass_light
# Light control — same logic as the Discord version
turned_on_light = False
try:
resp = await loop.run_in_executor(
None, lambda: requests.get(hass_state_url, headers=hass_headers)
)
lights = resp.json()
if lights.get("state") == "off":
turned_on_light = True
await self._send_text(room_id, "Turning on a light, give me a moment...")
hass_on_url = self.hass_url + "/api/services/switch/turn_on"
payload = f'{{"entity_id": "{self.hass_light}"}}'
await loop.run_in_executor(
None, lambda: requests.post(hass_on_url, headers=hass_headers, data=payload)
)
await asyncio.sleep(3)
except Exception as e:
print(f" ! [Matrix] Hass light check failed: {e}")
await self._send_text(room_id, "Hmm, I can't tell if the light is on... oh well [Hass API error]")
# Capture webcam snapshot
file_name = str(random.randrange(1, 10000)) + ".jpg"
try:
print(" - [Matrix] Getting image from Webcam")
await loop.run_in_executor(
None,
lambda: urllib.request.urlretrieve(
f"http://{self.octoprint_ip}:8080/?action=snapshot", file_name
),
)
await self._send_image(room_id, file_name)
os.remove(file_name)
except Exception as e:
print(f" ! [Matrix] Webcam capture failed: {e}")
await self._send_text(room_id, f"Unable to capture snapshot: {e}")
# Turn the light back off if we turned it on
if turned_on_light:
try:
hass_off_url = self.hass_url + "/api/services/switch/turn_off"
payload = f'{{"entity_id": "{self.hass_light}"}}'
await loop.run_in_executor(
None, lambda: requests.post(hass_off_url, headers=hass_headers, data=payload)
)
except Exception as e:
print(f" ! [Matrix] Unable to turn off light: {e}")
# Fetch printer stats from OctoPrint
try:
operational = await loop.run_in_executor(None, octoapi.get_printer_dict)
except Exception:
operational = False
if operational:
try:
print_filename = await loop.run_in_executor(None, octoapi.get_printFileName)
except Exception:
print_filename = ""
try:
print_completion = round(
await loop.run_in_executor(None, octoapi.get_completion), 2
)
except Exception:
print_completion = "Unknown"
try:
print_seconds = await loop.run_in_executor(None, octoapi.get_printTime)
print_secondsleft = await loop.run_in_executor(None, octoapi.get_printTimeLeft)
print_hours = int(print_seconds / 3600)
print_min = int((print_seconds / 60) - (print_hours * 60))
print_hoursleft = int(print_secondsleft / 3600)
print_minleft = int((print_secondsleft / 60) - (print_hoursleft * 60))
time_elapsed = f"{print_hours}h {print_min}m"
time_remaining = f"{print_hoursleft}h {print_minleft}m"
except Exception:
time_elapsed = time_remaining = "Unknown"
printer_bed = f"{operational['temperature']['bed']['actual']}°C"
printer_hotend = f"{operational['temperature']['tool0']['actual']}°C"
fields = [
("Progress", f"{print_completion}%"),
("Elapsed", time_elapsed),
("Remaining", time_remaining),
("Bed Temp", printer_bed),
("Hotend", printer_hotend),
]
await self._send_text(
room_id,
self._format_text("R0b3's 3D Printer Status", f"Current Job: {print_filename}", fields),
html=self._format_html("R0b3's 3D Printer Status", f"Current Job: {print_filename}", fields),
)
else:
await self._send_text(room_id, "Printer is Offline")
async def _cmd_bambustat(self, room_id):
status = self._get_bambu_status()
if status is None:
await self._send_text(
room_id,
"Bambu printer status is not available yet. The printer may be offline or still connecting.",
)
return
gcode_state = status.gcode_state or "Unknown"
gcode_file = status.gcode_file or "Unknown"
mc_percent = status.mc_percent if status.mc_percent is not None else "Unknown"
remaining_mins = status.mc_remaining_time or 0
time_remaining = f"{remaining_mins // 60}h {remaining_mins % 60:02d}m"
nozzle_temp = (
f"{status.nozzle_temper:.1f}°C" if status.nozzle_temper is not None else "Unknown"
)
bed_temp = (
f"{status.bed_temper:.1f}°C" if status.bed_temper is not None else "Unknown"
)
chamber_temp = (
f"{status.chamber_temper:.1f}°C" if status.chamber_temper is not None else "Unknown"
)
layer_info = "Unknown"
if status.layer_num is not None and status.total_layer_num is not None:
layer_info = f"{status.layer_num} / {status.total_layer_num}"
fields = [
("State", gcode_state),
("Progress", f"{mc_percent}%"),
("Remaining", time_remaining),
("Layer", layer_info),
("Nozzle", nozzle_temp),
("Bed", bed_temp),
("Chamber", chamber_temp),
]
await self._send_text(
room_id,
self._format_text("Bambu Lab Printer Status", f"File: {gcode_file}", fields),
html=self._format_html("Bambu Lab Printer Status", f"File: {gcode_file}", fields),
)
# Optional camera snapshot via ffmpeg RTSPS
if self.bambu_camera_enable and self._capture_bambu_frame:
image_filename = f"bambu_{random.randrange(1, 10000)}.jpg"
try:
print(" - [Matrix] Capturing Bambu camera frame via RTSPS")
success = await self._capture_bambu_frame(image_filename)
if success:
await self._send_image(room_id, image_filename)
os.remove(image_filename)
else:
print(" ! [Matrix] ffmpeg exited with non-zero code, skipping image")
except asyncio.TimeoutError:
print(" ! [Matrix] ffmpeg timed out capturing Bambu camera frame")
except Exception as e:
print(f" ! [Matrix] Unable to capture Bambu camera frame: {e}")
+116 -116
View File
@@ -74,34 +74,25 @@ BAMBU_ACCESS_CODE = config.get('bot-config', 'bambu_access_code', fallback=None)
BAMBU_SERIAL_NUMBER = config.get('bot-config', 'bambu_serial_number', fallback=None)
BAMBU_CAMERA_ENABLE = config.get('bot-config', 'bambu_camera_enable', fallback='false').lower() == 'true'
# Matrix bot config (optional — all four keys required to enable)
MATRIX_HOMESERVER = config.get('matrix-config', 'matrix_homeserver', fallback=None)
MATRIX_USER_ID = config.get('matrix-config', 'matrix_user_id', fallback=None)
MATRIX_PASSWORD = config.get('matrix-config', 'matrix_password', fallback=None)
_matrix_room_ids_raw = config.get('matrix-config', 'matrix_room_ids', fallback=None)
MATRIX_ROOM_IDS = [r.strip() for r in _matrix_room_ids_raw.split(',')] if _matrix_room_ids_raw else []
MATRIX_STORE_PATH = config.get('matrix-config', 'matrix_store_path', fallback='./matrix_store/')
# Module-level cache for Bambu printer status, updated by the MQTT watch callback
bambu_status = None
bambu_client = None
_bot_loop = None # set in on_ready; used by the MQTT thread
_last_bambu_activity_update = 0.0
_bambu_was_printing = False
_status_slot = 0 # 0 = OctoPi, 1 = Bambu; toggled when both are printing
def _on_bambu_update(status):
"""Callback invoked by bambu-connect's MQTT thread with the latest PrinterStatus."""
global bambu_status, _last_bambu_activity_update, _bambu_was_printing
global bambu_status
bambu_status = status
if _bot_loop is None:
return
now = time.time()
if status.gcode_state == "RUNNING":
_bambu_was_printing = True
# Throttle Discord presence updates to once every 30 seconds
if (now - _last_bambu_activity_update) >= 30:
_last_bambu_activity_update = now
asyncio.run_coroutine_threadsafe(_update_bambu_activity(status), _bot_loop)
elif _bambu_was_printing:
# State just left RUNNING (paused, finished, failed) — reset presence
_bambu_was_printing = False
asyncio.run_coroutine_threadsafe(_reset_bot_activity(), _bot_loop)
# Configure bot
bot = commands.Bot(intents=discord.Intents.all(),command_prefix=BOT_COMMAND_PREFIX, description='A derpy derp of a bot.')
@@ -142,7 +133,13 @@ async def _capture_bambu_frame(filename):
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except asyncio.TimeoutError:
proc.kill()
_, stderr = await proc.communicate()
print(f" ! ffmpeg stderr on timeout: {stderr.decode(errors='replace').strip()}")
raise
if proc.returncode != 0:
print(f" ! ffmpeg stderr: {stderr.decode(errors='replace').strip()}")
return proc.returncode == 0
@@ -156,24 +153,58 @@ async def _reset_bot_activity():
await bot.change_presence(activity=activity)
# Background task: poll OctoPrint every 2 minutes and update bot status if printing
@tasks.loop(seconds=120)
async def check_printer_status():
async def _set_octo_activity(completion):
"""Update Discord presence with the current OctoPrint job status."""
loop = asyncio.get_running_loop()
try:
print(">> Checking 3D Printer Status")
# Run the synchronous octoapi call in a thread so it doesn't block
# the event loop and starve Discord's heartbeat on slow/unreachable hosts
loop = asyncio.get_running_loop()
print_completion = round(await loop.run_in_executor(None, octoapi.get_completion), 2)
secs_left = await loop.run_in_executor(None, octoapi.get_printTimeLeft)
hours_left = int(secs_left / 3600)
mins_left = int((secs_left % 3600) / 60)
time_left = f"{hours_left:02d}:{mins_left:02d}"
except Exception:
print_completion = "Unknown"
print(">>>> Nothing Printing.")
if print_completion != "Unknown" and print_completion < 100:
print(">>>> Something is printing, calling UpdateStatus")
await updateStatus()
time_left = "??"
activity = discord.Activity(
name=f"OctoPi @ {completion}%, {time_left} to go",
type=discord.ActivityType.watching,
)
await bot.change_presence(activity=activity)
# Unified background task: polls both printers every 30s and updates presence,
# alternating between them fairly when both are printing simultaneously.
@tasks.loop(seconds=7)
async def update_presence():
global _status_slot
loop = asyncio.get_running_loop()
# Check OctoPrint
try:
completion = round(await loop.run_in_executor(None, octoapi.get_completion), 1)
octo_printing = completion < 100
except Exception:
completion = None
octo_printing = False
# Check Bambu (from MQTT cache updated by _on_bambu_update)
bambu_printing = bambu_status is not None and bambu_status.gcode_state == "RUNNING"
print(f">> Presence update — OctoPi printing: {octo_printing}, Bambu printing: {bambu_printing}")
if octo_printing and bambu_printing:
# Both printing: alternate each tick for equal representation
_status_slot = 1 - _status_slot
if _status_slot == 0:
await _set_octo_activity(completion)
else:
await _update_bambu_activity(bambu_status)
elif octo_printing:
_status_slot = 0
await _set_octo_activity(completion)
elif bambu_printing:
_status_slot = 1
await _update_bambu_activity(bambu_status)
else:
# Will hit this if the last print job completed and is sitting at 100%
print(">>>> Nothing Printing.")
await _reset_bot_activity()
# On Ready
@@ -200,13 +231,9 @@ async def on_ready():
except Exception as e:
print(f" ! Could not clear voice state for {guild.name}: {e}")
# Start the printer polling task (guard against double-start on reconnects)
if not check_printer_status.is_running():
check_printer_status.start()
# Store the running event loop so the Bambu MQTT thread can schedule coroutines
global _bot_loop
_bot_loop = asyncio.get_running_loop()
# Start the unified presence task (guard against double-start on reconnects)
if not update_presence.is_running():
update_presence.start()
# Start the Bambu watch client if config is present
global bambu_client
@@ -444,12 +471,12 @@ async def play_sound(ctx, member : discord.Member, soundFile, command):
# OctoPrint Commands
#
@bot.command()
async def printstat(ctx):
async def octostat(ctx):
datestring = datetime.now()
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
print(f"[{datestring}]: {ctx.message.author.display_name} called $printstat")
print(f"[{datestring}]: {ctx.message.author.display_name} called $octostat")
# We haven't yet changed the state of the light, setting this to False
turned_on_light = False
@@ -630,75 +657,6 @@ async def printstat(ctx):
print(" - Notifying chat channel")
await ctx.send(embed=embed)
async def updateStatus():
# Initialize variables
# Unknowns is present to handle issues like print paused or cancelled
print_completion = 0
unknowns = 0
loop = asyncio.get_running_loop()
#While the print status is less than 100 % complete, keep updating
while print_completion < 100:
try:
# Try to get the % of Completion
print_completion = round(await loop.run_in_executor(None, octoapi.get_completion), 1)
except Exception:
print_completion = 999
try:
print_secondsleft = await loop.run_in_executor(None, octoapi.get_printTimeLeft)
except Exception:
print(" ! Unable to get Seconds Left from OctoPrint")
print_secondsleft = 0
try:
# Try to convert seconds left to hours left
print_hoursleft = int(((print_secondsleft / 60) / 60))
except (TypeError, ValueError):
print_hoursleft = "Unknown"
try:
# Same as above but for the minutes left in print job
print_minleft = int(((print_secondsleft / 60) - (print_hoursleft * 60)))
except (TypeError, ValueError):
print_minleft = "Unknown"
if print_minleft == "Unknown" or print_hoursleft == "Unknown":
time_left = "Unknown"
else:
time_left = f"{print_hoursleft:02d}:{print_minleft:02d}"
#Set the activity to the new percent complete value
if print_completion != 999:
unknowns = 0
print(f" - Print job is at {print_completion}%, with {time_left} to go")
activity = discord.Activity(name=f"3D Print @ {str(print_completion)}%, {time_left} to go",type=discord.ActivityType.watching)
else:
unknowns += 1
print(f" - Error getting print status ({unknowns}/5 Errors)")
activity = discord.Activity(name=f"3D Print @ ERROR",type=discord.ActivityType.watching)
await bot.change_presence(activity=activity)
if (unknowns >= 5):
print(" - Reached 5 consecutive errors, breaking from loop")
break
await asyncio.sleep(30)
# Flash the status @ 100% before changing back to listening to your commands, only if uknowns !5.
if unknowns != 5:
for x in range(20):
activity = discord.Activity(name="-",type=discord.ActivityType.watching)
await bot.change_presence(activity=activity)
await asyncio.sleep(1)
activity = discord.Activity(name=f"3D Print @ {str(print_completion)}%",type=discord.ActivityType.watching)
await bot.change_presence(activity=activity)
await asyncio.sleep(1)
print(" - Done watching 3D Printer status")
await asyncio.sleep(3)
activity = discord.Activity(name=f"your {BOT_COMMAND_PREFIX}commands",type=discord.ActivityType.listening)
await bot.change_presence(activity=activity)
#
# Bambu Lab Printer Commands
@@ -772,6 +730,14 @@ async def bambustat(ctx):
await ctx.send(embed=embed)
@bot.command()
async def printstat(ctx):
datestring = datetime.now().strftime("%m/%d/%Y-%H:%M:%S")
print(f"[{datestring}]: {ctx.message.author.display_name} called $printstat")
await octostat.callback(ctx)
await bambustat.callback(ctx)
@bot.command()
async def info(ctx):
embed = discord.Embed(title="R0b3BOT", description="Derpy Derp of a Bot :P", color=0xeee657)
@@ -852,14 +818,18 @@ async def help(ctx):
embed = discord.Embed(title="A note about sound clips:", description="Sound clips are only played in voice channels. If a user is not specified when calling the command, the sound will played in the channel of that issuing user is currently joined to. When a username is specified, the bot will play the sound in the channel that user is currently in.")
await ctx.send(embed=embed)
embed = discord.Embed(title="3D Printer Status", color=0xeee657)
embed.add_field(name=f"{BOT_COMMAND_PREFIX}printstat", value="Uploads a snapshot of Rich's OctoPrint 3D printer and current stats", inline=False)
embed = discord.Embed(title="OctoPi Printer Status", color=0xeee657)
embed.add_field(name=f"{BOT_COMMAND_PREFIX}octostat", value="Uploads a snapshot of Rich's OctoPrint 3D printer and current stats", inline=False)
await ctx.send(embed=embed)
embed = discord.Embed(title="Bambu Lab Printer Status", color=0x00B4D8)
embed.add_field(name=f"{BOT_COMMAND_PREFIX}bambustat", value="Displays the current status of the Bambu Lab printer (temps, progress, time remaining)", inline=False)
await ctx.send(embed=embed)
embed = discord.Embed(title="All Printers Status", color=0x9B59B6)
embed.add_field(name=f"{BOT_COMMAND_PREFIX}printstat", value="Shows status of both the OctoPi and Bambu Lab printers in one command", inline=False)
await ctx.send(embed=embed)
# Function to run shell command and return stdout or sterr
async def run(cmd):
proc = await asyncio.create_subprocess_shell(
@@ -877,4 +847,34 @@ async def run(cmd):
#print(f'[stderr]\n{stderr.decode()}')
return stderr.decode()
bot.run(DISCORD_AUTH_TOKEN)
async def main():
tasks = [bot.start(DISCORD_AUTH_TOKEN)]
if all([MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_PASSWORD, MATRIX_ROOM_IDS]):
from matrix_bot import MatrixBot
matrix_bot = MatrixBot(
homeserver=MATRIX_HOMESERVER,
user_id=MATRIX_USER_ID,
password=MATRIX_PASSWORD,
command_prefix=BOT_COMMAND_PREFIX,
room_ids=MATRIX_ROOM_IDS,
get_bambu_status=lambda: bambu_status,
get_last_error=lambda: last_error_msg,
hass_url=HASS_URL,
hass_token=HASS_TOKEN,
hass_light=HASS_LIGHT,
octoprint_ip=OCTOPRINT_IP_ADDRESS,
bambu_ip=BAMBU_IP_ADDRESS,
bambu_access_code=BAMBU_ACCESS_CODE,
bambu_camera_enable=BAMBU_CAMERA_ENABLE,
capture_bambu_frame=_capture_bambu_frame,
store_path=MATRIX_STORE_PATH,
)
print(">> Matrix bot configured, adding to event loop")
tasks.append(matrix_bot.start())
else:
print(">> Matrix config not found, skipping Matrix bot")
await asyncio.gather(*tasks)
asyncio.run(main())
+5
View File
@@ -11,3 +11,8 @@ requests>=2.32.3
# bambu-connect 0.3.1 uses mqtt.CallbackAPIVersion.VERSION1, which requires paho-mqtt 2.x
bambu-connect>=0.3.1
paho-mqtt>=2.0
# Matrix bot support (optional — only needed if matrix-config section is present in bot_config.conf)
# [e2e] extra adds Olm/Megolm encryption support for encrypted rooms.
# Requires the libolm system library: apt-get install libolm3 (Debian/Ubuntu)
matrix-nio[e2e]>=0.21