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_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

334
bot.py
View File

@@ -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
self.guild.categories, name=category_name
)
if not self.session_category:
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}")
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(
title="🛰️ Gravity Control 연결됨",
description="Antigravity 세션 모니터링을 시작합니다.",
color=discord.Color.green(),
title=f"🚀 {project_name}",
description=(
f"Antigravity 세션 연결됨\n"
f"Session: `{conversation_id}`"
),
color=discord.Color.blue(),
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)
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,62 +244,19 @@ 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)
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
# 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)
if channel:
if event.file_name == "task.md":
await self._send_task_update(channel, event)
else:
@@ -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()

View File

@@ -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