diff --git a/.env.example b/.env.example index 2f21c9e..f33729f 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,14 @@ # Discord Bot Token DISCORD_TOKEN=your_discord_bot_token_here -# Discord Channel ID (메인 채널 — 세션 스레드가 여기에 생성됨) -DISCORD_CHANNEL_ID= +# Discord Guild (서버) ID — 봇이 채널을 생성할 서버 +DISCORD_GUILD_ID= # Antigravity Brain Path BRAIN_PATH=C:\Users\Certes\.gemini\antigravity\brain +# 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성 +ACTIVE_TIMEOUT_SECONDS=300 + # Watcher Settings DEBOUNCE_SECONDS=2 diff --git a/bot.py b/bot.py index e46c6af..6ef8fe6 100644 --- a/bot.py +++ b/bot.py @@ -1,16 +1,22 @@ """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. +Dynamic channel management: +- Scans brain/ for active sessions on startup +- Creates `gravity-{project_name}` channels per active session +- Archives channels when sessions become inactive +- Project name extracted from artifact content or short conversation ID """ import asyncio +import json import logging +import re +import time from datetime import datetime, timezone from pathlib import Path import discord -from discord.ext import commands +from discord.ext import commands, tasks from config import Config from parser import ( @@ -23,6 +29,84 @@ from watcher import BrainEvent, EventType logger = logging.getLogger(__name__) +def detect_project_name(conv_dir: Path) -> str: + """Extract a human-readable project name from conversation artifacts. + + Strategy: + 1. Check task.md first line for a title (# Title) + 2. Check implementation_plan.md first line + 3. Check any .metadata.json for summary keywords + 4. Fallback to short conversation ID + """ + short_id = conv_dir.name[:8] + + # Try task.md title + task_file = conv_dir / "task.md" + if task_file.exists(): + try: + first_lines = task_file.read_text(encoding="utf-8").splitlines()[:5] + for line in first_lines: + match = re.match(r'^#\s+(.+?)[\s—\-]+', line) + if match: + name = match.group(1).strip() + # Sanitize for Discord channel name + name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name) + name = re.sub(r'\s+', '-', name).lower()[:30] + if name: + return name + except (OSError, UnicodeDecodeError): + pass + + # Try implementation_plan.md title + plan_file = conv_dir / "implementation_plan.md" + if plan_file.exists(): + try: + first_lines = plan_file.read_text(encoding="utf-8").splitlines()[:5] + for line in first_lines: + match = re.match(r'^#\s+(.+?)[\s—\-]+', line) + if match: + name = match.group(1).strip() + name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name) + name = re.sub(r'\s+', '-', name).lower()[:30] + if name: + return name + except (OSError, UnicodeDecodeError): + pass + + # Try metadata summary + for meta_file in conv_dir.glob("*.metadata.json"): + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + summary = meta.get("summary", "") + # Extract first meaningful noun/phrase + words = summary.split()[:3] + if words: + name = "-".join(words).lower() + name = re.sub(r'[^a-z0-9가-힣\-]', '', name)[:30] + if name: + return name + except (OSError, json.JSONDecodeError): + pass + + return short_id + + +def is_session_active(conv_dir: Path) -> bool: + """Check if a session is active based on file modification time.""" + now = time.time() + threshold = Config.ACTIVE_TIMEOUT_SECONDS + + for f in conv_dir.iterdir(): + if f.is_file(): + try: + mtime = f.stat().st_mtime + if now - mtime < threshold: + return True + except OSError: + continue + return False + + class GravityBot(commands.Bot): """Discord bot for Antigravity session monitoring.""" @@ -33,59 +117,119 @@ class GravityBot(commands.Bot): super().__init__(command_prefix="!", intents=intents) self.event_queue = event_queue - # conversation_id -> channel_id - self.session_channels: dict[str, int] = {} + # conversation_id -> channel object + self.session_channels: dict[str, discord.TextChannel] = {} # 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 + # conversation_id -> project name + self.session_names: dict[str, str] = {} # Category for session channels self.session_category: discord.CategoryChannel | None = None + # Guild reference + self.guild: discord.Guild | None = None async def setup_hook(self): """Called after login, before processing events.""" self.loop.create_task(self._process_events()) + self.session_cleanup_loop.start() 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}") + # Get guild + 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 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") + # Find or create session 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 - # 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) + # Sync existing active sessions + await self._sync_active_sessions() + + async def _sync_active_sessions(self): + """Scan brain/ and create channels for currently active sessions.""" + brain_path = Config.BRAIN_PATH + if not brain_path.exists(): + return + + active_count = 0 + for entry in brain_path.iterdir(): + if entry.is_dir() and self._is_conversation_id(entry.name): + if is_session_active(entry): + project_name = detect_project_name(entry) + await self._ensure_channel(entry.name, project_name) + active_count += 1 + + logger.info(f"Synced {active_count} active sessions on startup") + + def _is_conversation_id(self, name: str) -> bool: + """Check if directory name looks like a UUID.""" + parts = name.split("-") + return len(parts) == 5 and all(len(p) >= 4 for p in parts) + + async def _ensure_channel( + self, conversation_id: str, project_name: str + ) -> discord.TextChannel: + """Get or create a Discord channel for a session.""" + # Check if channel already exists + if conversation_id in self.session_channels: + return self.session_channels[conversation_id] + + channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" + + # Check if channel already exists in category (from previous run) + if self.session_category: + for ch in self.session_category.text_channels: + if ch.topic and conversation_id in ch.topic: + self.session_channels[conversation_id] = ch + self.session_names[conversation_id] = project_name + logger.info(f"Reconnected to existing channel #{ch.name}") + return ch + + # Create new channel + try: + channel = await self.guild.create_text_channel( + name=channel_name, + category=self.session_category, + topic=f"Antigravity Session: {conversation_id}", + ) + self.session_channels[conversation_id] = channel + self.session_names[conversation_id] = project_name + logger.info(f"Created channel #{channel_name}") + + # Welcome embed + embed = discord.Embed( + title=f"🚀 {project_name}", + description=( + f"Antigravity 세션 연결됨\n" + f"Session: `{conversation_id}`" + ), + color=discord.Color.blue(), + timestamp=datetime.now(timezone.utc), + ) + await channel.send(embed=embed) + return channel + + except discord.errors.Forbidden: + logger.error(f"No permission to create channel: {channel_name}") + return None async def _process_events(self): - """Main event processing loop — consumes brain events.""" + """Main event processing loop.""" await self.wait_until_ready() while not self.is_closed(): @@ -100,66 +244,23 @@ class GravityBot(commands.Bot): logger.error(f"Error processing event: {e}", exc_info=True) async def _handle_event(self, event: BrainEvent): - """Route brain events to appropriate handlers.""" + """Route brain events to handlers.""" if event.event_type == EventType.SESSION_START: - await self._handle_session_start(event) + conv_dir = Config.BRAIN_PATH / event.conversation_id + project_name = detect_project_name(conv_dir) + await self._ensure_channel(event.conversation_id, project_name) + elif event.event_type in (EventType.FILE_CREATED, EventType.FILE_CHANGED): - await self._handle_file_event(event) + # Ensure channel exists + conv_dir = Config.BRAIN_PATH / event.conversation_id + project_name = detect_project_name(conv_dir) + channel = await self._ensure_channel(event.conversation_id, project_name) - 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) + if channel: + 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 @@ -175,9 +276,10 @@ class GravityBot(commands.Bot): else discord.Color.greyple(), timestamp=datetime.now(timezone.utc), ) - embed.set_footer(text=f"Session: {event.conversation_id[:8]}") + short_id = event.conversation_id[:8] + embed.set_footer(text=f"Session: {short_id}") - # Edit existing status message or send new one + # Edit existing or send new msg_id = self.session_status_messages.get(event.conversation_id) if msg_id: try: @@ -194,7 +296,6 @@ class GravityBot(commands.Bot): self, channel: discord.TextChannel, event: BrainEvent ): """Send artifact file content as Discord text messages.""" - # File type label labels = { "implementation_plan.md": "📐 구현 계획", "walkthrough.md": "📝 작업 결과 요약", @@ -202,62 +303,47 @@ class GravityBot(commands.Bot): 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) + @tasks.loop(minutes=5) + async def session_cleanup_loop(self): + """Periodically check for inactive sessions and archive their channels.""" + if not self.guild: + return - # 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) + to_remove = [] + for conv_id, channel in self.session_channels.items(): + conv_dir = Config.BRAIN_PATH / conv_id + if not conv_dir.exists() or not is_session_active(conv_dir): + to_remove.append(conv_id) + + for conv_id in to_remove: + channel = self.session_channels.pop(conv_id, None) + self.session_status_messages.pop(conv_id, None) + name = self.session_names.pop(conv_id, conv_id[:8]) - 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]}` 이 종료되었습니다.", + title="🔴 세션 비활성", + description=f"`{name}` 세션이 비활성 상태입니다.", 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]}") + await channel.edit(name=f"closed-{name[:20]}") + logger.info(f"Archived channel for {conv_id[:8]}") except discord.errors.Forbidden: - logger.warning(f"No permission to archive channel for {conversation_id}") + logger.warning(f"No permission to archive {conv_id[:8]}") + except Exception as e: + logger.warning(f"Failed to archive {conv_id[:8]}: {e}") + + @session_cleanup_loop.before_loop + async def before_cleanup(self): + await self.wait_until_ready() diff --git a/config.py b/config.py index a319ae0..f828266 100644 --- a/config.py +++ b/config.py @@ -13,7 +13,7 @@ class Config: # Discord DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "") - DISCORD_CHANNEL_ID: int = int(os.getenv("DISCORD_CHANNEL_ID", "0")) + DISCORD_GUILD_ID: int = int(os.getenv("DISCORD_GUILD_ID") or "0") # Antigravity Brain path BRAIN_PATH: Path = Path(os.getenv( @@ -21,6 +21,9 @@ class Config: os.path.expanduser("~/.gemini/antigravity/brain") )) + # Session activity detection + ACTIVE_TIMEOUT_SECONDS: int = int(os.getenv("ACTIVE_TIMEOUT_SECONDS", "300")) + # Watcher settings DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2")) @@ -35,14 +38,17 @@ class Config: DISCORD_MSG_LIMIT: int = 2000 DISCORD_EMBED_DESC_LIMIT: int = 4096 + # Channel naming + CHANNEL_PREFIX: str = "gravity" + @classmethod def validate(cls) -> list[str]: """Return list of configuration errors.""" errors = [] if not cls.DISCORD_TOKEN: errors.append("DISCORD_TOKEN is not set") - if not cls.DISCORD_CHANNEL_ID: - errors.append("DISCORD_CHANNEL_ID is not set") + if not cls.DISCORD_GUILD_ID: + errors.append("DISCORD_GUILD_ID is not set") if not cls.BRAIN_PATH.exists(): errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}") return errors