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}")