feat(bridge): 동적 채널 관리 - gravity-{프로젝트명} 채널 자동 생성/아카이브

This commit is contained in:
2026-03-07 10:36:06 +09:00
parent ea5001f243
commit ba8454c2e1
3 changed files with 239 additions and 144 deletions

View File

@@ -1,11 +1,14 @@
# Discord Bot Token # Discord Bot Token
DISCORD_TOKEN=your_discord_bot_token_here DISCORD_TOKEN=your_discord_bot_token_here
# Discord Channel ID (메인 채널 — 세션 스레드가 여기에 생성됨) # Discord Guild (서버) ID — 봇이 채널을 생성할 서버
DISCORD_CHANNEL_ID= DISCORD_GUILD_ID=
# Antigravity Brain Path # Antigravity Brain Path
BRAIN_PATH=C:\Users\Certes\.gemini\antigravity\brain BRAIN_PATH=C:\Users\Certes\.gemini\antigravity\brain
# 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성
ACTIVE_TIMEOUT_SECONDS=300
# Watcher Settings # Watcher Settings
DEBOUNCE_SECONDS=2 DEBOUNCE_SECONDS=2

334
bot.py
View File

@@ -1,16 +1,22 @@
"""Discord bot — relays Antigravity brain events to Discord channels. """Discord bot — relays Antigravity brain events to Discord channels.
Creates per-session text channels in Discord when new Antigravity sessions are Dynamic channel management:
detected, relays task progress and artifact content as text messages and embeds. - 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 asyncio
import json
import logging import logging
import re
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import discord import discord
from discord.ext import commands from discord.ext import commands, tasks
from config import Config from config import Config
from parser import ( from parser import (
@@ -23,6 +29,84 @@ from watcher import BrainEvent, EventType
logger = logging.getLogger(__name__) 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): class GravityBot(commands.Bot):
"""Discord bot for Antigravity session monitoring.""" """Discord bot for Antigravity session monitoring."""
@@ -33,59 +117,119 @@ class GravityBot(commands.Bot):
super().__init__(command_prefix="!", intents=intents) super().__init__(command_prefix="!", intents=intents)
self.event_queue = event_queue self.event_queue = event_queue
# conversation_id -> channel_id # conversation_id -> channel object
self.session_channels: dict[str, int] = {} self.session_channels: dict[str, discord.TextChannel] = {}
# conversation_id -> status message id (to edit in-place) # conversation_id -> status message id (to edit in-place)
self.session_status_messages: dict[str, int] = {} self.session_status_messages: dict[str, int] = {}
# Main channel for creating session threads # conversation_id -> project name
self.main_channel: discord.TextChannel | None = None self.session_names: dict[str, str] = {}
# Category for session channels # Category for session channels
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
# Guild reference
self.guild: discord.Guild | None = None
async def setup_hook(self): async def setup_hook(self):
"""Called after login, before processing events.""" """Called after login, before processing events."""
self.loop.create_task(self._process_events()) self.loop.create_task(self._process_events())
self.session_cleanup_loop.start()
logger.info("Bot setup complete, event processor started") logger.info("Bot setup complete, event processor started")
async def on_ready(self): async def on_ready(self):
"""Called when bot is ready.""" """Called when bot is ready."""
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
# Find or create main channel # Get guild
if Config.DISCORD_CHANNEL_ID: self.guild = self.get_guild(Config.DISCORD_GUILD_ID)
self.main_channel = self.get_channel(Config.DISCORD_CHANNEL_ID) if not self.guild:
if self.main_channel: logger.error(f"Guild {Config.DISCORD_GUILD_ID} not found!")
logger.info(f"Main channel: #{self.main_channel.name}") return
# Find or create session category # Find or create session category
guild = self.main_channel.guild
category_name = "Antigravity Sessions" category_name = "Antigravity Sessions"
self.session_category = discord.utils.get( self.session_category = discord.utils.get(
guild.categories, name=category_name self.guild.categories, name=category_name
) )
if not self.session_category: if not self.session_category:
try: try:
self.session_category = await guild.create_category(category_name) self.session_category = await self.guild.create_category(category_name)
logger.info(f"Created category: {category_name}") logger.info(f"Created category: {category_name}")
except discord.errors.Forbidden: except discord.errors.Forbidden:
logger.warning("No permission to create category, using main channel") logger.error("No permission to create category!")
return
# Send startup message # 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( embed = discord.Embed(
title="🛰️ Gravity Control 연결됨", title=f"🚀 {project_name}",
description="Antigravity 세션 모니터링을 시작합니다.", description=(
color=discord.Color.green(), f"Antigravity 세션 연결됨\n"
f"Session: `{conversation_id}`"
),
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
embed.add_field( await channel.send(embed=embed)
name="Brain Path", return channel
value=f"`{Config.BRAIN_PATH}`",
inline=False, except discord.errors.Forbidden:
) logger.error(f"No permission to create channel: {channel_name}")
await self.main_channel.send(embed=embed) return None
async def _process_events(self): async def _process_events(self):
"""Main event processing loop — consumes brain events.""" """Main event processing loop."""
await self.wait_until_ready() await self.wait_until_ready()
while not self.is_closed(): while not self.is_closed():
@@ -100,62 +244,19 @@ class GravityBot(commands.Bot):
logger.error(f"Error processing event: {e}", exc_info=True) logger.error(f"Error processing event: {e}", exc_info=True)
async def _handle_event(self, event: BrainEvent): 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: 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): 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
async def _handle_session_start(self, event: BrainEvent): project_name = detect_project_name(conv_dir)
"""Create a new Discord channel for the session.""" channel = await self._ensure_channel(event.conversation_id, project_name)
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 channel:
if event.file_name == "task.md": if event.file_name == "task.md":
await self._send_task_update(channel, event) await self._send_task_update(channel, event)
else: else:
@@ -175,9 +276,10 @@ class GravityBot(commands.Bot):
else discord.Color.greyple(), else discord.Color.greyple(),
timestamp=datetime.now(timezone.utc), 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) msg_id = self.session_status_messages.get(event.conversation_id)
if msg_id: if msg_id:
try: try:
@@ -194,7 +296,6 @@ class GravityBot(commands.Bot):
self, channel: discord.TextChannel, event: BrainEvent self, channel: discord.TextChannel, event: BrainEvent
): ):
"""Send artifact file content as Discord text messages.""" """Send artifact file content as Discord text messages."""
# File type label
labels = { labels = {
"implementation_plan.md": "📐 구현 계획", "implementation_plan.md": "📐 구현 계획",
"walkthrough.md": "📝 작업 결과 요약", "walkthrough.md": "📝 작업 결과 요약",
@@ -202,62 +303,47 @@ class GravityBot(commands.Bot):
label = labels.get(event.file_name, f"📄 {event.file_name}") label = labels.get(event.file_name, f"📄 {event.file_name}")
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트" event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
# Header message
await channel.send(f"**{label} ({event_label}됨)**") await channel.send(f"**{label} ({event_label}됨)**")
# Convert and send content
chunks = md_to_discord_text(event.content) chunks = md_to_discord_text(event.content)
for chunk in chunks: for chunk in chunks:
if chunk.strip(): if chunk.strip():
await channel.send(chunk) await channel.send(chunk)
# Small delay to avoid rate limits
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
async def _get_session_channel( @tasks.loop(minutes=5)
self, conversation_id: str async def session_cleanup_loop(self):
) -> discord.TextChannel | None: """Periodically check for inactive sessions and archive their channels."""
"""Get the Discord channel for a session.""" if not self.guild:
channel_id = self.session_channels.get(conversation_id) return
# If no channel mapped, check if this is a known session and create one to_remove = []
if not channel_id: for conv_id, channel in self.session_channels.items():
# Auto-create for sessions that started before the bot conv_dir = Config.BRAIN_PATH / conv_id
await self._handle_session_start(BrainEvent( if not conv_dir.exists() or not is_session_active(conv_dir):
event_type=EventType.SESSION_START, to_remove.append(conv_id)
conversation_id=conversation_id,
)) for conv_id in to_remove:
channel_id = self.session_channels.get(conversation_id) 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: 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: try:
# Send closing message before archiving
embed = discord.Embed( embed = discord.Embed(
title="🔴 세션 종료", title="🔴 세션 비활성",
description=f"Session `{conversation_id[:8]}` 이 종료되었습니다.", description=f"`{name}` 세션이 비활성 상태입니다.",
color=discord.Color.red(), color=discord.Color.red(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
await channel.send(embed=embed) await channel.send(embed=embed)
await channel.edit(name=f"closed-{name[:20]}")
# Archive channel (move to bottom, read-only) logger.info(f"Archived channel for {conv_id[:8]}")
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: 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()

View File

@@ -13,7 +13,7 @@ class Config:
# Discord # Discord
DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "") 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 # Antigravity Brain path
BRAIN_PATH: Path = Path(os.getenv( BRAIN_PATH: Path = Path(os.getenv(
@@ -21,6 +21,9 @@ class Config:
os.path.expanduser("~/.gemini/antigravity/brain") os.path.expanduser("~/.gemini/antigravity/brain")
)) ))
# Session activity detection
ACTIVE_TIMEOUT_SECONDS: int = int(os.getenv("ACTIVE_TIMEOUT_SECONDS", "300"))
# Watcher settings # Watcher settings
DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2")) DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2"))
@@ -35,14 +38,17 @@ class Config:
DISCORD_MSG_LIMIT: int = 2000 DISCORD_MSG_LIMIT: int = 2000
DISCORD_EMBED_DESC_LIMIT: int = 4096 DISCORD_EMBED_DESC_LIMIT: int = 4096
# Channel naming
CHANNEL_PREFIX: str = "gravity"
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]:
"""Return list of configuration errors.""" """Return list of configuration errors."""
errors = [] errors = []
if not cls.DISCORD_TOKEN: if not cls.DISCORD_TOKEN:
errors.append("DISCORD_TOKEN is not set") errors.append("DISCORD_TOKEN is not set")
if not cls.DISCORD_CHANNEL_ID: if not cls.DISCORD_GUILD_ID:
errors.append("DISCORD_CHANNEL_ID is not set") errors.append("DISCORD_GUILD_ID is not set")
if not cls.BRAIN_PATH.exists(): if not cls.BRAIN_PATH.exists():
errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}") errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}")
return errors return errors