"""Discord bot β€” relays Antigravity brain events to Discord channels. Creates per-session text channels in Discord when new Antigravity sessions are detected, relays task progress and artifact content as text messages and embeds. """ import asyncio import logging from datetime import datetime, timezone from pathlib import Path import discord from discord.ext import commands from config import Config from parser import ( parse_task_progress, md_to_discord_text, format_task_embed_text, ) from watcher import BrainEvent, EventType logger = logging.getLogger(__name__) class GravityBot(commands.Bot): """Discord bot for Antigravity session monitoring.""" def __init__(self, event_queue: asyncio.Queue): intents = discord.Intents.default() intents.message_content = True intents.guilds = True super().__init__(command_prefix="!", intents=intents) self.event_queue = event_queue # conversation_id -> channel_id self.session_channels: dict[str, int] = {} # conversation_id -> status message id (to edit in-place) self.session_status_messages: dict[str, int] = {} # Main channel for creating session threads self.main_channel: discord.TextChannel | None = None # Category for session channels self.session_category: discord.CategoryChannel | None = None async def setup_hook(self): """Called after login, before processing events.""" self.loop.create_task(self._process_events()) logger.info("Bot setup complete, event processor started") async def on_ready(self): """Called when bot is ready.""" logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") # Find or create main channel if Config.DISCORD_CHANNEL_ID: self.main_channel = self.get_channel(Config.DISCORD_CHANNEL_ID) if self.main_channel: logger.info(f"Main channel: #{self.main_channel.name}") # Find or create session category guild = self.main_channel.guild category_name = "Antigravity Sessions" self.session_category = discord.utils.get( guild.categories, name=category_name ) if not self.session_category: try: self.session_category = await guild.create_category(category_name) logger.info(f"Created category: {category_name}") except discord.errors.Forbidden: logger.warning("No permission to create category, using main channel") # Send startup message embed = discord.Embed( title="πŸ›°οΈ Gravity Control 연결됨", description="Antigravity μ„Έμ…˜ λͺ¨λ‹ˆν„°λ§μ„ μ‹œμž‘ν•©λ‹ˆλ‹€.", color=discord.Color.green(), timestamp=datetime.now(timezone.utc), ) embed.add_field( name="Brain Path", value=f"`{Config.BRAIN_PATH}`", inline=False, ) await self.main_channel.send(embed=embed) async def _process_events(self): """Main event processing loop β€” consumes brain events.""" await self.wait_until_ready() while not self.is_closed(): try: event = await asyncio.wait_for( self.event_queue.get(), timeout=5.0 ) await self._handle_event(event) except asyncio.TimeoutError: continue except Exception as e: logger.error(f"Error processing event: {e}", exc_info=True) async def _handle_event(self, event: BrainEvent): """Route brain events to appropriate handlers.""" if event.event_type == EventType.SESSION_START: await self._handle_session_start(event) elif event.event_type in (EventType.FILE_CREATED, EventType.FILE_CHANGED): await self._handle_file_event(event) async def _handle_session_start(self, event: BrainEvent): """Create a new Discord channel for the session.""" conv_id = event.conversation_id short_id = conv_id[:8] if self.session_category: guild = self.session_category.guild channel_name = f"session-{short_id}" try: channel = await guild.create_text_channel( name=channel_name, category=self.session_category, topic=f"Antigravity Session: {conv_id}", ) self.session_channels[conv_id] = channel.id logger.info(f"Created channel #{channel_name} for session {conv_id}") # Send welcome embed embed = discord.Embed( title=f"πŸš€ μƒˆ Antigravity μ„Έμ…˜", description=f"Session ID: `{conv_id}`", color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) await channel.send(embed=embed) # Notify main channel if self.main_channel: await self.main_channel.send( f"πŸ“‘ μƒˆ μ„Έμ…˜ μ‹œμž‘: {channel.mention} (`{short_id}`)" ) except discord.errors.Forbidden: logger.warning(f"No permission to create channel for {conv_id}") # Fall back to main channel self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID else: # No category β€” use main channel self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID if self.main_channel: await self.main_channel.send( f"πŸ“‘ μƒˆ μ„Έμ…˜ 감지: `{short_id}`" ) async def _handle_file_event(self, event: BrainEvent): """Handle file creation/modification events.""" channel = await self._get_session_channel(event.conversation_id) if not channel: return if event.file_name == "task.md": await self._send_task_update(channel, event) else: await self._send_artifact_content(channel, event) async def _send_task_update( self, channel: discord.TextChannel, event: BrainEvent ): """Send task progress update as an embed.""" progress = parse_task_progress(event.content) embed = discord.Embed( title="πŸ“‹ Task μ§„ν–‰ ν˜„ν™©", description=format_task_embed_text(progress), color=discord.Color.gold() if progress.in_progress > 0 else discord.Color.green() if progress.done == progress.total else discord.Color.greyple(), timestamp=datetime.now(timezone.utc), ) embed.set_footer(text=f"Session: {event.conversation_id[:8]}") # Edit existing status message or send new one msg_id = self.session_status_messages.get(event.conversation_id) if msg_id: try: msg = await channel.fetch_message(msg_id) await msg.edit(embed=embed) return except (discord.NotFound, discord.HTTPException): pass msg = await channel.send(embed=embed) self.session_status_messages[event.conversation_id] = msg.id async def _send_artifact_content( self, channel: discord.TextChannel, event: BrainEvent ): """Send artifact file content as Discord text messages.""" # File type label labels = { "implementation_plan.md": "πŸ“ κ΅¬ν˜„ κ³„νš", "walkthrough.md": "πŸ“ μž‘μ—… κ²°κ³Ό μš”μ•½", } label = labels.get(event.file_name, f"πŸ“„ {event.file_name}") event_label = "생성" if event.event_type == EventType.FILE_CREATED else "μ—…λ°μ΄νŠΈ" # Header message await channel.send(f"**{label} ({event_label}됨)**") # Convert and send content chunks = md_to_discord_text(event.content) for chunk in chunks: if chunk.strip(): await channel.send(chunk) # Small delay to avoid rate limits await asyncio.sleep(0.5) async def _get_session_channel( self, conversation_id: str ) -> discord.TextChannel | None: """Get the Discord channel for a session.""" channel_id = self.session_channels.get(conversation_id) # If no channel mapped, check if this is a known session and create one if not channel_id: # Auto-create for sessions that started before the bot await self._handle_session_start(BrainEvent( event_type=EventType.SESSION_START, conversation_id=conversation_id, )) channel_id = self.session_channels.get(conversation_id) if channel_id: channel = self.get_channel(channel_id) if channel: return channel return self.main_channel async def cleanup_session(self, conversation_id: str): """Clean up when an Antigravity session ends.""" channel_id = self.session_channels.pop(conversation_id, None) self.session_status_messages.pop(conversation_id, None) if channel_id: channel = self.get_channel(channel_id) if channel and channel.id != Config.DISCORD_CHANNEL_ID: try: # Send closing message before archiving embed = discord.Embed( title="πŸ”΄ μ„Έμ…˜ μ’…λ£Œ", description=f"Session `{conversation_id[:8]}` 이 μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", color=discord.Color.red(), timestamp=datetime.now(timezone.utc), ) await channel.send(embed=embed) # Archive channel (move to bottom, read-only) await channel.edit( name=f"closed-{conversation_id[:8]}", sync_permissions=True, ) logger.info(f"Archived channel for session {conversation_id[:8]}") except discord.errors.Forbidden: logger.warning(f"No permission to archive channel for {conversation_id}")