Files

881 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/python3
import discord
from discord.ext import commands, tasks
from datetime import datetime
import urllib.request
import random
import os
import octoapi
import time
import configparser
import json
import requests
import asyncio
from bambu_connect import BambuClient
# 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'
try:
config.read(configFilePath)
except:
print("Error loading config! Please check config file 'bot-config.conf'")
# 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')
hassURL = HASS_URL + "/api/states/" + HASS_LIGHT
hassHEADERS = {
'Authorization': f"Bearer {HASS_TOKEN}",
'content-type': 'application/json',
}
OCTOPRINT_IP_ADDRESS = config.get('bot-config', 'octoprint_ip_address')
# 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("=== 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(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!")
@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})")
await ctx.send(f"This command is ratelimited per user, please try again in {seconds}s")
elif isinstance(error, commands.CommandInvokeError):
print(" - ERROR: encountered 'CommandInvokeError'")
print(f" - Dumping error: {error}")
await ctx.send("Error, unable to complete your request.")
else:
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
#
@bot.command()
async def cat(ctx):
await ctx.send("https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif")
@bot.command()
async def explain(ctx):
await ctx.send("https://media.giphy.com/media/A8NNZlVuA1LoY/giphy.gif")
@bot.command()
async def holyshit(ctx):
await ctx.send("https://media.giphy.com/media/QcGTswMKyBNgA/giphy.gif")
###
#
# 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"):
datestring = datetime.now()
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
if ctx.message.channel.is_nsfw():
await play_sound(ctx, member, "./sounds/monkey_bitch.mp3", "$bitch")
else:
print(f"[{datestring}]: {ctx.message.author.display_name} called $bitch, but is not in a NSFW channel")
await ctx.send("This command is too explicit for you!")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def cowbell(ctx, member : discord.Member="NONE"):
await play_sound(ctx, member, "./sounds/More_cowbell.mp3", "$cowbell")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def boom(ctx, member : discord.Member="NONE"):
datestring = datetime.now()
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
if ctx.message.channel.is_nsfw():
await play_sound(ctx, member, "./sounds/BoomBitch.mp3", "$boom")
else:
print(f"[{datestring}]: {ctx.message.author.display_name} called $boom, but is not in a NSFW channel")
await ctx.send("This command is too explicit for you!")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def oops(ctx, member : discord.Member="NONE"):
await play_sound(ctx, member, "./sounds/Oops.mp3", "$oops")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def trololo(ctx, member : discord.Member="NONE"):
await play_sound(ctx, member, "./sounds/Trololo.mp3", "$trololo")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def leeroy(ctx, member : discord.Member="NONE"):
await play_sound(ctx, member, "./sounds/leeroy.mp3", "$leeroy")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
async def promoted(ctx, member : discord.Member="NONE"):
await play_sound(ctx, member, "./sounds/Promoted.mp3", "$promoted")
@bot.command()
@commands.cooldown(rate=1, per=10.0, type=commands.BucketType.user)
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"):
datestring = datetime.now()
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
if ctx.message.channel.is_nsfw():
soundfiles = ["./sounds/leeroy.mp3","./sounds/monkey_bitch.mp3","./sounds/More_cowbell.mp3","./sounds/BoomBitch.mp3","./sounds/Promoted.mp3","./sounds/Trololo.mp3","./sounds/Oops.mp3"]
await play_sound(ctx, member, random.choice(soundfiles), "$rs")
else:
print(f"[{datestring}]: {ctx.message.author.display_name} called $rs, but is not in a NSFW channel")
await ctx.send("This command is too explicit for you!")
async def play_sound(ctx, member : discord.Member, soundFile, command):
datestring = datetime.now()
datestring = datestring.strftime("%m/%d/%Y-%H:%M:%S")
print(f"[{datestring}]: {ctx.message.author.display_name} called {command}")
# Need to check whether or not a user has been specified. If one has, we need to use the channel
# they are currently connected to instead the channel that the command issuer is in.
if member != "NONE":
try:
channel = member.voice.channel
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
print(f" - User specified: {member.display_name}")
print(f" - Destination channel: {channel}")
if member == "NONE":
try:
channel = ctx.message.author.voice.channel
except AttributeError:
await ctx.send("You are not connected to a voice channel.")
print("User is not connected to a voice channel")
return
voice: discord.VoiceClient = discord.utils.get(bot.voice_clients, guild=ctx.guild)
# Different join actions are needed depending on whether or not the bot is already in a
# voice channel. If already in a channel, wait until disconnected, then join the requested
# channel. If not already in a channel, then join requested channel immediately
if voice and voice.is_connected():
print(f" - Moving to channel '{channel}''")
await ctx.send(f"Moving to '{channel}' for just a moment")
await voice.move_to(channel)
else:
print(f" - Joining Channel '{channel}''")
await ctx.send(f"Joining '{channel}' for just a moment")
# 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)
while voice.is_playing():
await asyncio.sleep(1)
print(f" - Disconnecting from '{channel}'")
await voice.disconnect()
###
#
# OctoPrint Commands
#
@bot.command()
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 $octostat")
# We haven't yet changed the state of the light, setting this to False
turned_on_light = False
# Try to get the status of the 3D printer light
print(" - Getting Printer Light Status")
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_resp.text)
# Making sure that the API request succeeded. If it has, there will be a state attribute added to
# work_lights.
try:
work_lights["state"]
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]")
# Create empty dict w/ state of unknown
work_lights = {'state': "unknown"}
# If the light is off, let's turn it on before we take a picture
if work_lights["state"] == 'off':
print(" - Lights are off, turning on for image capture")
turned_on_light = True
lightMessages = ["Woah, it's pretty dark in R0b3's Basement... Give me a couple seconds to turn on a light.", \
"Turning on a light, give me a moment...", \
"I can't see a thing, let me get a light...", \
"Where's that light switch?...", \
"Uh, the lights are off. Give me a sec...", \
"Replacing the light bulb, just a moment..."]
await ctx.send(f"{random.choice(lightMessages)}")
try:
# 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}"}}'
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
await asyncio.sleep(3)
# Randomly generate a filename to save the image to
file_name = random.randrange(1,10000)
full_file_name = str(file_name) + '.jpg'
# Get an image from OctoPrint and save it
print(" - Getting image from Webcam")
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:
print(" - Turning Light back Off")
try:
# 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}"}}'
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}")
print(" ! Unable to turn off light")
pass
# Send the image to the chat channel
print(" - Uploading image to chat channel")
file = discord.File(full_file_name, filename=full_file_name)
await ctx.send("3D Printer Snapshot:", file=file)
# Remove the Image File now that it is no longer needed
print(" - Deleting temporary image file")
os.remove(full_file_name)
print(" - Connecting to OctoPrint API")
# Testing OctoPrint API Status. If printer is off, I won't receive JSON data.
try:
operational = await loop.run_in_executor(None, octoapi.get_printer_dict)
except Exception:
# Printer is Offline
operational = False
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 = 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(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 = 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 = 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 (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 (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 (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 (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)
# 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)
embed.add_field(name="Percent Complete: ", value=str(print_completion) + "%")
embed.add_field(name="Time Elapsed: ", value=time_elapsed)
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)
## 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")
embed = discord.Embed(title="Printer is Offline")
print(" - Notifying chat channel")
await ctx.send(embed=embed)
#
# 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(" - 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(" - 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()
async def info(ctx):
embed = discord.Embed(title="R0b3BOT", description="Derpy Derp of a Bot :P", color=0xeee657)
# give info about you here
embed.add_field(name="Author", value="R0b3")
# Shows the number of servers the bot is member of.
embed.add_field(name="Server count", value=f"{len(bot.guilds)}")
# 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.command()
#async def commands(ctx):
# # Redirect to the 'help' command
# await help(ctx)
@bot.command()
async def help(ctx):
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)
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())