feat(bridge): Watcher, Parser, Bot, Main 핵심 컴포넌트 구현 #task-215 #task-216 #task-217 #task-218

This commit is contained in:
2026-03-07 10:21:00 +09:00
parent 063257bca0
commit ea5001f243
5 changed files with 779 additions and 0 deletions

263
bot.py Normal file
View File

@@ -0,0 +1,263 @@
"""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}")

48
config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Configuration module — loads settings from .env file or environment variables."""
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from project root
load_dotenv(Path(__file__).parent / ".env")
class Config:
"""Bridge configuration."""
# Discord
DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "")
DISCORD_CHANNEL_ID: int = int(os.getenv("DISCORD_CHANNEL_ID", "0"))
# Antigravity Brain path
BRAIN_PATH: Path = Path(os.getenv(
"BRAIN_PATH",
os.path.expanduser("~/.gemini/antigravity/brain")
))
# Watcher settings
DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2"))
# Files to monitor within each conversation directory
WATCHED_FILES: set = {
"task.md",
"implementation_plan.md",
"walkthrough.md",
}
# Discord message limits
DISCORD_MSG_LIMIT: int = 2000
DISCORD_EMBED_DESC_LIMIT: int = 4096
@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.BRAIN_PATH.exists():
errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}")
return errors

79
main.py Normal file
View File

@@ -0,0 +1,79 @@
"""Gravity Control — Antigravity Discord Bridge.
Entry point that runs the brain watcher and Discord bot together.
"""
import asyncio
import logging
import signal
import sys
from config import Config
from watcher import BrainWatcher
from bot import GravityBot
# Logging setup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("gravity_control.log", encoding="utf-8"),
],
)
logger = logging.getLogger("gravity_control")
async def main():
"""Run the bridge: watcher + Discord bot."""
# Validate config
errors = Config.validate()
if errors:
for e in errors:
logger.error(f"Config error: {e}")
logger.error("Fix configuration issues and restart.")
sys.exit(1)
logger.info("=" * 50)
logger.info("Gravity Control — Antigravity Discord Bridge")
logger.info("=" * 50)
logger.info(f"Brain path: {Config.BRAIN_PATH}")
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
# Shared event queue
event_queue = asyncio.Queue()
# Get the running loop
loop = asyncio.get_running_loop()
# Create components
watcher = BrainWatcher(event_queue, loop)
bot = GravityBot(event_queue)
try:
# Start watcher (runs in a separate thread via watchdog)
watcher.start()
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
# Run Discord bot (blocks until bot disconnects)
await bot.start(Config.DISCORD_TOKEN)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
finally:
# Cleanup
watcher.stop()
if not bot.is_closed():
await bot.close()
logger.info("Gravity Control shutdown complete")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

218
parser.py Normal file
View File

