Files
gravity_control/bot.py

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)