416 lines
17 KiB
Python
416 lines
17 KiB
Python
"""Discord bot — relays Antigravity brain events to Discord channels.
|
|
|
|
Multi-project channel architecture:
|
|
- One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva)
|
|
- Each conversation maps to a project via conv_to_project dict
|
|
- Extension registers projects via bridge/pending/ files
|
|
- Commands include project_name for routing to correct IDE window
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import discord
|
|
from discord.ext import commands, tasks
|
|
|
|
from config import Config
|
|
from parser import (
|
|
parse_task_progress,
|
|
md_to_discord_text,
|
|
format_task_embed_text,
|
|
)
|
|
from watcher import BrainEvent, EventType
|
|
from bridge import BridgeProtocol, ApprovalRequest, UserResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ─── Discord UI Components ──────────────────────────────────────────
|
|
|
|
class ApprovalView(discord.ui.View):
|
|
"""Discord buttons for approving/rejecting Antigravity actions."""
|
|
|
|
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
|
|
super().__init__(timeout=300)
|
|
self.bridge = bridge
|
|
self.request = request
|
|
self.responded = False
|
|
|
|
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
|
|
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
if self.responded:
|
|
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
|
return
|
|
self.responded = True
|
|
self.bridge.write_response(UserResponse(
|
|
request_id=self.request.request_id, approved=True,
|
|
))
|
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
|
if embed:
|
|
embed.color = discord.Color.green()
|
|
embed.set_footer(text=f"✅ 승인됨 by {interaction.user.display_name}")
|
|
await interaction.response.edit_message(embed=embed, view=None)
|
|
|
|
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
|
|
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
if self.responded:
|
|
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
|
return
|
|
self.responded = True
|
|
self.bridge.write_response(UserResponse(
|
|
request_id=self.request.request_id, approved=False,
|
|
))
|
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
|
if embed:
|
|
embed.color = discord.Color.red()
|
|
embed.set_footer(text=f"❌ 거부됨 by {interaction.user.display_name}")
|
|
await interaction.response.edit_message(embed=embed, view=None)
|
|
|
|
async def on_timeout(self):
|
|
if not self.responded:
|
|
self.bridge.write_response(UserResponse(
|
|
request_id=self.request.request_id, approved=False,
|
|
))
|
|
|
|
|
|
# ─── Bot ─────────────────────────────────────────────────────────────
|
|
|
|
class GravityBot(commands.Bot):
|
|
"""Discord bot for Antigravity session monitoring.
|
|
|
|
Multi-project architecture:
|
|
- project_channels: project_name → TextChannel (ag-gravity_control, ag-deriva, etc.)
|
|
- conv_to_project: conversation_id → project_name (learned from pending approvals)
|
|
- channel_to_project: channel_id → project_name (for Discord→IDE routing)
|
|
"""
|
|
|
|
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
|
|
self.project_channels: dict[str, discord.TextChannel] = {} # project → channel
|
|
self.conv_to_project: dict[str, str] = {} # conv_id → project
|
|
self.channel_to_project: dict[int, str] = {} # channel.id → project
|
|
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
|
|
self._sent_approval_ids: set[str] = set()
|
|
self._ready_event = asyncio.Event()
|
|
self._channel_lock = asyncio.Lock()
|
|
self.bridge = BridgeProtocol()
|
|
self.session_category: discord.CategoryChannel | None = None
|
|
self.guild: discord.Guild | None = None
|
|
|
|
@staticmethod
|
|
def _make_channel_name(project_name: str) -> str:
|
|
"""ag-gravity_control, ag-deriva, etc."""
|
|
return f"{Config.CHANNEL_PREFIX}-{project_name}".lower()
|
|
|
|
async def setup_hook(self):
|
|
self.loop.create_task(self._process_events())
|
|
self.pending_approval_scanner.start()
|
|
logger.info("Bot setup complete")
|
|
|
|
async def on_ready(self):
|
|
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
|
|
|
|
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 category
|
|
category_name = "Antigravity Sessions"
|
|
self.session_category = discord.utils.get(
|
|
self.guild.categories, name=category_name
|
|
)
|
|
if not self.session_category:
|
|
try:
|
|
self.session_category = await self.guild.create_category(category_name)
|
|
logger.info(f"Created category: {category_name}")
|
|
except discord.errors.Forbidden:
|
|
logger.error("No permission to create category!")
|
|
return
|
|
|
|
# Discover existing project channels
|
|
await self._discover_channels()
|
|
|
|
# Load conversation → project registrations from Extension
|
|
self._load_registrations()
|
|
|
|
# Open the gate
|
|
self._ready_event.set()
|
|
logger.info("Ready gate opened — event processing enabled")
|
|
|
|
# ─── Channel Management ──────────────────────────────────────────
|
|
|
|
def _load_registrations(self):
|
|
"""Read bridge/register/ to learn conversation → project mappings."""
|
|
register_dir = self.bridge.bridge_dir / "register"
|
|
if not register_dir.exists():
|
|
return
|
|
|
|
count = 0
|
|
for f in register_dir.glob("*.json"):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
|
conv_id = data.get("conversation_id", "")
|
|
project = data.get("project_name", "")
|
|
if conv_id and project:
|
|
self.conv_to_project[conv_id] = project
|
|
count += 1
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
if count:
|
|
logger.info(f"Loaded {count} conversation→project registrations")
|
|
|
|
# ─── Channel Management ──────────────────────────────────────────
|
|
|
|
async def _discover_channels(self):
|
|
"""Find existing project channels via Discord API (not cache)."""
|
|
all_channels = await self.guild.fetch_channels()
|
|
prefix = Config.CHANNEL_PREFIX.lower() + "-"
|
|
|
|
for ch in all_channels:
|
|
if (isinstance(ch, discord.TextChannel)
|
|
and ch.category_id == self.session_category.id
|
|
and ch.name.startswith(prefix)):
|
|
project = ch.name[len(prefix):]
|
|
self.project_channels[project] = ch
|
|
self.channel_to_project[ch.id] = project
|
|
logger.info(f"Found channel: #{ch.name} → project={project}")
|
|
|
|
logger.info(f"Discovered {len(self.project_channels)} project channels")
|
|
|
|
async def _get_channel(self, project_name: str) -> discord.TextChannel:
|
|
"""Get or create a channel for a project. Lock-protected."""
|
|
if project_name in self.project_channels:
|
|
return self.project_channels[project_name]
|
|
|
|
async with self._channel_lock:
|
|
# Double-check
|
|
if project_name in self.project_channels:
|
|
return self.project_channels[project_name]
|
|
|
|
channel_name = self._make_channel_name(project_name)
|
|
try:
|
|
ch = await self.guild.create_text_channel(
|
|
name=channel_name,
|
|
category=self.session_category,
|
|
topic=f"Antigravity Bridge — {project_name}",
|
|
)
|
|
self.project_channels[project_name] = ch
|
|
self.channel_to_project[ch.id] = project_name
|
|
logger.info(f"Created channel: #{channel_name}")
|
|
|
|
embed = discord.Embed(
|
|
title=f"🚀 {project_name}",
|
|
description=f"Antigravity Bridge 연결됨",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
await ch.send(embed=embed)
|
|
return ch
|
|
except discord.errors.Forbidden:
|
|
logger.error(f"No permission to create channel: {channel_name}")
|
|
return None
|
|
|
|
def _resolve_project(self, conversation_id: str) -> str:
|
|
"""Get project name for a conversation. Falls back to default."""
|
|
return self.conv_to_project.get(
|
|
conversation_id, Config.PROJECT_NAME
|
|
)
|
|
|
|
# ─── Event Processing ─────────────────────────────────────────────
|
|
|
|
async def _process_events(self):
|
|
"""Main event loop — ALL events go through here sequentially."""
|
|
await self.wait_until_ready()
|
|
await self._ready_event.wait()
|
|
logger.info("Event processor started (ready gate passed)")
|
|
|
|
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 the correct project channel."""
|
|
project = self._resolve_project(event.conversation_id)
|
|
channel = await self._get_channel(project)
|
|
if not channel:
|
|
return
|
|
|
|
if event.event_type == EventType.SESSION_START:
|
|
return
|
|
|
|
try:
|
|
if event.file_name == "task.md":
|
|
await self._send_task_update(channel, event)
|
|
else:
|
|
await self._send_artifact_update(channel, event)
|
|
except discord.NotFound:
|
|
self.project_channels.pop(project, None)
|
|
logger.warning(f"Channel deleted for project {project}, will recreate")
|
|
|
|
# ─── Message Senders ─────────────────────────────────────────────
|
|
|
|
async def _send_task_update(
|
|
self, channel: discord.TextChannel, event: BrainEvent
|
|
):
|
|
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]}")
|
|
|
|
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_update(
|
|
self, channel: discord.TextChannel, event: BrainEvent
|
|
):
|
|
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 "업데이트"
|
|
|
|
lines = event.content.strip().splitlines()
|
|
preview = "\n".join(l for l in lines[:6] if l.strip())
|
|
if len(lines) > 6:
|
|
preview += f"\n... (+{len(lines) - 6} lines)"
|
|
|
|
embed = discord.Embed(
|
|
title=f"{label} ({event_label}됨)",
|
|
description=preview[:1000],
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
|
await channel.send(embed=embed)
|
|
|
|
# ─── Approval Scanner ────────────────────────────────────────────
|
|
|
|
@tasks.loop(seconds=3)
|
|
async def pending_approval_scanner(self):
|
|
"""Scan bridge/pending/ for new approval requests."""
|
|
try:
|
|
requests = self.bridge.get_pending_requests()
|
|
for req in requests:
|
|
if req.request_id in self._sent_approval_ids:
|
|
continue
|
|
if req.discord_message_id != 0:
|
|
continue
|
|
|
|
# Learn project mapping from pending approval
|
|
project = getattr(req, 'project_name', '') or Config.PROJECT_NAME
|
|
if req.conversation_id and req.conversation_id != '__global__':
|
|
self.conv_to_project[req.conversation_id] = project
|
|
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
self._sent_approval_ids.add(req.request_id)
|
|
await self._send_approval_request(channel, req)
|
|
except Exception as e:
|
|
logger.error(f"Error scanning approvals: {e}")
|
|
|
|
@pending_approval_scanner.before_loop
|
|
async def before_scanner(self):
|
|
await self.wait_until_ready()
|
|
|
|
async def _send_approval_request(
|
|
self, channel: discord.TextChannel, request: ApprovalRequest
|
|
):
|
|
embed = discord.Embed(
|
|
title="⚠️ 승인 요청",
|
|
description=(
|
|
f"**명령어:**\n```\n{request.command[:1000]}\n```\n"
|
|
f"{request.description[:500]}"
|
|
),
|
|
color=discord.Color.orange(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
embed.set_footer(text=f"ID: {request.request_id}")
|
|
|
|
view = ApprovalView(self.bridge, request)
|
|
msg = await channel.send(embed=embed, view=view)
|
|
|
|
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
|
if pending_file.exists():
|
|
try:
|
|
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
|
data["discord_message_id"] = msg.id
|
|
pending_file.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
logger.info(f"Sent approval request: {request.request_id[:12]}")
|
|
|
|
# ─── Discord → IDE Text Relay ─────────────────────────────────────
|
|
|
|
async def on_message(self, message: discord.Message):
|
|
if message.author == self.user:
|
|
return
|
|
|
|
# Determine project from channel
|
|
project = self.channel_to_project.get(message.channel.id)
|
|
if not project:
|
|
await self.process_commands(message)
|
|
return
|
|
|
|
text = message.content.strip()
|
|
|
|
# Special command: !auto on/off
|
|
if text in ("!auto on", "!auto off"):
|
|
self.bridge.write_command(project, text, project_name=project)
|
|
enabled = text == "!auto on"
|
|
emoji = "🟢" if enabled else "🔴"
|
|
mode = "자동 승인" if enabled else "수동 승인"
|
|
embed = discord.Embed(
|
|
title=f"{emoji} {mode} 모드",
|
|
description=f"프로젝트: **{project}**\n"
|
|
f"`chat.tools.autoApprove = {enabled}`\n"
|
|
f"`chat.agent.autoApprove = {enabled}`",
|
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
|
)
|
|
await message.channel.send(embed=embed)
|
|
return
|
|
|
|
# General text relay — routed by project
|
|
if text:
|
|
self.bridge.write_command(project, text, project_name=project)
|
|
await message.add_reaction("📨")
|
|
|
|
await self.process_commands(message)
|