@@ -0,0 +1,218 @@
"""Markdown → Discord text parser.
Handles:
- task.md checkbox progress extraction
- MD → Discord-friendly text conversion
- Long text splitting for Discord's 2000 char limit
"""
import re
from dataclasses import dataclass
@dataclass
class TaskProgress:
"""Parsed progress from task.md."""
total: int = 0
done: int = 0
in_progress: int = 0
pending: int = 0
current_task: str = ""
sections: list = None
def __post_init__(self):
if self.sections is None:
self.sections = []
@property
def summary_line(self) -> str:
bar_len = 10
filled = round(self.done / max(self.total, 1) * bar_len)
bar = "" * filled + "" * (bar_len - filled)
return f"[{bar}] {self.done}/{self.total} 완료"
def parse_task_progress(content: str) -> TaskProgress:
"""Parse task.md and extract checkbox progress."""
progress = TaskProgress()
current_section = ""
for line in content.splitlines():
# Section headers
header_match = re.match(r'^#{1,3}\s+(.+)', line)
if header_match:
current_section = header_match.group(1).strip()
continue
# Checkboxes
checkbox_match = re.match(r'^\s*-\s*\[([ x/])\]\s*(.+)', line)
if checkbox_match:
state, text = checkbox_match.groups()
progress.total += 1
if state == 'x':
progress.done += 1
elif state == '/':
progress.in_progress += 1
progress.current_task = text.strip()
else:
progress.pending += 1
progress.sections.append({
"section": current_section,
"state": state,
"text": text.strip()
})
return progress
def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
"""Convert markdown to Discord-friendly text, splitting into chunks.
Preserves:
- Headers → **bold**
- Code blocks → unchanged (Discord supports ```)
- Checkboxes → emoji representation
- Tables → simplified text
Strips:
- Mermaid diagrams
- HTML comments
- Alert syntax (> [!NOTE] etc.)
Returns list of text chunks, each under max_length.
"""
lines = content.splitlines()
output_lines = []
in_mermaid = False
in_code_block = False
for line in lines:
# Skip mermaid blocks
if re.match(r'^```mermaid', line):
in_mermaid = True
output_lines.append("*(mermaid 다이어그램 생략)*")
continue
if in_mermaid:
if line.strip() == '```':
in_mermaid = False
continue
# Track code blocks
if re.match(r'^```', line) and not in_mermaid:
in_code_block = not in_code_block
output_lines.append(line)
continue
if in_code_block:
output_lines.append(line)
continue
# Skip HTML comments
if re.match(r'^\s*<!--.*-->\s*$', line):
continue
# Skip alert syntax but keep content
alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line)
if alert_match:
alert_type = alert_match.group(1)
emoji_map = {
"NOTE": "📝", "TIP": "💡", "IMPORTANT": "",
"WARNING": "⚠️", "CAUTION": "🔴"
}
output_lines.append(f"{emoji_map.get(alert_type, '📌')} **{alert_type}**")
continue
# Convert headers to bold
header_match = re.match(r'^(#{1,3})\s+(.+)', line)
if header_match:
level = len(header_match.group(1))
text = header_match.group(2)
if level == 1:
output_lines.append(f"\n**━━ {text} ━━**\n")
elif level == 2:
output_lines.append(f"\n**▸ {text}**")
else:
output_lines.append(f"**{text}**")
continue
# Convert checkboxes to emoji
checkbox_match = re.match(r'^(\s*)-\s*\[([ x/])\]\s*(.+)', line)
if checkbox_match:
indent, state, text = checkbox_match.groups()
emoji = {"x": "", "/": "🔄", " ": ""}.get(state, "")
output_lines.append(f"{indent}{emoji} {text}")
continue
# Convert blockquote markers
if line.startswith("> "):
output_lines.append(f"{line[2:]}")
continue
# Pass through everything else
output_lines.append(line)
# Join and split into chunks
full_text = "\n".join(output_lines).strip()
return split_text(full_text, max_length)
def split_text(text: str, max_length: int = 1900) -> list[str]:
"""Split text into chunks respecting Discord's message limit.
Tries to split on newlines first, then on spaces.
"""
if len(text) <= max_length:
return [text]
chunks = []
current = ""
for line in text.split("\n"):
if len(current) + len(line) + 1 > max_length:
if current:
chunks.append(current)
current = ""
# If single line is too long, split on spaces
if len(line) > max_length:
words = line.split(" ")
for word in words:
if len(current) + len(word) + 1 > max_length:
if current:
chunks.append(current)
current = word
else:
current = f"{current} {word}" if current else word
else:
current = line
else:
current = f"{current}\n{line}" if current else line
if current:
chunks.append(current)
return chunks
def format_task_embed_text(progress: TaskProgress) -> str:
"""Format task progress as a compact Discord text message."""
lines = [
f"📋 **진행 상황** {progress.summary_line}",
]
if progress.current_task:
lines.append(f"🔄 현재: {progress.current_task}")
# Group by section
current_section = ""
for item in progress.sections:
if item["section"] != current_section:
current_section = item["section"]
lines.append(f"\n**{current_section}**")
emoji = {"x": "", "/": "🔄", " ": ""}.get(item["state"], "")
lines.append(f" {emoji} {item['text']}")
return "\n".join(lines)

171
watcher.py Normal file
View File

