diff --git a/pyproject.toml b/pyproject.toml index e810bfe..51ebb7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ version = "0.0.0" dependencies = [ "discord.py", "python-dotenv", - "requests" + "requests", + "pyserial" ] # dynamic = [] diff --git a/src/pytexbot/hardware.py b/src/pytexbot/hardware.py new file mode 100644 index 0000000..229cac2 --- /dev/null +++ b/src/pytexbot/hardware.py @@ -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() diff --git a/src/pytexbot/schedule_data.py b/src/pytexbot/schedule_data.py new file mode 100644 index 0000000..1e3c2d2 --- /dev/null +++ b/src/pytexbot/schedule_data.py @@ -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." + } + } +} diff --git a/src/pytexbot/schedule_logic.py b/src/pytexbot/schedule_logic.py new file mode 100644 index 0000000..f32159d --- /dev/null +++ b/src/pytexbot/schedule_logic.py @@ -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!"