406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""Discord bot — relays Antigravity brain events to Discord channels.
|
|
|
|
Single project channel design:
|
|
- ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control)
|
|
- ALL conversations route to this single channel
|
|
- Uses guild.fetch_channels() API, NOT cached text_channels
|
|
"""
|
|
|
|
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.
|
|
|
|
Single-channel architecture:
|
|
- ONE channel per project (ag-gravity_control)
|
|
- self.project_channel is the singleton — trivially prevents duplication
|
|
"""
|
|
|
|
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_channel: discord.TextChannel | None = None # THE channel
|
|
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
|
|
self._sent_approval_ids: set[str] = set()
|
|
self._ready_event = asyncio.Event()
|
|
self.bridge = BridgeProtocol()
|
|
self.session_category: discord.CategoryChannel | None = None
|
|
self.guild: discord.Guild | None = None
|
|
|
|
@property
|
|
def _channel_name(self) -> str:
|
|
"""The ONE channel name: ag-gravity_control (lowercase)."""
|
|
return f"{Config.CHANNEL_PREFIX}-{Config.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
|
|
|
|
# Find the project channel + cleanup duplicates
|
|
await self._init_project_channel()
|
|
|
|
# Open the gate
|
|
self._ready_event.set()
|
|
logger.info("Ready gate opened — event processing enabled")
|
|
|
|
# ─── Channel Init (ONE channel, guild.fetch_channels API) ────────
|
|
|
|
async def _init_project_channel(self):
|
|
"""Find or create the single project channel. Delete any duplicates.
|
|
|
|
Uses guild.fetch_channels() — the REAL Discord API, not the cache.
|
|
"""
|
|
target_name = self._channel_name
|
|
|
|
# Fetch ALL channels from Discord API (not cache)
|
|
all_channels = await self.guild.fetch_channels()
|
|
|
|
matches: list[discord.TextChannel] = []
|
|
for ch in all_channels:
|
|
if (isinstance(ch, discord.TextChannel)
|
|
and ch.category_id == self.session_category.id
|
|
and ch.name == target_name):
|
|
matches.append(ch)
|
|
|
|
if matches:
|
|
# Keep the first, delete the rest
|
|
self.project_channel = matches[0]
|
|
logger.info(f"Found project channel: #{target_name} (id={self.project_channel.id})")
|
|
|
|
for dup in matches[1:]:
|
|
try:
|
|
await dup.delete(reason="Duplicate project channel cleanup")
|
|
logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})")
|
|
except (discord.Forbidden, discord.HTTPException) as e:
|
|
logger.warning(f"Failed to delete duplicate: {e}")
|
|
|
|
# Also delete any OLD-style channels with different names
|
|
for ch in all_channels:
|
|
if (isinstance(ch, discord.TextChannel)
|
|
and ch.category_id == self.session_category.id
|
|
and ch.name != target_name
|
|
and ch.topic and "Antigravity Session:" in ch.topic):
|
|
try:
|
|
await ch.delete(reason="Old-style channel cleanup")
|
|
logger.info(f"Deleted old channel: #{ch.name}")
|
|
except (discord.Forbidden, discord.HTTPException) as e:
|
|
logger.warning(f"Failed to delete old channel: {e}")
|
|
else:
|
|
logger.info(f"No existing project channel found. Will create on first event.")
|
|
|
|
async def _get_project_channel(self) -> discord.TextChannel:
|
|
"""Get the project channel. Create if it doesn't exist yet.
|
|
|
|
Thread-safe: only ONE channel will ever be created because
|
|
self.project_channel acts as a singleton guard.
|
|
"""
|
|
if self.project_channel:
|
|
return self.project_channel
|
|
|
|
# Create the channel
|
|
try:
|
|
self.project_channel = await self.guild.create_text_channel(
|
|
name=self._channel_name,
|
|
category=self.session_category,
|
|
topic=f"Gravity Control — Antigravity Bridge",
|
|
)
|
|
logger.info(f"Created project channel: #{self._channel_name}")
|
|
|
|
embed = discord.Embed(
|
|
title=f"🚀 {Config.PROJECT_NAME}",
|
|
description=(
|
|
f"Antigravity Bridge 연결됨\n"
|
|
f"모든 세션 이벤트가 이 채널로 전달됩니다."
|
|
),
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
await self.project_channel.send(embed=embed)
|
|
except discord.errors.Forbidden:
|
|
logger.error(f"No permission to create channel: {self._channel_name}")
|
|
|
|
return self.project_channel
|
|
|
|
# ─── 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 single project channel."""
|
|
if event.event_type == EventType.SESSION_START:
|
|
# Just ensure channel exists, no message needed
|
|
await self._get_project_channel()
|
|
return
|
|
|
|
channel = await self._get_project_channel()
|
|
if not channel:
|
|
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_channel = None # Channel was deleted, recreate next time
|
|
logger.warning("Project channel was deleted, will recreate")
|
|
|
|
# ─── Message Senders ─────────────────────────────────────────────
|
|
|
|
async def _send_task_update(
|
|
self, channel: discord.TextChannel, event: BrainEvent
|
|
):
|
|
"""Send/edit task progress embed (ONE message per conv_id, always edited)."""
|
|
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]}")
|
|
|
|
# Try to edit existing message for this conversation
|
|
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
|
|
):
|
|
"""Send artifact update as single compact embed (preview only)."""
|
|
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 "업데이트"
|
|
|
|
# Preview: first 6 non-empty lines only
|
|
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
|
|
|
|
channel = await self._get_project_channel()
|
|
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)
|
|
|
|
# Update pending file with discord message id
|
|
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"))
|
|
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[:8]}")
|
|
|
|
# ─── Discord → Antigravity Text Relay ─────────────────────────────
|
|
|
|
async def on_message(self, message: discord.Message):
|
|
if message.author == self.user:
|
|
return
|
|
|
|
# Only respond in the project channel
|
|
if not self.project_channel or message.channel.id != self.project_channel.id:
|
|
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("__global__", text)
|
|
enabled = text == "!auto on"
|
|
emoji = "🟢" if enabled else "🔴"
|
|
mode = "자동 승인" if enabled else "수동 승인"
|
|
embed = discord.Embed(
|
|
title=f"{emoji} {mode} 모드",
|
|
description=f"Antigravity IDE 설정이 변경됩니다.\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 (broadcast to most recent session or global)
|
|
if text:
|
|
self.bridge.write_command("__global__", text)
|
|
await message.add_reaction("📨")
|
|
|
|
await self.process_commands(message)
|