@@ -0,0 +1,171 @@
"""Brain directory watcher — monitors Antigravity's brain/ for file changes.
Uses watchdog to detect file creation/modification events in the brain directory.
Emits events to an asyncio queue for the Discord bot to consume.
"""
import asyncio
import time
import logging
from pathlib import Path
from dataclasses import dataclass, field
from enum import Enum
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from config import Config
logger = logging.getLogger(__name__)
class EventType(Enum):
"""Types of brain events."""
SESSION_START = "session_start" # New conversation directory created
SESSION_END = "session_end" # Conversation directory removed (or program exit)
FILE_CHANGED = "file_changed" # Watched file created/modified
FILE_CREATED = "file_created" # Watched file first created
@dataclass
class BrainEvent:
"""An event from the brain directory."""
event_type: EventType
conversation_id: str
file_name: str = ""
file_path: Path = None
content: str = ""
timestamp: float = field(default_factory=time.time)
class BrainEventHandler(FileSystemEventHandler):
"""Watchdog handler that filters and debounces brain events."""
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
super().__init__()
self.event_queue = event_queue
self.loop = loop
self._last_events: dict[str, float] = {} # path -> timestamp (debounce)
self._known_sessions: set[str] = set()
self._initialize_known_sessions()
def _initialize_known_sessions(self):
"""Scan existing brain directories to establish baseline."""
brain_path = Config.BRAIN_PATH
if brain_path.exists():
for entry in brain_path.iterdir():
if entry.is_dir() and self._is_conversation_id(entry.name):
self._known_sessions.add(entry.name)
logger.info(f"Found {len(self._known_sessions)} existing sessions at startup")
def _is_conversation_id(self, name: str) -> bool:
"""Check if directory name looks like a UUID conversation ID."""
parts = name.split("-")
return len(parts) == 5 and all(len(p) >= 4 for p in parts)
def _get_conversation_id(self, path: Path) -> str | None:
"""Extract conversation ID from file path."""
brain_path = Config.BRAIN_PATH
try:
relative = path.relative_to(brain_path)
parts = relative.parts
if parts and self._is_conversation_id(parts[0]):
return parts[0]
except ValueError:
pass
return None
def _should_debounce(self, path_str: str) -> bool:
"""Check if this event should be debounced."""
now = time.time()
last = self._last_events.get(path_str, 0)
if now - last < Config.DEBOUNCE_SECONDS:
return True
self._last_events[path_str] = now
return False
def _emit(self, event: BrainEvent):
"""Thread-safe emit to asyncio queue."""
self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event)
def on_created(self, event: FileSystemEvent):
if event.is_directory:
self._handle_directory_created(Path(event.src_path))
else:
self._handle_file_event(Path(event.src_path), EventType.FILE_CREATED)
def on_modified(self, event: FileSystemEvent):
if not event.is_directory:
self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED)
def _handle_directory_created(self, path: Path):
"""Detect new session directories."""
conv_id = self._get_conversation_id(path)
if conv_id and conv_id not in self._known_sessions:
# Check if this is a direct child of brain/
if path.parent == Config.BRAIN_PATH:
self._known_sessions.add(conv_id)
logger.info(f"New session detected: {conv_id}")
self._emit(BrainEvent(
event_type=EventType.SESSION_START,
conversation_id=conv_id,
))
def _handle_file_event(self, path: Path, event_type: EventType):
"""Process file creation/modification events."""
conv_id = self._get_conversation_id(path)
if not conv_id:
return
file_name = path.name
if file_name not in Config.WATCHED_FILES:
return
if self._should_debounce(str(path)):
return
# Read file content
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
logger.warning(f"Failed to read {path}: {e}")
return
logger.info(f"File event: {event_type.value} {conv_id}/{file_name}")
self._emit(BrainEvent(
event_type=event_type,
conversation_id=conv_id,
file_name=file_name,
file_path=path,
content=content,
))
class BrainWatcher:
"""Manages the watchdog observer for the brain directory."""
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
self.event_queue = event_queue
self.loop = loop
self.observer = Observer()
self.handler = BrainEventHandler(event_queue, loop)
def start(self):
"""Start watching the brain directory."""
brain_path = Config.BRAIN_PATH
if not brain_path.exists():
logger.error(f"Brain path does not exist: {brain_path}")
return
self.observer.schedule(self.handler, str(brain_path), recursive=True)
self.observer.start()
logger.info(f"Watching brain directory: {brain_path}")
def stop(self):
"""Stop the watcher."""
self.observer.stop()
self.observer.join()
logger.info("Brain watcher stopped")
@property
def known_sessions(self) -> set[str]:
return self.handler._known_sessions