"""Discord bot — relays Antigravity brain events to Discord channels. Single project channel design: - ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control) - ALL conversations route to this single channel - Uses guild.fetch_channels() API, NOT cached text_channels """ import asyncio import json import logging import time from datetime import datetime, timezone from pathlib import Path import discord from discord.ext import commands, tasks from config import Config from parser import ( parse_task_progress, md_to_discord_text, format_task_embed_text, ) from watcher import BrainEvent, EventType from bridge import BridgeProtocol, ApprovalRequest, UserResponse logger = logging.getLogger(__name__) # ─── Discord UI Components ────────────────────────────────────────── class ApprovalView(discord.ui.View): """Discord buttons for approving/rejecting Antigravity actions.""" def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest): super().__init__(timeout=300) self.bridge = bridge self.request = request self.responded = False @discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green) async def approve(self, interaction: discord.Interaction, button: discord.ui.Button): if self.responded: await interaction.response.send_message("이미 응답됨", ephemeral=True) return self.responded = True self.bridge.write_response(UserResponse( request_id=self.request.request_id, approved=True, )) embed = interaction.message.embeds[0] if interaction.message.embeds else None if embed: embed.color = discord.Color.green() embed.set_footer(text=f"✅ 승인됨 by {interaction.user.display_name}") await interaction.response.edit_message(embed=embed, view=None) @discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red) async def reject(self, interaction: discord.Interaction, button: discord.ui.Button): if self.responded: await interaction.response.send_message("이미 응답됨", ephemeral=True) return self.responded = True self.bridge.write_response(UserResponse( request_id=self.request.request_id, approved=False, )) embed = interaction.message.embeds[0] if interaction.message.embeds else None if embed: embed.color = discord.Color.red() embed.set_footer(text=f"❌ 거부됨 by {interaction.user.display_name}") await interaction.response.edit_message(embed=embed, view=None) async def on_timeout(self): if not self.responded: self.bridge.write_response(UserResponse( request_id=self.request.request_id, approved=False, )) # ─── Bot ───────────────────────────────────────────────────────────── class GravityBot(commands.Bot): """Discord bot for Antigravity session monitoring. Single-channel architecture: - ONE channel per project (ag-gravity_control) - self.project_channel is the singleton — trivially prevents duplication """ 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 self.project_channel: discord.TextChannel | None = None # THE channel self.session_status_messages: dict[str, int] = {} # conv_id → msg_id self._sent_approval_ids: set[str] = set() self._ready_event = asyncio.Event() self.bridge = BridgeProtocol() self.session_category: discord.CategoryChannel | None = None self.guild: discord.Guild | None = None @property def _channel_name(self) -> str: """The ONE channel name: ag-gravity_control (lowercase).""" return f"{Config.CHANNEL_PREFIX}-{Config.PROJECT_NAME}".lower() async def setup_hook(self): self.loop.create_task(self._process_events()) self.pending_approval_scanner.start() logger.info("Bot setup complete") async def on_ready(self): logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") self.guild = self.get_guild(Config.DISCORD_GUILD_ID) if not self.guild: logger.error(f"Guild {Config.DISCORD_GUILD_ID} not found!") return # Find or create category category_name = "Antigravity Sessions" self.session_category = discord.utils.get( self.guild.categories, name=category_name ) if not self.session_category: try: self.session_category = await self.guild.create_category(category_name) logger.info(f"Created category: {category_name}") except discord.errors.Forbidden: logger.error("No permission to create category!") return # Find the project channel + cleanup duplicates await self._init_project_channel() # Open the gate self._ready_event.set() logger.info("Ready gate opened — event processing enabled") # ─── Channel Init (ONE channel, guild.fetch_channels API) ──────── async def _init_project_channel(self): """Find or create the single project channel. Delete any duplicates. Uses guild.fetch_channels() — the REAL Discord API, not the cache. """ target_name = self._channel_name # Fetch ALL channels from Discord API (not cache) all_channels = await self.guild.fetch_channels() matches: list[discord.TextChannel] = [] for ch in all_channels: if (isinstance(ch, discord.TextChannel) and ch.category_id == self.session_category.id and ch.name == target_name): matches.append(ch) if matches: # Keep the first, delete the rest self.project_channel = matches[0] logger.info(f"Found project channel: #{target_name} (id={self.project_channel.id})") for dup in matches[1:]: try: await dup.delete(reason="Duplicate project channel cleanup") logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})") except (discord.Forbidden, discord.HTTPException) as e: logger.warning(f"Failed to delete duplicate: {e}") # Also delete any OLD-style channels with different names for ch in all_channels: if (isinstance(ch, discord.TextChannel) and ch.category_id == self.session_category.id and ch.name != target_name and ch.topic and "Antigravity Session:" in ch.topic): try: await ch.delete(reason="Old-style channel cleanup") logger.info(f"Deleted old channel: #{ch.name}") except (discord.Forbidden, discord.HTTPException) as e: logger.warning(f"Failed to delete old channel: {e}") else: logger.info(f"No existing project channel found. Will create on first event.") async def _get_project_channel(self) -> discord.TextChannel: """Get the project channel. Create if it doesn't exist yet. Thread-safe: only ONE channel will ever be created because self.project_channel acts as a singleton guard. """ if self.project_channel: return self.project_channel # Create the channel try: self.project_channel = await self.guild.create_text_channel( name=self._channel_name, category=self.session_category, topic=f"Gravity Control — Antigravity Bridge", ) logger.info(f"Created project channel: #{self._channel_name}") embed = discord.Embed( title=f"🚀 {Config.PROJECT_NAME}", description=( f"Antigravity Bridge 연결됨\n" f"모든 세션 이벤트가 이 채널로 전달됩니다." ), color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) await self.project_channel.send(embed=embed) except discord.errors.Forbidden: logger.error(f"No permission to create channel: {self._channel_name}") return self.project_channel # ─── Event Processing ───────────────────────────────────────────── async def _process_events(self): """Main event loop — ALL events go through here sequentially.""" await self.wait_until_ready() await self._ready_event.wait() logger.info("Event processor started (ready gate passed)") 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 the single project channel.""" if event.event_type == EventType.SESSION_START: # Just ensure channel exists, no message needed await self._get_project_channel() return channel = await self._get_project_channel() if not channel: return try: if event.file_name == "task.md": await self._send_task_update(channel, event) else: await self._send_artifact_update(channel, event) except discord.NotFound: self.project_channel = None # Channel was deleted, recreate next time logger.warning("Project channel was deleted, will recreate") # ─── Message Senders ───────────────────────────────────────────── async def _send_task_update( self, channel: discord.TextChannel, event: BrainEvent ): """Send/edit task progress embed (ONE message per conv_id, always edited).""" 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]}") # Try to edit existing message for this conversation 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_update( self, channel: discord.TextChannel, event: BrainEvent ): """Send artifact update as single compact embed (preview only).""" 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 "업데이트" # Preview: first 6 non-empty lines only lines = event.content.strip().splitlines() preview = "\n".join(l for l in lines[:6] if l.strip()) if len(lines) > 6: preview += f"\n... (+{len(lines) - 6} lines)" embed = discord.Embed( title=f"{label} ({event_label}됨)", description=preview[:1000], color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) embed.set_footer(text=f"Session: {event.conversation_id[:8]}") await channel.send(embed=embed) # ─── Approval Scanner ──────────────────────────────────────────── @tasks.loop(seconds=3) async def pending_approval_scanner(self): """Scan bridge/pending/ for new approval requests.""" try: requests = self.bridge.get_pending_requests() for req in requests: if req.request_id in self._sent_approval_ids: continue if req.discord_message_id != 0: continue channel = await self._get_project_channel() if channel: self._sent_approval_ids.add(req.request_id) await self._send_approval_request(channel, req) except Exception as e: logger.error(f"Error scanning approvals: {e}") @pending_approval_scanner.before_loop async def before_scanner(self): await self.wait_until_ready() async def _send_approval_request( self, channel: discord.TextChannel, request: ApprovalRequest ): embed = discord.Embed( title="⚠️ 승인 요청", description=( f"**명령어:**\n```\n{request.command[:1000]}\n```\n" f"{request.description[:500]}" ), color=discord.Color.orange(), timestamp=datetime.now(timezone.utc), ) embed.set_footer(text=f"ID: {request.request_id}") view = ApprovalView(self.bridge, request) msg = await channel.send(embed=embed, view=view) # Update pending file with discord message id pending_file = self.bridge.pending_dir / f"{request.request_id}.json" if pending_file.exists(): try: data = json.loads(pending_file.read_text(encoding="utf-8")) data["discord_message_id"] = msg.id pending_file.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) except (json.JSONDecodeError, OSError): pass logger.info(f"Sent approval request: {request.request_id[:8]}") # ─── Discord → Antigravity Text Relay ───────────────────────────── async def on_message(self, message: discord.Message): if message.author == self.user: return # Only respond in the project channel if not self.project_channel or message.channel.id != self.project_channel.id: await self.process_commands(message) return text = message.content.strip() # Special command: !auto on/off if text in ("!auto on", "!auto off"): self.bridge.write_command("__global__", text) enabled = text == "!auto on" emoji = "🟢" if enabled else "🔴" mode = "자동 승인" if enabled else "수동 승인" embed = discord.Embed( title=f"{emoji} {mode} 모드", description=f"Antigravity IDE 설정이 변경됩니다.\n" f"`chat.tools.autoApprove = {enabled}`\n" f"`chat.agent.autoApprove = {enabled}`", color=discord.Color.green() if enabled else discord.Color.red(), ) await message.channel.send(embed=embed) return # General text relay (broadcast to most recent session or global) if text: self.bridge.write_command("__global__", text) await message.add_reaction("📨") await self.process_commands(message)