Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ version = "0.0.0"
dependencies = [
"discord.py",
"python-dotenv",
"requests"
"requests",
"pyserial"
]
# dynamic = []

Expand Down
171 changes: 171 additions & 0 deletions src/pytexbot/hardware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import os
import asyncio
import discord
from discord import app_commands
from discord.ext import tasks
from dotenv import load_dotenv
import serial
import time
from datetime import datetime

from pytexbot.schedule_logic import get_next_session
from pytexbot.schedule_data import CONFERENCE_DATA

# 1. Load configuration from .env file
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')
SERIAL_PORT = os.getenv('SERIAL_PORT', 'COM5')
ALLOWED_CHANNEL_ID = os.getenv('ALLOWED_CHANNEL_ID') # NEW: Re-load from .env
ANNOUNCEMENT_CHANNEL_ID = os.getenv('ANNOUNCEMENT_CHANNEL_ID')

# 2. Setup Serial Connection Utility
__ARDUINO = None

def get_arduino():
global __ARDUINO
if __ARDUINO is None:
try:
# Only init when first needed
__ARDUINO = serial.Serial(SERIAL_PORT, 9600, timeout=1)
time.sleep(2) # Allow Arduino to reset
print(f"✅ Connected to Arduino on {SERIAL_PORT}!")
except Exception as e:
print(f"❌ Hardware Error on {SERIAL_PORT}: {e}")
return None
return __ARDUINO

# 3. Discord Bot Logic
class HardwareBot(discord.Client):
def __init__(self):
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)

async def setup_hook(self):
if not session_announcer.is_running():
session_announcer.start()
await self.tree.sync()

client = HardwareBot()

@client.tree.command(name="next", description="Find out which talk is coming up next!")
async def next_talk(interaction: discord.Interaction):
talk, time_or_msg = get_next_session()

if not talk:
await interaction.response.send_message(time_or_msg, ephemeral=True)
return

embed = discord.Embed(
title=f"📅 Next Up: {time_or_msg}",
description=f"**{talk['title']}**",
color=0x3498db # PyTexas Blue
)
embed.add_field(name="Speaker", value=talk['speaker'], inline=True)
embed.add_field(name="About this talk", value=talk['desc'], inline=False)
embed.set_footer(text="PyTexas 2026 Virtual Assistant | /next for upcoming")

await interaction.response.send_message(embed=embed)

# --- BACKGROUND TASKS ---

@tasks.loop(seconds=60)
async def session_announcer():
now = datetime.now().strftime("%H:%M")
today = datetime.now().strftime("%Y-%m-%d")

if today in CONFERENCE_DATA and now in CONFERENCE_DATA[today]:
talk = CONFERENCE_DATA[today][now]

# Use stored ID or try to find a channel named 'announcements'
channel_id = ANNOUNCEMENT_CHANNEL_ID
channel = None
if channel_id:
channel = client.get_channel(int(channel_id))

if not channel:
for guild in client.guilds:
channel = discord.utils.get(guild.text_channels, name='announcements')
if channel: break

if channel:
embed = discord.Embed(
title="🔔 Session Starting Now!",
description=f"**{talk['title']}**",
color=0xffd700 # Gold
)
embed.add_field(name="Speaker", value=talk['speaker'], inline=True)
embed.add_field(name="Description", value=talk['desc'], inline=False)

await channel.send(content="@everyone", embed=embed)

# Optional: Wave the hardware when a session starts!
arduino = get_arduino()
if arduino:
arduino.write(b'W')

# --- COMMANDS WITH COOLDOWNS ---

@client.tree.command(name="wave", description="Send a long wave blink")
@app_commands.checks.cooldown(1, 10.0, key=lambda i: i.user.id) # 1 use every 10s
async def wave(interaction: discord.Interaction):
if ALLOWED_CHANNEL_ID and str(interaction.channel.id) != str(ALLOWED_CHANNEL_ID):
await interaction.response.send_message("❌ This command is not allowed in this channel.", ephemeral=True)
return

arduino = get_arduino()
if arduino:
arduino.write(b'W')
await interaction.response.send_message(f"👋 {interaction.user.display_name} sent a wave!")
else:
await interaction.response.send_message("⚠️ Hardware not connected.", ephemeral=True)

