264 lines
10 KiB
Python
264 lines
10 KiB
Python
"""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}")
|