Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe26d9e2ed | ||
|
|
831ee9cbae | ||
|
|
545958b474 | ||
|
|
367ef5b7af | ||
|
|
2a156b3a3c | ||
|
|
8ac70c645b | ||
|
|
4bdf61a018 | ||
|
|
cd6955a567 | ||
|
|
eb922b4271 | ||
|
|
f51025d5f7 | ||
|
|
1f06251ca4 | ||
|
|
70ea6508e2 | ||
|
|
e79abf7715 | ||
|
|
945e2464f7 | ||
|
|
93b6995554 | ||
|
|
b45f617c73 | ||
|
|
d872725af0 | ||
|
|
c18c89ac41 | ||
|
|
787b0a2fb1 | ||
|
|
31270db12b | ||
|
|
a2e1a42fe6 | ||
|
|
1572eabac5 | ||
|
|
baf4905beb | ||
|
|
123aa22abf | ||
|
|
c2490d2159 | ||
|
|
36b8eccb6c | ||
|
|
e104538b5f | ||
|
|
516ba09548 | ||
|
|
6f984b3d4e |
No files matched your search
@@ -1 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
__pycache__
|
||||||
|
bot_config.conf
|
||||||
|
.claude
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
stages:
|
|
||||||
- deploy
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
stage: deploy
|
|
||||||
script: /var/lib/gitlab-runner/deploy_r0b3bot.sh
|
|
||||||
only:
|
|
||||||
- dev
|
|
||||||
- master
|
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
*** This project lives @ https://gitlab.rickelobe.com/Bots/R0b3BOT ***
|
*** This project lives @ https://git.rickelobe.com/rich/R0b3BOT ***
|
||||||
|
|
||||||
[](http://gitlab.rickelobe.com/Bots/r0b3BOT/commits/master)
|
|
||||||
|
|
||||||
# Description
|
# 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
|
# 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>
|
||||||

|

|
||||||
* Query & Monitor Bambu Lab Printer Status using the `$bambustat` command (Requires a Bambu Lab printer accessible on the local network via MQTT)<br>
|
* 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
|
* 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)
|
* 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
|
* 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
|
||||||
* Play a selection of sound clips to voice channel members using the various commands (`$torololo`, `$oops`, `$promoted`, etc)
|
* 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
|
# Commands
|
||||||

|