@client.tree.command(name="love", description="Send fast blinks of love")
@app_commands.checks.cooldown(1, 10.0, key=lambda i: i.user.id) # 1 use every 10s
async def love(interaction: discord.Interaction):
if ALLOWED_CHANNEL_ID and str(interaction.channel.id) != str(ALLOWED_CHANNEL_ID):
await interaction.response.send_message("❌ This command is not allowed in this channel.", ephemeral=True)
return

arduino = get_arduino()
if arduino:
arduino.write(b'L')
await interaction.response.send_message(f"❤️ {interaction.user.display_name} is sending love!")
else:
await interaction.response.send_message("⚠️ Hardware not connected.", ephemeral=True)

@client.tree.command(name="question", description="Send a pulse for a question")
@app_commands.checks.cooldown(1, 10.0, key=lambda i: i.user.id)
async def question(interaction: discord.Interaction):
if ALLOWED_CHANNEL_ID and str(interaction.channel.id) != str(ALLOWED_CHANNEL_ID):
await interaction.response.send_message("❌ This command is not allowed in this channel.", ephemeral=True)
return

arduino = get_arduino()
if arduino:
arduino.write(b'Q')
await interaction.response.send_message(f"❓ {interaction.user.display_name} has a question!")
else:
await interaction.response.send_message("⚠️ Hardware not connected.", ephemeral=True)

# --- GLOBAL ERROR HANDLER ---
# This one function handles the "Rate Limit" message for ALL commands above
@client.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.CommandOnCooldown):
await interaction.response.send_message(
f"⏳ Slow down, {interaction.user.display_name}! Try again in {error.retry_after:.1f}s.",
ephemeral=True # Only the spammer sees this
)
else:
# Log other errors so you can see them in your terminal
print(f"Command Error: {error}")

def main():
# 4. Run the Bot using the hidden Token
if TOKEN:
client.run(TOKEN)
else:
print("❌ Error: No token found. Did you create the .env file?")

