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 | ||
|
|
bc4854e6f4 | ||
|
|
6362f2558e | ||
|
|
a750628097 | ||
|
|
ce31a2e5df | ||
|
|
570e9d14a6 | ||
|
|
23fe33e3ad | ||
|
|
54c16d81dc | ||
|
|
04d7d5c37f | ||
|
|
fbc3ceb3c4 | ||
|
|
e1703ae326 | ||
|
|
65a1fe413d | ||
|
|
451938772a | ||
|
|
591d44904e | ||
|
|
84dda24e9d | ||
|
|
ebb9865109 | ||
|
|
e6cf3c6984 | ||
|
|
01e102af17 | ||
|
|
790c224dcc | ||
|
|
14104b4f39 | ||
|
|
1184102860 | ||
|
|
4e3d735b1d | ||
|
|
b074306696 | ||
|
|
0d5fd6c0cf | ||
|
|
8647451b24 | ||
|
|
c89bfaeff1 | ||
|
|
74f6e0a32c | ||
|
|
be9173a7ff | ||
|
|
1666e8fbf9 | ||
|
|
b950886c2d | ||
|
|
9a627c8a83 | ||
|
|
9f11775db8 | ||
|
|
d994b9f8fe | ||
|
|
a7e5b3bea2 | ||
|
|
ed2e00ac3c | ||
|
|
0fd568fd87 | ||
|
|
2801e1559f | ||
|
|
3007aa56f5 | ||
|
|
669333e2c7 | ||
|
|
bf88d3d7c1 | ||
|
|
3751a29dfb | ||
|
|
2aae88ed22 | ||
|
|
dc2ea9a7e3 | ||
|
|
7285c4da85 | ||
|
|
e0c34659a2 | ||
|
|
32bb958ac0 | ||
|
|
a80f62f8d4 | ||
|
|
a9efb888bd | ||
|
|
59fdac5803 | ||
|
|
64173df84d | ||
|
|
5859bbdac8 | ||
|
|
5b6555b8da | ||
|
|
0e287c369d | ||
|
|
98f7ced3bf | ||
|
|
dce4303ca1 | ||
|
|
1153d1919c | ||
|
|
499d86459b | ||
|
|
c053b58733 | ||
|
|
e018cfa05b | ||
|
|
9f58177559 | ||
|
|
58e1781397 | ||
|
|
f08eb06db4 | ||
|
|
807bf17f61 | ||
|
|
3e354d3655 | ||
|
|
fa38734869 | ||
|
|
66ed4fd4d1 | ||
|
|
c528e0f638 | ||
|
|
41b1c73ff3 | ||
|
|
b1d1a7b1cc | ||
|
|
8bb132be24 | ||
|
|
15d0a01dcb | ||
|
|
ad05b2f427 | ||
|
|
2cbaef99c0 | ||
|
|
a53cab8343 | ||
|
|
e00da20870 | ||
|
|
45d8d5702c | ||
|
|
86e7944837 | ||
|
|
e9a2165f78 | ||
|
|
9421b22cff | ||
|
|
5767d2d606 | ||
|
|
3b5ecf4b6b | ||
|
|
d961698d40 | ||
|
|
37e9512d82 | ||
|
|
5e1164d98c | ||
|
|
1e64e992dd | ||
|
|
667126c916 | ||
|
|
5ada6cfb36 | ||
|
|
b6d5ce6836 | ||
|
|
0077f1a80b | ||
|
|
dfa82dbe96 | ||
|
|
ba014f4c98 | ||
|
|
6823161897 | ||
|
|
fcb0051c87 | ||
|
|
403aa9a362 | ||
|
|
8d91336b43 | ||
|
|
25abfb2a59 | ||
|
|
3f9a976742 | ||
|
|
8f41991a3d | ||
|
|
1785162cea | ||
|
|
0207424d32 | ||
|
|
ab7009e43f | ||
|
|
5c0e61d466 | ||
|
|
2cabbbf6f1 | ||
|
|
803e8d552d | ||
|
|
0950092f6d | ||
|
|
f6e15d9c35 | ||
|
|
5b917e032f | ||
|
|
7628f8d1ae | ||
|
|
1e496efbee | ||
|
|
0cf348570b | ||
|
|
55419dbafb | ||
|
|
cf3757ce11 | ||
|
|
4cf45b2684 | ||
|
|
544f7b5833 | ||
|
|
fa2a34c951 | ||
|
|
affbf366a1 | ||
|
|
4056f60cd8 | ||
|
|
a7a36f123c | ||
|
|
6865041707 | ||
|
|
520758e2f2 | ||
|
|
cd74624747 | ||
|
|
525f13b3e8 | ||
|
|
c82dc32ca2 | ||
|
|
ccb260c3f9 | ||
|
|
fa9fb6b27d | ||
|
|
b222b2342e | ||
|
|
a0d5c63aee | ||
|
|
0976c2c9e3 | ||
|
|
82647b2597 | ||
|
|
e20ac2c849 | ||
|
|
37a689e58e | ||
|
|
79043feb91 | ||
|
|
c2f905229f | ||
|
|
114e6aeb9b | ||
|
|
83f7270d6b | ||
|
|
63602bc19c | ||
|
|
65a608395b | ||
|
|
f11c076258 | ||
|
|
7b7f57f4c0 | ||
|
|
e2c100c5bb | ||
|
|
57e5c37f1a | ||
|
|
d53cc3b3f6 | ||
|
|
fc7ef5eb69 | ||
|
|
ccba129bb2 | ||
|
|
d9f52bdafd | ||
|
|
f0615c51e8 | ||
|
|
6deb4455ed | ||
|
|
86d7e414c2 | ||
|
|
19304b5d27 | ||
|
|
f87a07f6bd | ||
|
|
6b3490557c | ||
|
|
006468bd35 | ||
|
|
2cf516dd1f | ||
|
|
0467fde122 | ||
|
|
d9362fb819 | ||
|
|
b165e67676 | ||
|
|
1dc54fa5f2 | ||
|
|
91063db833 | ||
|
|
c9f2d07f4b | ||
|
|
290bdc3903 | ||
|
|
93734ec773 | ||
|
|
37a6b08fe2 | ||
|
|
f1e35bae27 | ||
|
|
5c2b426748 | ||
|
|
ed6606f8ac | ||
|
|
be4b46a81d | ||
|
|
f0c65454d3 | ||
|
|
646081286f | ||
|
|
e36d8727c5 | ||
|
|
e8cf0f2c24 | ||
|
|
cd4b3bbb38 | ||
|
|
2a5ced954b | ||
|
|
ea2a76bf81 | ||
|
|
1be2590f43 | ||
|
|
f37fc95ddc | ||
|
|
2515eb83a5 | ||
|
|
47e5c364a9 | ||
|
|
a03efbd2a9 | ||
|
|
d49d368895 | ||
|
|
ba792ff1f5 | ||
|
|
faad238f8c | ||
|
|
cc1368258c | ||
|
|
7c1b3c207d | ||
|
|
8f9af7d10c | ||
|
|
4ec07fdf77 | ||
|
|
7e9be22734 | ||
|
|
d85149e5f1 | ||
|
|
0e393b089f | ||
|
|
c16fb46b78 | ||
|
|
f051fe0087 | ||
|
|
bf2ac43a88 | ||
|
|
87f82af697 | ||
|
|
9036557154 | ||
|
|
c9c67f32c9 | ||
|
|
cc90375376 | ||
|
|
6df3f8a55f | ||
|
|
0bf0134a86 | ||
|
|
576f21b6fc | ||
|
|
824c952ad9 | ||
|
|
eeae88595d | ||
|
|
a709f25ea3 | ||
|
|
4c43ab0e8e | ||
|
|
d97a2d1fe3 | ||
|
|
10baa139a1 | ||
|
|
be04db6237 | ||
|
|
ce319f8696 | ||
|
|
b26139fdac | ||
|
|
98cf67381b | ||
|
|
132267f5d4 | ||
|
|
1a204d303b | ||
|
|
f82227ea0d | ||
|
|
238bb9f5ea | ||
|
|
f026c88817 | ||
|
|
fa2fb34e41 | ||
|
|
85cf5f5ce5 | ||
|
|
1e62920925 | ||
|
|
d58c7245e7 | ||
|
|
861397b455 | ||
|
|
a265de4a62 | ||
|
|
d9883b6f97 | ||
|
|
64d80c79ac | ||
|
|
91def4cd4d | ||
|
|
6a46983293 | ||
|
|
0544d870e7 | ||
|
|
e1327961b6 | ||
|
|
153bc39e63 | ||
|
|
f94f69aa59 | ||
|
|
9dfae705e5 | ||
|
|
32144c5657 | ||
|
|
5235df2259 | ||
|
|
eca6fae897 | ||
|
|
1e58d5017d | ||
|
|
75121c5a79 | ||
|
|
71231d3a9b | ||
|
|
3b6c82d6f5 | ||
|
|
1fc96f6b3a | ||
|
|
c7216563e6 | ||
|
|
4a27acb4ac | ||
|
|
e0d77bf82d | ||
|
|
84e6f8ed6d | ||
|
|
67309f5925 | ||
|
|
8480215db9 | ||
|
|
7bab9a1238 | ||
|
|
e08621d317 | ||
|
|
204acac19c | ||
|
|
077e82bda2 | ||
|
|
9af4a52927 | ||
|
|
2970adc634 | ||
|
|
a95b88d99f | ||
|
|
3832d13200 | ||
|
|
ca06fea13f | ||
|
|
5575dbb054 | ||
|
|
8d02152697 | ||
|
|
ae601c45f5 | ||
|
|
7391e68778 | ||
|
|
0f607f67ee | ||
|
|
142a7fcf48 | ||
|
|
2ef6cf6053 | ||
|
|
dd0dbb0f27 | ||
|
|
f85a637566 | ||
|
|
bb711b8167 | ||
|
|
9f4ee76bb7 | ||
|
|
4464d589d9 | ||
|
|
de815af682 | ||
|
|
46dc4190a0 | ||
|
|
c8414ba000 | ||
|
|
9eaf1d8029 | ||
|
|
b26af574ae | ||
|
|
51692a17db | ||
|
|
b90b5fc8f4 | ||
|
|
6e0642a11b | ||
|
|
fba8cc680c | ||
|
|
1547784a40 | ||
|
|
3b65435899 | ||
|
|
bd98d1bdcb | ||
|
|
d946067bd4 | ||
|
|
d15267ea53 | ||
|
|
05b7520a3f | ||
|
|
f8e04ea086 | ||
|
|
e594d27beb | ||
|
|
427937e8d5 | ||
|
|
ff189b7357 | ||
|
|
92542b4405 | ||
|
|
351fb9c090 | ||
|
|
af6862dfe5 | ||
|
|
4e562c5c66 | ||
|
|
25d0aad3cb | ||
|
|
82743a0d66 | ||
|
|
ce51483344 | ||
|
|
40806125a5 | ||
|
|
bb33e9f82b | ||
|
|
7fdbcdf951 | ||
|
|
369b3bf867 | ||
|
|
b62f864167 | ||
|
|
5b748dc207 | ||
|
|
cbf96086d3 | ||
|
|
29b7cd93da | ||
|
|
7987fe186a | ||
|
|
3fd8c5de0e | ||
|
|
82fda0be94 | ||
|
|
3f9cbf8964 | ||
|
|
d86f634827 | ||
|
|
82b08ce80b | ||
|
|
7015ac4f7e | ||
|
|
bf8a306870 | ||
|
|
2a3fc39da6 | ||
|
|
079770da7b | ||
|
|
cabf2d3ba8 | ||
|
|
9f8864683d | ||
|
|
56685916f0 | ||
|
|
991e0f2429 | ||
|
|
89235209e1 | ||
|
|
66ff3375ff | ||
|
|
993adfc196 | ||
|
|
fdbe058c2a | ||
|
|
4471e5930d | ||
|
|
1c7c739942 | ||
|
|
0a7eec666d | ||
|
|
41c640ca10 | ||
|
|
2e961db66f | ||
|
|
c0d817fb69 | ||
|
|
6d1b82d3c3 | ||
|
|
8805587576 | ||
|
|
a8e62c5ce9 | ||
|
|
dca02881c8 | ||
|
|
fabd6efe69 | ||
|
|
fe2d9c21d3 | ||
|
|
582a5c05e6 | ||
|
|
1be37c1b4d | ||
|
|
f45e3d654a | ||
|
|
8204d41193 | ||
|
|
5f3a1a9f3f | ||
|
|
13d9bad9e6 | ||
|
|
1c7e26b26e | ||
|
|
5cc9d98475 | ||
|
|
46d3b7b95c | ||
|
|
13ec709616 | ||
|
|
f1dc6e2975 | ||
|
|
4cf2cb0864 | ||
|
|
d34a8e5387 | ||
|
|
8814ac1013 | ||
|
|
e3c197e22b | ||
|
|
efbdcace68 | ||
|
|
23c0343917 | ||
|
|
e9a72ae15c | ||
|
|
ef77651512 | ||
|
|
e62bc7ea36 | ||
|
|
bc10fac9f0 | ||
|
|
81deee6692 | ||
|
|
b44075c37d | ||
|
|
99a6d362d5 | ||
|
|
4bd4bf56d3 | ||
|
|
9be971ce64 | ||
|
|
efc899be71 | ||
|
|
277c3b3b36 | ||
|
|
e4c69be8f2 | ||
|
|
1fe66ee2da | ||
|
|
96ea3cf3f3 | ||
|
|
503117f242 | ||
|
|
57bd92cc7d | ||
|
|
298eed22cc | ||
|
|
5005602292 | ||
|
|
66472f0f06 | ||
|
|
2a79e312be | ||
|
|
248e598f77 | ||
|
|
bb265b750e | ||
|
|
97a71f2bda |
No files matched your search
@@ -1 +1,5 @@
|
||||
.DS_Store
|
||||
.vscode/launch.json
|
||||
__pycache__
|
||||
bot_config.conf
|
||||
.claude
|
||||
@@ -1,6 +0,0 @@
|
||||
stages:
|
||||
- deploy
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
script: /var/lib/gitlab-runner/deploy_r0b3bot.sh
|
||||
@@ -1,19 +1,86 @@
|
||||
*** This project lives @ https://gitlab.rickelobe.com/Bots/R0b3BOT ***
|
||||
|
||||
[](http://gitlab.rickelobe.com/Bots/r0b3BOT/commits/master)
|
||||
|
||||
# Screenshot
|
||||

|
||||
*** 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 `$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)
|
||||
* 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
|
||||

|
||||
|
||||
All text commands are available on both Discord and Matrix. Sound/voice commands are Discord-only.
|
||||
|
||||
# Requirements
|
||||
1. discord.py version 1.2.4 or greater<br>
|
||||
<code> pip install discord.py </code>
|
||||
|
||||
Install all Python dependencies using the provided `requirements.txt`:
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Dependencies:
|
||||
- `discord.py[voice]` >= 2.6.4 — Discord bot framework (voice support included)
|
||||
- `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 Developer tools<br>
|
||||
2. Enter various configuration parameters in bot_config.conf.example<br>
|
||||
3. Enter OctoPrint config parameters in octoapi.conf.example<br>
|
||||
4. Rename *.example files as *.conf<br>
|
||||
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)
|
||||
3. Rename `octoapi.conf.example` to `octoapi.conf` and configure OctoPrint settings (if using OctoPrint)
|
||||
|
||||
## bot_config.conf Keys
|
||||
|
||||
### [bot-config]
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `discord_auth_token` | Bot token from the Discord Developer Console |
|
||||
| `bot_command_prefix` | Command prefix (e.g. `$`, `!`, `/`) |
|
||||
| `hass_url` | URL to your Home Assistant instance |
|
||||
| `hass_token` | Long-lived access token for Home Assistant REST API |
|
||||
| `hass_light` | Entity ID of the light at your printer |
|
||||
| `octoprint_ip_address` | IP address of your OctoPrint/OctoPi host |
|
||||
| `bambu_ip_address` | IP address of your Bambu Lab printer on the local network |
|
||||
| `bambu_access_code` | Access code shown on the printer screen (Settings > WLAN) |
|
||||
| `bambu_serial_number` | Serial number of your Bambu Lab printer |
|
||||
| `bambu_camera_enable` | Set to `true` to capture camera snapshots with `$bambustat` (requires ffmpeg) |
|
||||
| `statping_enable` | Set to `true` to enable StatPing service status integration |
|
||||
| `statping_url` | URL of your StatPing instance |
|
||||
| `statping_api_key` | API key for StatPing |
|
||||
| `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/` |
|
||||
+41
-1
@@ -3,14 +3,54 @@
|
||||
# Replace the placeholders with your information. Do not use quotes.
|
||||
#
|
||||
# discord_auth_token - this is the bot token generated via the Discord development console.
|
||||
# bot_command_prefix - The prefix used identify commands for this bot
|
||||
# hass_url - URL to your home assistant instance. This is used to turn on the light by your 3d printer
|
||||
# hass_token - Long Lived Access Token used to authenticate with the REST API for your Home assistant instance
|
||||
# hass_light - Entity name of the light you want to control
|
||||
# octoprint_ip_address - IP Address of you OctoPrint (or OctoPi) host. Used to get 3D printer stats.
|
||||
# octoprint_ip_address - IP Address of you OctoPrint (or OctoPi) host. Used to get 3D printer stats.
|
||||
# bambu_ip_address - IP Address of your Bambu Lab printer on the local network
|
||||
# bambu_access_code - Access code displayed on the printer's screen (Settings > WLAN)
|
||||
# bambu_serial_number - Serial number of your Bambu Lab printer
|
||||
# bambu_camera_enable - Set to true to capture a camera snapshot with $bambustat via ffmpeg RTSPS stream.
|
||||
# Requires ffmpeg to be installed on the bot host.
|
||||
# statping_url - Server hostname of the StatPing instance we will be querying w/ the REST API
|
||||
# statping_api_key - API Key for Statping to query for service status
|
||||
#
|
||||
discord_auth_token = <Discord Auth Token>
|
||||
bot_command_prefix = <!/$/#/etc>
|
||||
hass_url = <URL to HomeAssistant>
|
||||
hass_token = <HomeAssistant Long Lived Access Token>
|
||||
hass_light = <Entity_ID of the light @ your printer>
|
||||
octoprint_ip_address = <OctoPrint IP Address>
|
||||
bambu_ip_address = <Bambu Lab Printer IP Address>
|
||||
bambu_access_code = <Bambu Lab Access Code>
|
||||
bambu_serial_number = <Bambu Lab Serial Number>
|
||||
bambu_camera_enable = false
|
||||
statping_enable = <trues/false>
|
||||
statping_url = <Statping URL>
|
||||
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/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
+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}")
|
||||
+518
-121
@@ -1,23 +1,42 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext import commands, tasks
|
||||
from datetime import datetime
|
||||
import urllib.request
|
||||
import random
|
||||
import os
|
||||
import sys
|
||||
import octoapi
|
||||
import time
|
||||
from datetime import datetime
|
||||
import configparser
|
||||
import json
|
||||
from discord import FFmpegPCMAudio
|
||||
from discord.utils import get
|
||||
from discord.voice_client import VoiceClient
|
||||
import requests
|
||||
import asyncio
|
||||
from bambu_connect import BambuClient
|
||||
|
||||
bot = commands.Bot(command_prefix='$', description='A derpy derp of a bot.')
|
||||
# Monkey-patch every dataclass in bambu-connect's models module to tolerate
|
||||
# unknown fields. The Bambu printer firmware sends extra keys that the
|
||||
# bambu-connect 0.3.1 models don't define (e.g. Upload.file_size,
|
||||
# Online.ext), causing TypeErrors that crash the MQTT thread.
|
||||
# Filtering kwargs to known fields before calling the original __init__
|
||||
# fixes this for all nested classes at once.
|
||||
import dataclasses as _dc
|
||||
import inspect as _inspect
|
||||
from bambu_connect.utils import models as _bambu_models
|
||||
|
||||
def _make_permissive(cls):
|
||||
known = {f.name for f in _dc.fields(cls)}
|
||||
orig = cls.__init__
|
||||
def __init__(self, **kwargs):
|
||||
orig(self, **{k: v for k, v in kwargs.items() if k in known})
|
||||
cls.__init__ = __init__
|
||||
|
||||
for _name, _cls in _inspect.getmembers(_bambu_models, _inspect.isclass):
|
||||
if _dc.is_dataclass(_cls):
|
||||
_make_permissive(_cls)
|
||||
|
||||
#Record the time the bot started
|
||||
start_time = time.time()
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
configFilePath = r'bot_config.conf'
|
||||
@@ -27,7 +46,18 @@ try:
|
||||
except:
|
||||
print("Error loading config! Please check config file 'bot-config.conf'")
|
||||
|
||||
# Setup HomeAssistant URL and Headers
|
||||
# Last error message variable used by $last_error command to display in discord
|
||||
last_error_msg = "No errors have been recorded."
|
||||
|
||||
#
|
||||
# Assign bot config from setting in config file
|
||||
#
|
||||
|
||||
# Assign Bot core settings
|
||||
DISCORD_AUTH_TOKEN = config.get('bot-config', 'discord_auth_token')
|
||||
BOT_COMMAND_PREFIX = config.get('bot-config', 'bot_command_prefix')
|
||||
|
||||
# 3D Printer Setup - HomeAssistant URL and Headers & OctoPrint
|
||||
HASS_TOKEN = config.get('bot-config', 'hass_token')
|
||||
HASS_URL = config.get('bot-config', 'hass_url')
|
||||
HASS_LIGHT = config.get('bot-config', 'hass_light')
|
||||
@@ -36,28 +66,187 @@ hassHEADERS = {
|
||||
'Authorization': f"Bearer {HASS_TOKEN}",
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
|
||||
OCTOPRINT_IP_ADDRESS = config.get('bot-config', 'octoprint_ip_address')
|
||||
|
||||
DISCORD_AUTH_TOKEN = config.get('bot-config', 'discord_auth_token')
|
||||
# Bambu Lab Printer config (optional — feature is skipped if not set)
|
||||
BAMBU_IP_ADDRESS = config.get('bot-config', 'bambu_ip_address', fallback=None)
|
||||
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
|
||||
_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
|
||||
bambu_status = status
|
||||
|
||||
|
||||
# Configure bot
|
||||
bot = commands.Bot(intents=discord.Intents.all(),command_prefix=BOT_COMMAND_PREFIX, description='A derpy derp of a bot.')
|
||||
bot.remove_command('help')
|
||||
|
||||
# Load opus once at startup rather than on every sound command
|
||||
discord.opus.load_opus("libopus.so")
|
||||
|
||||
# Printing configuration details to console
|
||||
print(f"HomeAssistant URL: {hassURL}")
|
||||
print(f"HomeAssistant URL HEADERS: {hassHEADERS}")
|
||||
print(f"HomeAssistant Light: {HASS_LIGHT}")
|
||||
print(f"OctoPrint IP Address: {OCTOPRINT_IP_ADDRESS}")
|
||||
print("=== 3D Printer Status config ===")
|
||||
print(f" HomeAssistant URL: {hassURL}")
|
||||
print(f" HomeAssistant URL HEADERS: {hassHEADERS}")
|
||||
print(f" HomeAssistant Light: {HASS_LIGHT}")
|
||||
print(f" OctoPrint IP Address: {OCTOPRINT_IP_ADDRESS}")
|
||||
|
||||
async def _update_bambu_activity(status):
|
||||
remaining_mins = status.mc_remaining_time or 0
|
||||
remaining_hours = remaining_mins // 60
|
||||
remaining_mins_part = remaining_mins % 60
|
||||
activity = discord.Activity(
|
||||
name=f"Bambu @ {status.mc_percent}%, {remaining_hours}h{remaining_mins_part:02d}m to go",
|
||||
type=discord.ActivityType.watching,
|
||||
)
|
||||
await bot.change_presence(activity=activity)
|
||||
|
||||
|
||||
async def _capture_bambu_frame(filename):
|
||||
"""Capture a single JPEG frame from the Bambu RTSPS stream via ffmpeg.
|
||||
Returns True on success, raises on timeout or ffmpeg error."""
|
||||
rtsp_url = f"rtsps://bblp:{BAMBU_ACCESS_CODE}@{BAMBU_IP_ADDRESS}/streaming/live/1"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-y",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-i", rtsp_url,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
filename,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
async def _reset_bot_activity():
|
||||
activity = discord.Activity(
|
||||
name=f"your {BOT_COMMAND_PREFIX}commands",
|
||||
type=discord.ActivityType.listening,
|
||||
)
|
||||
await bot.change_presence(activity=activity)
|
||||
|
||||
|
||||
async def _set_octo_activity(completion):
|
||||
"""Update Discord presence with the current OctoPrint job status."""
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
await _reset_bot_activity()
|
||||
|
||||
|
||||
# On Ready
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
print("\n:::: Config Loaded ::::\n")
|
||||
print('Logged in as')
|
||||
print(bot.user.name)
|
||||
print(bot.user.id)
|
||||
print('------')
|
||||
print("Setting activity to 'Listenting to your commands")
|
||||
activity = discord.Activity(name="your commands",type=discord.ActivityType.listening)
|
||||
print(f" User: {bot.user.name}")
|
||||
print(f" ID: {bot.user.id}")
|
||||
print(f"\n\n")
|
||||
print(f"Setting activity to 'Listenting to your {BOT_COMMAND_PREFIX}commands'")
|
||||
activity = discord.Activity(name=f"your {BOT_COMMAND_PREFIX}commands",type=discord.ActivityType.listening)
|
||||
await bot.change_presence(activity=activity)
|
||||
|
||||
print(">> Startup complete.")
|
||||
|
||||
# Clear any stale voice states left over from a previous session.
|
||||
# Discord holds onto the bot's voice state across restarts; if not cleared,
|
||||
# the first voice connect attempt gets a 4006 (session no longer valid).
|
||||
print(">> Clearing stale voice states across all guilds")
|
||||
for guild in bot.guilds:
|
||||
try:
|
||||
await guild.change_voice_state(channel=None)
|
||||
except Exception as e:
|
||||
print(f" ! Could not clear voice state for {guild.name}: {e}")
|
||||
|
||||
# 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
|
||||
if BAMBU_IP_ADDRESS and BAMBU_ACCESS_CODE and BAMBU_SERIAL_NUMBER:
|
||||
if bambu_client is None:
|
||||
print(">> Connecting to Bambu printer MQTT watch client")
|
||||
bambu_client = BambuClient(BAMBU_IP_ADDRESS, BAMBU_ACCESS_CODE, BAMBU_SERIAL_NUMBER)
|
||||
bambu_client.start_watch_client(_on_bambu_update)
|
||||
print(">> Bambu watch client started")
|
||||
else:
|
||||
print(">> Bambu printer config not found, skipping Bambu watch client")
|
||||
|
||||
|
||||
@bot.command()
|
||||
async def greetings(ctx):
|
||||
await ctx.send(":smiley: :wave: Hello there!")
|
||||
@@ -65,11 +254,13 @@ async def greetings(ctx):
|
||||
|
||||
@bot.event
|
||||
async def on_command_error(ctx, error):
|
||||
global last_error_msg
|
||||
last_error_msg = error
|
||||
if isinstance(error, commands.CommandOnCooldown):
|
||||
seconds = round(error.retry_after,1)
|
||||
print(f" - Error, user tried to use command while in cooldown (wait is: {seconds}")
|
||||
print(f" - Error, user tried to use command while in cooldown (wait is: {seconds})")
|
||||
await ctx.send(f"This command is ratelimited per user, please try again in {seconds}s")
|
||||
if isinstance(error, commands.CommandInvokeError):
|
||||
elif isinstance(error, commands.CommandInvokeError):
|
||||
print(" - ERROR: encountered 'CommandInvokeError'")
|
||||
print(f" - Dumping error: {error}")
|
||||
await ctx.send("Error, unable to complete your request.")
|
||||
@@ -77,6 +268,12 @@ async def on_command_error(ctx, error):
|
||||
print('Oopsie, I found an error...')
|
||||
print(f"Error: {error}")
|
||||
|
||||
#
|
||||
# Debug commands
|
||||
#
|
||||
@bot.command()
|
||||
async def last_error(ctx):
|
||||
await ctx.send(f"Last error recorded: {last_error_msg}")
|
||||
|
||||
#
|
||||
# Giphy Commands
|
||||
@@ -95,10 +292,18 @@ async def holyshit(ctx):
|
||||
|
||||
###
|
||||
|
||||
#
|
||||
# Image Commands
|
||||
#
|
||||
@bot.command()
|
||||
async def smudge(ctx):
|
||||
print(" - Uploading Smudge to chat channel")
|
||||
file = discord.File("./images/smudge.png", filename="smudge.png")
|
||||
await ctx.send(file=file)
|
||||
|
||||
#
|
||||
# Voice Commands
|
||||
#
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=30.0, type=commands.BucketType.user)
|
||||
async def bitch(ctx, member : discord.Member="NONE"):
|
||||
@@ -151,6 +356,50 @@ async def promoted(ctx, member : discord.Member="NONE"):
|
||||
async def wow(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/wow.mp3", "$wow")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def aww(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/Awww_Bitch.mp3", "$aww")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def eia(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/awesome.mp3", "$eia")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def yaw(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/yaw.mp3", "$yaw")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def heavy(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/heavy.mp3", "$heavy")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def iran(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/iran.mp3", "$iran")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def big(ctx, member : discord.Member="NONE"):
|
||||
await play_sound(ctx, member, "./sounds/big.mp3", "$big")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def breee(ctx, member : discord.Member="NONE"):
|
||||
await ctx.send(f"\m/ ( ゚Д゚)<BREEEEE!! \m/")
|
||||
await play_sound(ctx, member, "./sounds/wbbwb_metal.mp3", "$breee")
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def nice(ctx, member : discord.Member="NONE"):
|
||||
await ctx.send(f"\m/ ( ゚Д゚)<N!CE! \m/")
|
||||
await play_sound(ctx, member, "./sounds/wbtbwb-nice.mp3", "$nice")
|
||||
|
||||
|
||||
|
||||
@bot.command()
|
||||
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
|
||||
async def rs(ctx, member : discord.Member="NONE"):
|
||||
@@ -165,8 +414,6 @@ async def rs(ctx, member : discord.Member="NONE"):
|
||||
|
||||
async def play_sound(ctx, member : discord.Member, soundFile, command):
|
||||
|
||||
discord.opus.load_opus("libopus.so")
|
||||
|
||||
datestring = datetime.now()
|
||||
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
|
||||
|
||||
@@ -177,7 +424,7 @@ async def play_sound(ctx, member : discord.Member, soundFile, command):
|
||||
if member != "NONE":
|
||||
try:
|
||||
channel = member.voice.channel
|
||||
except:
|
||||
except AttributeError:
|
||||
await ctx.send(f"{member.display_name} is not in a voice channel!")
|
||||
print(f" ! {member.display_name} is not in a voice channel")
|
||||
return
|
||||
@@ -187,7 +434,7 @@ async def play_sound(ctx, member : discord.Member, soundFile, command):
|
||||
if member == "NONE":
|
||||
try:
|
||||
channel = ctx.message.author.voice.channel
|
||||
except:
|
||||
except AttributeError:
|
||||
await ctx.send("You are not connected to a voice channel.")
|
||||
print("User is not connected to a voice channel")
|
||||
return
|
||||
@@ -205,13 +452,17 @@ async def play_sound(ctx, member : discord.Member, soundFile, command):
|
||||
else:
|
||||
print(f" - Joining Channel '{channel}''")
|
||||
await ctx.send(f"Joining '{channel}' for just a moment")
|
||||
voice = await channel.connect()
|
||||
# reconnect=False disables discord.py's internal retry loop so it doesn't
|
||||
# hammer the same invalid session repeatedly (which was causing "attempt 4").
|
||||
voice = await channel.connect(reconnect=False)
|
||||
|
||||
print(" - Creating Player")
|
||||
audio_source = discord.FFmpegPCMAudio(soundFile)
|
||||
print(f" - Playing {soundFile}")
|
||||
await asyncio.sleep(1)
|
||||
voice.play(audio_source, after=None)
|
||||
time.sleep(5)
|
||||
while voice.is_playing():
|
||||
await asyncio.sleep(1)
|
||||
print(f" - Disconnecting from '{channel}'")
|
||||
await voice.disconnect()
|
||||
###
|
||||
@@ -220,32 +471,29 @@ 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")
|
||||
|
||||
#print("========== Command Called at %s ==========" % (time.strftime("%Y%m%d-%H%M%S")))
|
||||
# We haven't yet changed the state of the light, setting this to False
|
||||
turned_on_light = False
|
||||
|
||||
# No Data from Printer?
|
||||
operational = True
|
||||
|
||||
# Try to get the status of the 3D printer light
|
||||
print(" - Getting Printer Light Status")
|
||||
work_lights = requests.get(hassURL, headers=hassHEADERS)
|
||||
print(f" JSON Response from HomeAssistant: {work_lights.text}")
|
||||
loop = asyncio.get_running_loop()
|
||||
work_lights_resp = await loop.run_in_executor(None, lambda: requests.get(hassURL, headers=hassHEADERS))
|
||||
print(f" JSON Response from HomeAssistant: {work_lights_resp.text}")
|
||||
|
||||
work_lights = json.loads(work_lights.text)
|
||||
work_lights = json.loads(work_lights_resp.text)
|
||||
|
||||
# Making sure that the API request succeeded. If it has, there will be a state attribute added to
|
||||
# work_lights.
|
||||
# work_lights.
|
||||
try:
|
||||
work_lights["state"]
|
||||
except:
|
||||
except KeyError:
|
||||
print(" ! Unable to get light status from Homeassistant, notifying chat channel")
|
||||
await ctx.send("Hmm, I can't tell if the light is on... oh well [Hass API error]")
|
||||
|
||||
@@ -267,19 +515,17 @@ async def printstat(ctx):
|
||||
await ctx.send(f"{random.choice(lightMessages)}")
|
||||
|
||||
try:
|
||||
# REST API call to home assistant to turn the light off.
|
||||
# REST API call to home assistant to turn the light on.
|
||||
hassAPIURL = HASS_URL + "/api/services/switch/turn_on"
|
||||
payload = f'{{"entity_id": "{HASS_LIGHT}"}}'
|
||||
#print(f" - Payload String: {payload}")
|
||||
response = requests.post(hassAPIURL, headers=hassHEADERS, data=payload)
|
||||
#print(f" - POST Response: {response.text}")
|
||||
await loop.run_in_executor(None, lambda: requests.post(hassAPIURL, headers=hassHEADERS, data=payload))
|
||||
|
||||
except Exception as e:
|
||||
print(f" ! Unable to turn on light; ERROR: {e}")
|
||||
await ctx.send("Sorry, I was unable to turn on the light.")
|
||||
pass
|
||||
|
||||
time.sleep(3)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Randomly generate a filename to save the image to
|
||||
file_name = random.randrange(1,10000)
|
||||
@@ -287,7 +533,8 @@ async def printstat(ctx):
|
||||
|
||||
# Get an image from OctoPrint and save it
|
||||
print(" - Getting image from Webcam")
|
||||
urllib.request.urlretrieve("http://%s:8080/?action=snapshot" % (OCTOPRINT_IP_ADDRESS), full_file_name)
|
||||
await loop.run_in_executor(None, lambda: urllib.request.urlretrieve(
|
||||
"http://%s:8080/?action=snapshot" % (OCTOPRINT_IP_ADDRESS), full_file_name))
|
||||
|
||||
# If we turned on the light, let's be nice and turn it back off
|
||||
if turned_on_light:
|
||||
@@ -296,8 +543,7 @@ async def printstat(ctx):
|
||||
# REST API call to home assistant to turn the light off.
|
||||
hassAPIURL = HASS_URL + "/api/services/switch/turn_off"
|
||||
payload = f'{{"entity_id": "{HASS_LIGHT}"}}'
|
||||
response = requests.post(hassAPIURL, headers=hassHEADERS, data=payload)
|
||||
#print(f" - POST Response: {response.text}")
|
||||
await loop.run_in_executor(None, lambda: requests.post(hassAPIURL, headers=hassHEADERS, data=payload))
|
||||
|
||||
except Exception as e:
|
||||
print(f" ! Unable to turn on light; ERROR: {e}")
|
||||
@@ -316,75 +562,78 @@ async def printstat(ctx):
|
||||
print(" - Connecting to OctoPrint API")
|
||||
# Testing OctoPrint API Status. If printer is off, I won't receive JSON data.
|
||||
try:
|
||||
operational = json.loads(octoapi.get_printer_dict())
|
||||
except:
|
||||
operational = await loop.run_in_executor(None, octoapi.get_printer_dict)
|
||||
except Exception:
|
||||
# Printer is Offline
|
||||
operational = False
|
||||
|
||||
if not operational:
|
||||
|
||||
if operational:
|
||||
print(" - 3D Printer is on and printing, getting details")
|
||||
|
||||
# Get the name of the file currently being printed
|
||||
try:
|
||||
print(" - Getting Filename")
|
||||
print_filename = octoapi.get_printFileName()
|
||||
except:
|
||||
print_filename = await loop.run_in_executor(None, octoapi.get_printFileName)
|
||||
except Exception:
|
||||
print_filename = ""
|
||||
|
||||
try:
|
||||
print(" - Getting % Completion")
|
||||
# Try to get the % of Completion
|
||||
print_completion = round(octoapi.get_completion(), 2)
|
||||
except:
|
||||
print_completion = round(await loop.run_in_executor(None, octoapi.get_completion), 2)
|
||||
except Exception:
|
||||
print_completion = "Unknown"
|
||||
|
||||
# Get the current print time in seconds
|
||||
try:
|
||||
print(" - Getting Seconds Elapsed")
|
||||
print_seconds = octoapi.get_printTime()
|
||||
except:
|
||||
print_seconds = await loop.run_in_executor(None, octoapi.get_printTime)
|
||||
except Exception:
|
||||
print("Unable to get Seconds data from OctoPrint")
|
||||
print_seconds = 0
|
||||
|
||||
# Get the time remaining for current print job in seconds
|
||||
try:
|
||||
print(" - Getting Seconds Left")
|
||||
print_secondsleft = octoapi.get_printTimeLeft()
|
||||
except:
|
||||
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:
|
||||
print(" - Converting Seconds to Hours")
|
||||
# Try to convert seconds to hours
|
||||
print_hours = int(((print_seconds / 60) / 60))
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
print_hours = "Unknown"
|
||||
|
||||
try:
|
||||
print(" - Converting Seconds left to Hours left")
|
||||
# Try to convert seconds left to hours left
|
||||
print_hoursleft = int(((print_secondsleft / 60) / 60))
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
print_hoursleft = "Unknown"
|
||||
|
||||
|
||||
try:
|
||||
print(" - Calculating Minutes Elapsed")
|
||||
# Using the total number of minutes minus the total number of whole hours to get the minutes remaining
|
||||
print_min = int(((print_seconds / 60) - (print_hours * 60)))
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
print_min = "Unknown"
|
||||
|
||||
|
||||
try:
|
||||
print(" - Calculating Minutes Left")
|
||||
# Same as above but for the minutes left in print job
|
||||
print_minleft = int(((print_secondsleft / 60) - (print_hoursleft * 60)))
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
print_minleft = "Unknown"
|
||||
|
||||
time_elapsed = "%s Hours %s Minutes" % (print_hours, print_min)
|
||||
time_remaining = "%s Hours %s Minutes" % (print_hoursleft, print_minleft)
|
||||
|
||||
printer_bed = "%s°C" % (octoapi.get_printer_dict()["temperature"]["bed"]["actual"])
|
||||
printer_hotend = "%s°C" % (octoapi.get_printer_dict()["temperature"]["tool0"]["actual"])
|
||||
# operational dict already fetched above — use it directly instead of calling again
|
||||
printer_bed = "%s°C" % (operational["temperature"]["bed"]["actual"])
|
||||
printer_hotend = "%s°C" % (operational["temperature"]["tool0"]["actual"])
|
||||
|
||||
embed = discord.Embed(title="R0b3's 3D Printer Status", description="Current Job: " + print_filename, color=0xF5A623)
|
||||
|
||||
@@ -393,11 +642,14 @@ async def printstat(ctx):
|
||||
embed.add_field(name="Time Remaining: ", value=time_remaining)
|
||||
embed.add_field(name="Bed Temp.: ", value=printer_bed)
|
||||
embed.add_field(name="Hotend Temp.: ", value=printer_hotend)
|
||||
embed.add_field(name="Livestream URL: ",value="https://rickelobe.com/printer/?action=stream")
|
||||
print(" - Sending details to chat channel")
|
||||
await ctx.send(embed=embed)
|
||||
activity = discord.Activity(name=f"3D Print @ {str(print_completion)}%",type=discord.ActivityType.watching)
|
||||
await bot.change_presence(activity=activity)
|
||||
await updateStatus()
|
||||
|
||||
## No longer need to update the bot status here since there's a check happening every 2 mins in on_ready
|
||||
#activity = discord.Activity(name=f"3D Print @ {str(print_completion)}%",type=discord.ActivityType.watching)
|
||||
#await bot.change_presence(activity=activity)
|
||||
#await updateStatus() # No longer need to call this since it the bot now checks for a print job every 2 mins.
|
||||
|
||||
else:
|
||||
print(" - 3D Printer is not on or is not printing")
|
||||
@@ -405,42 +657,85 @@ async def printstat(ctx):
|
||||
print(" - Notifying chat channel")
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
async def updateStatus():
|
||||
|
||||
# Initialize variables
|
||||
print_completion = 0
|
||||
unknowns = 0
|
||||
#While the print status is less than 100 % complete, keep updating
|
||||
while print_completion < 100:
|
||||
#
|
||||
# Bambu Lab Printer Commands
|
||||
#
|
||||
@bot.command()
|
||||
async def bambustat(ctx):
|
||||
|
||||
datestring = datetime.now().strftime("%m/%d/%Y-%H:%M:%S")
|
||||
print(f"[{datestring}]: {ctx.message.author.display_name} called $bambustat")
|
||||
|
||||
if bambu_status is None:
|
||||
await ctx.send("Bambu printer status is not available yet. The printer may be offline or still connecting.")
|
||||
return
|
||||
|
||||
status = bambu_status
|
||||
|
||||
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"
|
||||
|
||||
# mc_remaining_time is in minutes
|
||||
remaining_mins = status.mc_remaining_time or 0
|
||||
remaining_hours = remaining_mins // 60
|
||||
remaining_mins_part = remaining_mins % 60
|
||||
time_remaining = f"{remaining_hours}h {remaining_mins_part: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}"
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Bambu Lab Printer Status",
|
||||
description=f"File: {gcode_file}",
|
||||
color=0x00B4D8,
|
||||
)
|
||||
embed.add_field(name="State:", value=gcode_state, inline=False)
|
||||
embed.add_field(name="Percent Complete:", value=f"{mc_percent}%")
|
||||
embed.add_field(name="Time Remaining:", value=time_remaining)
|
||||
embed.add_field(name="Layer:", value=layer_info)
|
||||
embed.add_field(name="Nozzle Temp:", value=nozzle_temp)
|
||||
embed.add_field(name="Bed Temp:", value=bed_temp)
|
||||
embed.add_field(name="Chamber Temp:", value=chamber_temp)
|
||||
|
||||
# Try to grab a snapshot via ffmpeg RTSPS (only if enabled in config)
|
||||
image_filename = None
|
||||
if BAMBU_CAMERA_ENABLE and BAMBU_IP_ADDRESS and BAMBU_ACCESS_CODE:
|
||||
image_filename = f"bambu_{random.randrange(1, 10000)}.jpg"
|
||||
try:
|
||||
print(" - Getting % Completion")
|
||||
# Try to get the % of Completion
|
||||
print_completion = round(octoapi.get_completion(), 2)
|
||||
except:
|
||||
print_completion = "Unknown"
|
||||
unknowns += 1
|
||||
|
||||
#Set the activity to the new percent complete value
|
||||
activity = discord.Activity(name=f"3D Print @ {str(print_completion)}%",type=discord.ActivityType.watching)
|
||||
await bot.change_presence(activity=activity)
|
||||
if (unknowns >= 5):
|
||||
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(" - Capturing Bambu camera frame via RTSPS")
|
||||
success = await _capture_bambu_frame(image_filename)
|
||||
if not success:
|
||||
print(" ! ffmpeg exited with non-zero code, skipping image")
|
||||
image_filename = None
|
||||
except asyncio.TimeoutError:
|
||||
print(" ! ffmpeg timed out capturing Bambu camera frame")
|
||||
image_filename = None
|
||||
except Exception as e:
|
||||
print(f" ! Unable to capture Bambu camera frame: {e}")
|
||||
image_filename = None
|
||||
|
||||
print(" - Done watching 3D Printer status")
|
||||
await asyncio.sleep(3)
|
||||
activity = discord.Activity(name="your commands",type=discord.ActivityType.listening)
|
||||
await bot.change_presence(activity=activity)
|
||||
print(" - Sending Bambu details to chat channel")
|
||||
if image_filename:
|
||||
file = discord.File(image_filename, filename=image_filename)
|
||||
await ctx.send(file=file, embed=embed)
|
||||
os.remove(image_filename)
|
||||
else:
|
||||
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()
|
||||
@@ -456,28 +751,130 @@ async def info(ctx):
|
||||
# give users a link to invite thsi bot to their server
|
||||
embed.add_field(name="Invite", value="[Invite link](https://discordapp.com/api/oauth2/authorize?client_id=416312716677087233&permissions=51264&scope=bot)")
|
||||
|
||||
# Bot Uptime
|
||||
second = time.time() - start_time
|
||||
minute, second = divmod(second, 60)
|
||||
hour, minute = divmod(minute, 60)
|
||||
day, hour = divmod(hour, 24)
|
||||
week, day = divmod(day, 7)
|
||||
week = int(week)
|
||||
day = int(day)
|
||||
hour = int(hour)
|
||||
minute = int(minute)
|
||||
second = int(second)
|
||||
embed.add_field(name="Uptime [WW:DD:HH:MM:SS]:", value=f"{week:02d}:{day:02d}:{hour:02d}:{minute:02d}:{second:02d}", inline=False)
|
||||
|
||||
print("Getting git describe data to display as version in info embed")
|
||||
bot_version = await run('git describe --tags')
|
||||
embed.add_field(name="Version:", value=f"{bot_version}", inline=False)
|
||||
embed.add_field(name="discord.py Version:", value=f"{discord.__version__}", inline=False)
|
||||
|
||||
# Gitlab link to source
|
||||
embed.add_field(name="Source Code:", value="[R0b3Bot on Gitlab](https://gitlab.rickelobe.com/Bots/r0b3BOT)", inline=False)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
bot.remove_command('help')
|
||||
#
|
||||
#
|
||||
#
|
||||
#@bot.command()
|
||||
#async def commands(ctx):
|
||||
# # Redirect to the 'help' command
|
||||
# await help(ctx)
|
||||
|
||||
@bot.command()
|
||||
async def help(ctx):
|
||||
embed = discord.Embed(title="R0b3BOT", description="List of commands are:", color=0xeee657)
|
||||
embed.add_field(name="$printstat", value="Uploads a snapshot of Rich's 3D printer and current stats", inline=False)
|
||||
embed.add_field(name="$explain", value="Displays Dalek EXPLAIN gif", inline=False)
|
||||
embed.add_field(name="$holyshit", value="Displays Marty McFly HOLY SHIT gif", inline=False)
|
||||
embed.add_field(name="$greetings", value="Gives a nice greet message", inline=False)
|
||||
embed.add_field(name="$cat", value="Gives a cute cat gif to lighten up the mood.", inline=False)
|
||||
embed.add_field(name="$bitch @member", value="Plays 'monkey_bitch.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$boom @member", value="Plays 'BoomBitch.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$cowbell @member", value="Plays 'More_cowbell.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$oops @member", value="Plays 'Oops.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$promoted @member", value="Plays 'Promoted.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$trololo @member", value="Plays 'Trololo.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$leeroy @member", value="Plays 'leeroy.mp3' to the users voice channel, if no user is specified it will play in your own voice channel", inline=False)
|
||||
embed.add_field(name="$info", value="Gives a little info about the bot", inline=False)
|
||||
embed.add_field(name="$help", value="Gives this message", inline=False)
|
||||
|
||||
embed = discord.Embed(title='Core & Debug Commands:', description="Core commands for info and debug", color=0xeee657)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}info", value="Gives a little info about the bot", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}help", value="Gives this message", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}last_error", value="Will display the real error message the bot has last encountered for additional debugging info")
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}greetings", value="Gives a nice greeting message", inline=False)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
bot.run(DISCORD_AUTH_TOKEN)
|
||||
embed = discord.Embed(title="GIF Commands", description="Commands to call up a GIF image", color=0xeee657)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}explain", value="Displays Dalek EXPLAIN gif", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}holyshit", value="Displays Marty McFly HOLY SHIT gif", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}cat", value="Gives a cute cat gif to lighten up the mood.", inline=False)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
embed = discord.Embed(title="Sound Commands", description="To play a sound in a chat channel, use one of the following:", color=0xeee657)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}aww @member", value="Plays Rick & Morty clip 'Awww bitch'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}bitch @member", value="Plays clip of BigEric420 saying 'maybe you shouldn't be such a bitch.mp3'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}big @member", value="Plays 'Wiz Khalifa clip'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}boom @member", value="Plays 'Boom Bitch' sound clip", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}breee @member", value="Plays 'BREEEEE' clip from a WBTBWB song", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}cowbell @member", value="Plays 'SNL More Cowbell' sound clip", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}eia @member", value="Plays 'Everything is Awesome' song clip", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}heavy @member", value="Plays 'Poppy ooo heavy'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}iran @member", value="Plays Trump 'I Ran'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}leeroy @member", value="Plays Leeeerrroooyyy Jenkins clip", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}nice @member", value="Plays clip from WBTBWB - Nice!", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}oops @member", value="Plays 'Oops.mp3'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}promoted @member", value="Plays Battlefield Friends 'PROMOTED!' sound clip", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}trololo @member", value="Plays a clip of Trololo song", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}wow @member", value="Plays clip of Owen Wilson saying 'Woooow'", inline=False)
|
||||
embed.add_field(name=f"{BOT_COMMAND_PREFIX}yaw @member", value="Plays clip of Busta Ryhmes 'Yaw Yaw Yaw'", inline=False)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
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="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(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE)
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
print(f'[{cmd!r} exited with {proc.returncode}]')
|
||||
if stdout:
|
||||
#print(f'[stdout]\n{stdout.decode()}')
|
||||
return stdout.decode()
|
||||
if stderr:
|
||||
#print(f'[stderr]\n{stderr.decode()}')
|
||||
return stderr.decode()
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,18 @@
|
||||
# r0b3BOT Python dependencies
|
||||
# Install with: pip install -r requirements.txt
|
||||
|
||||
# Discord bot framework — [voice] pulls in PyNaCl for voice channel encryption
|
||||
discord.py[voice]>=2.6.4
|
||||
|
||||
# HTTP client — used for HomeAssistant REST API calls
|
||||
requests>=2.32.3
|
||||
|
||||
# Bambu Lab printer integration via local MQTT
|
||||
# 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user