|
||||||
|
|
||||||
|
All text commands are available on both Discord and Matrix. Sound/voice commands are Discord-only.
|
||||||
|
|
||||||
# Requirements
|
# Requirements
|
||||||
|
|
||||||
Install all Python dependencies using the provided `requirements.txt`:
|
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
|
- `requests` >= 2.32.3 — HTTP client for HomeAssistant REST API calls
|
||||||
- `bambu-connect` >= 0.3.1 — Bambu Lab printer integration via local MQTT
|
- `bambu-connect` >= 0.3.1 — Bambu Lab printer integration via local MQTT
|
||||||
- `paho-mqtt` >= 2.0 — MQTT client library (required by bambu-connect)
|
- `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**
|
**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.
|
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
|
# Configuration
|
||||||
1. Create a Discord Bot via the [Discord Developer Portal](https://discord.com/developers/applications)
|
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)
|
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.conf Keys
|
||||||
|
|
||||||
|
### [bot-config]
|
||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `discord_auth_token` | Bot token from the Discord Developer Console |
|
| `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_enable` | Set to `true` to enable MMR checker |
|
||||||
| `mmr_checker_interval` | Seconds between MMR checks |
|
| `mmr_checker_interval` | Seconds between MMR checks |
|
||||||
| `mmr_api_url` | MMR API URL |
|
| `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/` |
|
||||||
@@ -32,3 +32,25 @@ statping_api_key = <Statping API Key>
|
|||||||
mmr_checker_enable = <true/false>
|
mmr_checker_enable = <true/false>
|
||||||
mmr_checker_interval = <number of seconds between checks>
|
mmr_checker_interval = <number of seconds between checks>
|
||||||
mmr_api_url = <MMR API URL>
|
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
@@ -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
@@ -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_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'
|
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
|
# Module-level cache for Bambu printer status, updated by the MQTT watch callback
|
||||||
bambu_status = None
|
bambu_status = None
|
||||||
bambu_client = None
|
bambu_client = None
|
||||||
_bot_loop = None # set in on_ready; used by the MQTT thread
|
_status_slot = 0 # 0 = OctoPi, 1 = Bambu; toggled when both are printing
|
||||||
_last_bambu_activity_update = 0.0
|
|
||||||
_bambu_was_printing = False
|
|
||||||
|
|
||||||
|
|
||||||
def _on_bambu_update(status):
|
def _on_bambu_update(status):
|
||||||
"""Callback invoked by bambu-connect's MQTT thread with the latest PrinterStatus."""
|
"""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
|
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
|
# Configure bot
|
||||||
bot = commands.Bot(intents=discord.Intents.all(),command_prefix=BOT_COMMAND_PREFIX, description='A derpy derp of a 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,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
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:
|
if proc.returncode != 0:
|
||||||
print(f" ! ffmpeg stderr: {stderr.decode(errors='replace').strip()}")
|
print(f" ! ffmpeg stderr: {stderr.decode(errors='replace').strip()}")
|
||||||
return proc.returncode == 0
|
return proc.returncode == 0
|
||||||
@@ -156,24 +153,58 @@ async def _reset_bot_activity():
|
|||||||
await bot.change_presence(activity=activity)
|
await bot.change_presence(activity=activity)
|
||||||
|
|
||||||
|
|
||||||
# Background task: poll OctoPrint every 2 minutes and update bot status if printing
|
async def _set_octo_activity(completion):
|
||||||
@tasks.loop(seconds=120)
|
"""Update Discord presence with the current OctoPrint job status."""
|
||||||
async def check_printer_status():
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
print(">> Checking 3D Printer Status")
|
secs_left = await loop.run_in_executor(None, octoapi.get_printTimeLeft)
|
||||||
# Run the synchronous octoapi call in a thread so it doesn't block
|
hours_left = int(secs_left / 3600)
|
||||||
# the event loop and starve Discord's heartbeat on slow/unreachable hosts
|
mins_left = int((secs_left % 3600) / 60)
|
||||||
loop = asyncio.get_running_loop()
|
time_left = f"{hours_left:02d}:{mins_left:02d}"
|
||||||
print_completion = round(await loop.run_in_executor(None, octoapi.get_completion), 2)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
print_completion = "Unknown"
|
time_left = "??"
|
||||||
print(">>>> Nothing Printing.")
|
activity = discord.Activity(
|
||||||
if print_completion != "Unknown" and print_completion < 100:
|
name=f"OctoPi @ {completion}%, {time_left} to go",
|
||||||
print(">>>> Something is printing, calling UpdateStatus")
|
type=discord.ActivityType.watching,
|
||||||
await updateStatus()
|
)
|
||||||
|
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:
|
else:
|
||||||
# Will hit this if the last print job completed and is sitting at 100%
|
await _reset_bot_activity()
|
||||||
print(">>>> Nothing Printing.")
|
|
||||||
|
|
||||||
|
|
||||||
# On Ready
|
# On Ready
|
||||||
@@ -200,13 +231,9 @@ async def on_ready():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ! Could not clear voice state for {guild.name}: {e}")
|
print(f" ! Could not clear voice state for {guild.name}: {e}")
|
||||||
|
|
||||||
# Start the printer polling task (guard against double-start on reconnects)
|
# Start the unified presence task (guard against double-start on reconnects)
|
||||||
if not check_printer_status.is_running():
|
if not update_presence.is_running():
|
||||||
check_printer_status.start()
|
update_presence.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 Bambu watch client if config is present
|
# Start the Bambu watch client if config is present
|
||||||
global bambu_client
|
global bambu_client
|
||||||
@@ -444,12 +471,12 @@ async def play_sound(ctx, member : discord.Member, soundFile, command):
|
|||||||
# OctoPrint Commands
|
# OctoPrint Commands
|
||||||
#
|
#
|
||||||
@bot.command()
|
@bot.command()
|
||||||
async def printstat(ctx):
|
async def octostat(ctx):
|
||||||
|
|
||||||
datestring = datetime.now()
|
datestring = datetime.now()
|
||||||
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
|
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
|
# We haven't yet changed the state of the light, setting this to False
|
||||||
turned_on_light = False
|
turned_on_light = False
|
||||||
@@ -630,75 +657,6 @@ async def printstat(ctx):
|
|||||||
print(" - Notifying chat channel")
|
print(" - Notifying chat channel")
|
||||||
await ctx.send(embed=embed)
|
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
|
# Bambu Lab Printer Commands
|
||||||
@@ -772,6 +730,14 @@ async def bambustat(ctx):
|
|||||||
await ctx.send(embed=embed)
|
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()
|
@bot.command()
|
||||||
async def info(ctx):
|
async def info(ctx):
|
||||||
embed = discord.Embed(title="R0b3BOT", description="Derpy Derp of a Bot :P", color=0xeee657)
|
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.")
|
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)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
embed = discord.Embed(title="3D Printer Status", color=0xeee657)
|
embed = discord.Embed(title="OctoPi 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.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)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
embed = discord.Embed(title="Bambu Lab Printer Status", color=0x00B4D8)
|
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)
|
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)
|
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
|
# Function to run shell command and return stdout or sterr
|
||||||
async def run(cmd):
|
async def run(cmd):
|
||||||
proc = await asyncio.create_subprocess_shell(
|
proc = await asyncio.create_subprocess_shell(
|
||||||
@@ -877,4 +847,34 @@ async def run(cmd):
|
|||||||
#print(f'[stderr]\n{stderr.decode()}')
|
#print(f'[stderr]\n{stderr.decode()}')
|
||||||
return 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())
|
||||||
@@ -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 uses mqtt.CallbackAPIVersion.VERSION1, which requires paho-mqtt 2.x
|
||||||
bambu-connect>=0.3.1
|
bambu-connect>=0.3.1
|
||||||
paho-mqtt>=2.0
|
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
|
||||||
Reference in new issue
Block a user