if __name__ == "__main__":
main()
129 changes: 129 additions & 0 deletions src/pytexbot/schedule_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Full Schedule with Descriptions for PyTexas 2026
CONFERENCE_DATA = {
"2026-04-17": { # Friday Tutorials
"09:00": {
"title": "Import is Important: The Secret Life of Python Modules and Packages",
"speaker": "Heather Crawford",
"desc": "Learn how Python manages modules, how to import from them, and how to debug common issues with imports and modules."
},
"12:00": {
"title": "Lunch Break",
"speaker": "",
"desc": "Grab some food, meet fellow Pythonistas, and take a breather!"
},
"13:30": { # 01:30 PM
"title": "Becoming a Better Python Developer with AI",
"speaker": "Bernát Gábor",
"desc": "Hands-on workshop on mental models and workflows for using AI assistants effectively while maintaining code quality."
},
"16:30": { # 04:30 PM
"title": "Build Agentic AI with Semantic Kernel and Graph RAG on PostgreSQL (Sponsored)",
"speaker": "Microsoft",
"desc": "Build an agent-driven Retrieval-Augmented Generation (RAG) application using Azure Database for PostgreSQL and Semantic Kernel."
}
},
"2026-04-18": { # Saturday
"09:20": {
"title": "Keynote",
"speaker": "Dawn Wages",
"desc": "Director of Community at Anaconda. Focuses on inclusive practices and sustainable growth in open source."
},
"10:20": {
"title": "Python as Your DSL",
"speaker": "Moshe Zadka",
"desc": "Exploring the art of designing Python Domain-Specific Languages that feel natural."
},
"10:50": {
"title": "I Built an AI Running Coach",
"speaker": "Adam Gordon Bell",
"desc": "How to reverse-engineer APIs and use async patterns to give LLMs 'memory'."
},
"11:30": {
"title": "Using MCP to Build Safe AI Systems",
"speaker": "Maria Silvia Mielniczuk",
"desc": "Using the Model Context Protocol to design safe, auditable tool interfaces."
},
"12:00": {
"title": "The Hidden Power of Soft Skills",
"speaker": "Sumaiya Nalukwago",
"desc": "Why mastering people skills is the ultimate next level for any developer."
},
"12:45": {
"title": "Lunch Break",
"speaker": "",
"desc": "Enjoy a great lunch and network!"
},
"14:15": { # 02:15 PM
"title": "Why Installing Packages is a Security Risk",
"speaker": "Christopher Ariza",
"desc": "Introduction to installation-time threats and practical defenses for production environments."
},
"14:45": { # 02:45 PM
"title": "Behind the Magic: Descriptor Protocol",
"speaker": "Scott Irwin",
"desc": "Unlocking how Python decides what happens when you access object attributes."
},
"15:15": { # 03:15 PM
"title": "Data Engineer's Survival Guide",
"speaker": "Indrasena Manga",
"desc": "Writing resilient pipelines using Pydantic, pytest, and defensive design."
},
"16:00": { # 04:00 PM
"title": "Failed Experiments in Vibe Coding",
"speaker": "Al Sweigart",
"desc": "A hilarious exploration of non-developers using AI to 'vibe code' software."
},
"16:30": { # 04:30 PM
"title": "Building a Full-Stack FastAPI App (Sponsored)",
"speaker": "Microsoft",
"desc": "Hands-on lab using FastAPI and DocumentDB via Docker containers."
}
},
"2026-04-19": { # Sunday
"09:20": {
"title": "Keynote",
"speaker": "Hynek Schlawack",
"desc": "Lead infrastructure engineer discussing networks, security, and robust software."
},
"10:20": {
"title": "The Bakery: PEP810",
"speaker": "Jacob Coffee",
"desc": "How explicit lazy imports can dramatically improve application startup times."
},
"10:50": {
"title": "Python in the Browser",
"speaker": "Kassandra Keeton",
"desc": "Building interactive documentation with MkDocs and JupyterLite via WebAssembly."
},
"11:30": {
"title": "Are API Tests Overrated?",
"speaker": "Pandy Knight",
"desc": "Challenging conventional testing strategies in favor of smarter alternatives."
},
"12:15": {
"title": "Lunch Break",
"speaker": "",
"desc": "Enjoy a great lunch and network!"
},
"14:00": { # 02:00 PM
"title": "Introducing Meow'py",
"speaker": "Sophia Solomon",
"desc": "Applying observability (OpenTelemetry) to the Internet of Living Things (a virtual cat)."
},
"14:30": { # 02:30 PM
"title": "Tying Up Loose Threads (No-GIL)",
"speaker": "Charlie Lin",
"desc": "Making your project ready for the free-threaded interpreter to yield extra performance."
},
"15:10": { # 03:10 PM
"title": "Upgrading Python CLIs",
"speaker": "Avik Basu",
"desc": "Moving from basic scripts to professional TUIs with Textual, Rich, and Typer."
},
"15:40": { # 03:40 PM
"title": "Lint Fast, Type Hard",
"speaker": "Miguel Vargas",
"desc": "Using modern, ultra-fast tooling like Ruff and Pyrefly to improve code quality."
}
}
}
37 changes: 37 additions & 0 deletions src/pytexbot/schedule_logic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from datetime import datetime, timedelta
import zoneinfo
from pytexbot.schedule_data import CONFERENCE_DATA

def get_next_session():
# PyTexas is in Dallas (Central Time)
# Use ZoneInfo for reliable timezone handling
try:
tz = zoneinfo.ZoneInfo("America/Chicago")
except Exception:
# Fallback if zoneinfo is not properly set up on Windows
# Central Time is usually UTC-6 (Standard) or UTC-5 (Daylight)
# For simplicity in this script, we'll try to use local time if aligned
# but the ideal is ZoneInfo.
tz = None

now = datetime.now(tz)

# Format for matching
today_str = now.strftime("%Y-%m-%d")
current_time_str = now.strftime("%H:%M")

if today_str not in CONFERENCE_DATA:
# Check if conference is in the future
first_day = sorted(CONFERENCE_DATA.keys())[0]
if today_str < first_day:
return None, f"The conference hasn't started yet! First session is on {first_day}."
return None, "There are no sessions scheduled for today!"

todays_talks = CONFERENCE_DATA[today_str]
sorted_times = sorted(todays_talks.keys())

for start_time in sorted_times:
if start_time > current_time_str:
return todays_talks[start_time], start_time

return None, "All sessions for today have concluded. See you tomorrow!"