993 lines
47 KiB
Python
993 lines
47 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 collections import deque
|
|
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.
|
|
|
|
Supports two modes:
|
|
1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array)
|
|
2. Multi-choice: dynamic buttons from pending's buttons array
|
|
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
|
|
"""
|
|
|
|
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest,
|
|
buttons: list[dict] | None = None):
|
|
super().__init__(timeout=1800) # 30 minutes
|
|
self.bridge = bridge
|
|
self.request = request
|
|
self.responded = False
|
|
self.buttons_data = buttons
|
|
|
|
if buttons and len(buttons) > 1:
|
|
# Multi-choice mode: remove the default decorated buttons first
|
|
# (they are added by @discord.ui.button at class definition time)
|
|
self.clear_items()
|
|
|
|
# Add a Discord button for each option
|
|
for btn_info in buttons:
|
|
btn_text = btn_info.get("text", "?")
|
|
btn_index = btn_info.get("index", 0)
|
|
is_reject = btn_text.lower() in ("deny", "reject", "cancel",
|
|
"reject all", "decline",
|
|
"dismiss", "stop")
|
|
style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green
|
|
emoji = "❌" if is_reject else "✅"
|
|
|
|
button = discord.ui.Button(
|
|
label=f"{emoji} {btn_text}",
|
|
style=style,
|
|
custom_id=f"choice_{request.request_id}_{btn_index}",
|
|
)
|
|
# Bind the callback with closure over btn_index and btn_text
|
|
button.callback = self._make_choice_callback(btn_index, btn_text,
|
|
is_reject)
|
|
self.add_item(button)
|
|
# else: use the default @discord.ui.button decorated methods below
|
|
|
|
def _make_choice_callback(self, btn_index: int, btn_text: str,
|
|
is_reject: bool):
|
|
async def callback(interaction: discord.Interaction):
|
|
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=not is_reject,
|
|
button_index=btn_index,
|
|
step_type=getattr(self.request, 'step_type', ''),
|
|
project_name=getattr(self.request, 'project_name', ''),
|
|
))
|
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
|
if embed:
|
|
color = discord.Color.red() if is_reject else discord.Color.green()
|
|
embed.color = color
|
|
emoji = "❌" if is_reject else "✅"
|
|
embed.set_footer(
|
|
text=f"{emoji} {btn_text} by {interaction.user.display_name}"
|
|
)
|
|
await interaction.response.edit_message(embed=embed, view=None)
|
|
return callback
|
|
|
|
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
|
|
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
# Only active in legacy mode (no buttons array)
|
|
if self.buttons_data and len(self.buttons_data) > 1:
|
|
return # multi-choice mode handles via dynamic buttons
|
|
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,
|
|
step_type=getattr(self.request, 'step_type', ''),
|
|
project_name=getattr(self.request, 'project_name', ''),
|
|
))
|
|
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):
|
|
# Only active in legacy mode (no buttons array)
|
|
if self.buttons_data and len(self.buttons_data) > 1:
|
|
return # multi-choice mode handles via dynamic buttons
|
|
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,
|
|
step_type=getattr(self.request, 'step_type', ''),
|
|
project_name=getattr(self.request, 'project_name', ''),
|
|
))
|
|
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,
|
|
step_type=getattr(self.request, 'step_type', ''),
|
|
project_name=getattr(self.request, 'project_name', ''),
|
|
))
|
|
|
|
|
|
# ─── 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._deferred_ids: dict[str, int] = {} # request_id → defer count
|
|
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
|
|
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
|
|
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
|
|
self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay
|
|
self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup)
|
|
self.gateway = None # Set by main.py in gateway mode
|
|
|
|
def _write_command(self, project: str, text: str, **kwargs):
|
|
"""Write command to bridge AND push to gateway (if gateway mode)."""
|
|
self.bridge.write_command(project, text, **kwargs)
|
|
if self.gateway:
|
|
import time
|
|
self.gateway.push_command(project, {
|
|
"id": str(int(time.time() * 1000)),
|
|
"text": text,
|
|
"project_name": kwargs.get('project_name', project),
|
|
})
|
|
|
|
@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()
|
|
self.chat_snapshot_scanner.start()
|
|
self._register_slash_commands()
|
|
logger.info("Bot setup complete")
|
|
|
|
def _register_slash_commands(self):
|
|
"""Register Discord slash commands."""
|
|
|
|
@self.tree.command(name="stop", description="AI 작업 중지")
|
|
async def slash_stop(interaction: discord.Interaction):
|
|
project = self.channel_to_project.get(interaction.channel_id)
|
|
if not project:
|
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
|
return
|
|
self._write_command(project, "!stop", project_name=project)
|
|
await interaction.response.send_message(
|
|
embed=discord.Embed(
|
|
title="⏹️ AI 작업 중지",
|
|
description=f"**{project}** IDE에 중지 요청 전달됨",
|
|
color=discord.Color.orange(),
|
|
)
|
|
)
|
|
|
|
@self.tree.command(name="auto", description="자동 승인 토글")
|
|
async def slash_auto(interaction: discord.Interaction):
|
|
project = self.channel_to_project.get(interaction.channel_id)
|
|
if not project:
|
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
|
return
|
|
# Toggle
|
|
if project in self.auto_approve_projects:
|
|
self.auto_approve_projects.discard(project)
|
|
enabled = False
|
|
else:
|
|
self.auto_approve_projects.add(project)
|
|
enabled = True
|
|
self._write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
|
emoji = "🟢" if enabled else "🔴"
|
|
await interaction.response.send_message(
|
|
embed=discord.Embed(
|
|
title=f"{emoji} {'자동 승인' if enabled else '수동 승인'} 모드",
|
|
description=f"프로젝트: **{project}**",
|
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
|
)
|
|
)
|
|
|
|
@self.tree.command(name="send", description="IDE 채팅에 메시지 전송")
|
|
async def slash_send(interaction: discord.Interaction, message: str):
|
|
project = self.channel_to_project.get(interaction.channel_id)
|
|
if not project:
|
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
|
return
|
|
self._write_command(project, message, project_name=project)
|
|
await interaction.response.send_message(
|
|
embed=discord.Embed(
|
|
description=f"📨 → **{project}** IDE에 전달됨\n`{message[:100]}`",
|
|
color=discord.Color.blurple(),
|
|
),
|
|
delete_after=10,
|
|
)
|
|
|
|
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()
|
|
|
|
# Sync slash commands to guild
|
|
try:
|
|
self.tree.copy_global_to(guild=self.guild)
|
|
synced = await self.tree.sync(guild=self.guild)
|
|
logger.info(f"Synced {len(synced)} slash commands to guild")
|
|
except Exception as e:
|
|
logger.warning(f"Slash command sync failed: {e}")
|
|
|
|
# Open the gate
|
|
self._ready_event.set()
|
|
logger.info("Ready gate opened — event processing enabled")
|
|
|
|
# Start scanner loops
|
|
if not self.pending_approval_scanner.is_running():
|
|
self.pending_approval_scanner.start()
|
|
if not self.chat_snapshot_scanner.is_running():
|
|
self.chat_snapshot_scanner.start()
|
|
logger.info("Scanner loops started")
|
|
|
|
# ─── 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
|
|
|
|
# Only log when count changes
|
|
prev = getattr(self, '_last_reg_count', -1)
|
|
if count != prev:
|
|
self._last_reg_count = count
|
|
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.
|
|
|
|
Uses guild.channels cache first (NO API call), only locks + creates
|
|
if channel truly doesn't exist. This prevents O(N) fetch_channels()
|
|
API calls when multiple projects arrive simultaneously.
|
|
"""
|
|
if project_name in self.project_channels:
|
|
return self.project_channels[project_name]
|
|
|
|
if not self.session_category:
|
|
logger.error(f"[CHANNEL] session_category is None — cannot get channel for project={project_name}")
|
|
return None
|
|
|
|
channel_name = self._make_channel_name(project_name)
|
|
|
|
# 1. Check guild channel cache (NO API call — instant)
|
|
existing = discord.utils.get(
|
|
self.guild.channels, name=channel_name,
|
|
category_id=self.session_category.id,
|
|
)
|
|
if existing and isinstance(existing, discord.TextChannel):
|
|
self.project_channels[project_name] = existing
|
|
self.channel_to_project[existing.id] = project_name
|
|
logger.info(f"Found channel (cache): #{channel_name}")
|
|
return existing
|
|
|
|
# 2. Only lock + API call if truly creating new channel
|
|
async with self._channel_lock:
|
|
# Double-check after lock (another coroutine may have created it)
|
|
if project_name in self.project_channels:
|
|
return self.project_channels[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
|
|
except Exception as e:
|
|
logger.error(f"[CHANNEL] Failed to create channel #{channel_name}: {e}")
|
|
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)
|
|
|
|
# Full task content (truncated to embed limit)
|
|
full_content = event.content.strip()
|
|
description = format_task_embed_text(progress) + "\n\n" + full_content
|
|
if len(description) > 4000:
|
|
description = description[:4000] + "\n…(truncated)"
|
|
|
|
embed = discord.Embed(
|
|
title="📋 Task 진행 현황",
|
|
description=description,
|
|
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 "업데이트"
|
|
|
|
full_content = event.content.strip()
|
|
if not full_content:
|
|
full_content = "(빈 파일)"
|
|
|
|
FILE_ATTACH_THRESHOLD = 4000 # Above this, send as file attachment
|
|
|
|
if len(full_content) > FILE_ATTACH_THRESHOLD:
|
|
# Long content → summary embed + file attachment
|
|
# Extract first meaningful paragraph for summary
|
|
summary_lines = []
|
|
for line in full_content.split('\n'):
|
|
if line.strip():
|
|
summary_lines.append(line.strip())
|
|
if len('\n'.join(summary_lines)) > 300:
|
|
break
|
|
summary = '\n'.join(summary_lines[:5])
|
|
if len(summary) > 500:
|
|
summary = summary[:500] + '...'
|
|
|
|
embed = discord.Embed(
|
|
title=f"{label} ({event_label}됨)",
|
|
description=f"{summary}\n\n📎 *전체 내용은 첨부 파일을 확인하세요* ({len(full_content):,}자)",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
|
|
|
# Create in-memory file attachment
|
|
import io
|
|
file_bytes = full_content.encode('utf-8')
|
|
discord_file = discord.File(
|
|
io.BytesIO(file_bytes),
|
|
filename=event.file_name,
|
|
)
|
|
await channel.send(embed=embed, file=discord_file)
|
|
else:
|
|
# Short content → inline embed (original behavior)
|
|
embed = discord.Embed(
|
|
title=f"{label} ({event_label}됨)",
|
|
description=full_content,
|
|
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 + reload registrations.
|
|
|
|
Per-tick caps prevent Discord API rate limit cascade when multiple
|
|
projects generate pending files simultaneously.
|
|
"""
|
|
try:
|
|
# Reload conv→project registrations each cycle
|
|
self._load_registrations()
|
|
|
|
# Channels are created on-demand when actual signals arrive
|
|
# (via _get_channel in snapshot scanner / approval sender)
|
|
|
|
MAX_NEW_PER_TICK = 5 # Phase 1: max new pending to process per tick
|
|
MAX_STATUS_PER_TICK = 5 # Phase 2: max status changes to process per tick
|
|
phase1_processed = 0
|
|
|
|
requests = self.bridge.get_pending_requests()
|
|
for req in requests:
|
|
if phase1_processed >= MAX_NEW_PER_TICK:
|
|
break
|
|
if req.request_id in self._sent_approval_ids:
|
|
continue
|
|
if req.discord_message_id != 0:
|
|
continue
|
|
|
|
# Learn project mapping from pending approval
|
|
project = req.project_name or Config.PROJECT_NAME
|
|
if req.conversation_id and req.conversation_id != '__global__':
|
|
self.conv_to_project[req.conversation_id] = project
|
|
|
|
# ── Auto-approve: if project has auto enabled, approve immediately ──
|
|
if project in self.auto_approve_projects:
|
|
# Defence: reject-word commands should NEVER be auto-approved
|
|
# (DOM observer may create standalone "Deny" pending from file_permission UI)
|
|
reject_commands = {"deny", "reject", "cancel", "decline", "dismiss", "stop"}
|
|
if req.command.strip().lower() in reject_commands:
|
|
logger.warning(f"Auto-approve BLOCKED: command='{req.command}' is reject-word — skipping")
|
|
self._sent_approval_ids.add(req.request_id)
|
|
phase1_processed += 1
|
|
continue
|
|
|
|
self._sent_approval_ids.add(req.request_id)
|
|
|
|
# Smart button_index: read buttons array from pending file
|
|
# file_permission buttons = [Allow Once(0), Allow This Conv(1), Deny(2)]
|
|
# MUST pick non-reject button for safety
|
|
approve_btn_index = 0
|
|
pending_file = self.bridge.pending_dir / f"{req.request_id}.json"
|
|
if pending_file.exists():
|
|
try:
|
|
pdata = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
|
btns = pdata.get("buttons")
|
|
if btns and len(btns) > 1:
|
|
reject_words = {"deny", "reject", "cancel", "reject all",
|
|
"decline", "dismiss", "stop"}
|
|
for b in btns:
|
|
txt = b.get("text", "").lower().strip()
|
|
if txt not in reject_words:
|
|
approve_btn_index = b.get("index", 0)
|
|
break
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
# Write auto-approve response for Extension
|
|
self.bridge.write_response(UserResponse(
|
|
request_id=req.request_id,
|
|
approved=True,
|
|
button_index=approve_btn_index,
|
|
step_type=getattr(req, 'step_type', ''),
|
|
project_name=project,
|
|
))
|
|
# Show compact auto-approved embed in Discord
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
try:
|
|
embed = discord.Embed(
|
|
title="🤖 자동 승인됨",
|
|
description=f"```\n{req.command[:500]}\n```",
|
|
color=discord.Color.green(),
|
|
)
|
|
embed.set_footer(text=f"auto-approve | {req.request_id[:12]}")
|
|
await channel.send(embed=embed)
|
|
except Exception as e:
|
|
logger.error(f"[AUTO-APPROVE] Discord send failed for {project}: {e}")
|
|
else:
|
|
logger.warning(f"[AUTO-APPROVE] No Discord channel for project={project} — notification skipped")
|
|
logger.info(f"Auto-approved: {req.request_id[:12]} project={project} btn_idx={approve_btn_index}")
|
|
phase1_processed += 1
|
|
continue
|
|
|
|
# Defer short-command pendings (e.g. "Run") by 4 cycles (~12s)
|
|
# to give step_probe time to merge detailed command info
|
|
# (step_probe MERGE happens ~10s after pending creation)
|
|
if len(req.command) <= 15:
|
|
if req.request_id not in self._deferred_ids:
|
|
self._deferred_ids[req.request_id] = 1
|
|
continue # skip this cycle
|
|
elif self._deferred_ids[req.request_id] < 4:
|
|
self._deferred_ids[req.request_id] += 1
|
|
# Re-read from file (step_probe may have merged)
|
|
fresh = self.bridge.read_pending_request(req.request_id)
|
|
if fresh and len(fresh.command) > 15:
|
|
req = fresh # use merged version — send now!
|
|
else:
|
|
continue # wait one more cycle
|
|
|
|
# Clean up defer tracking
|
|
self._deferred_ids.pop(req.request_id, None)
|
|
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
self._sent_approval_ids.add(req.request_id)
|
|
self._sent_commands[req.request_id] = req.command
|
|
await self._send_approval_request(channel, req)
|
|
phase1_processed += 1
|
|
else:
|
|
logger.warning(f"[APPROVAL] No Discord channel for project={project} — approval request skipped (rid={req.request_id[:12]})")
|
|
|
|
# ── Single-pass: handle auto_resolved, expired, and MERGE in one glob ──
|
|
phase2_processed = 0
|
|
for f in self.bridge.pending_dir.glob("*.json"):
|
|
if phase2_processed >= MAX_STATUS_PER_TICK:
|
|
break
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
|
status = data.get("status", "pending")
|
|
rid = data.get("request_id", "")
|
|
|
|
if status == "auto_resolved":
|
|
# FIX #5: Use _approval_messages as fallback when discord_message_id is 0
|
|
msg_id = data.get("discord_message_id", 0) or self._approval_messages.get(rid, 0)
|
|
project = data.get("project_name", Config.PROJECT_NAME)
|
|
logger.info(f"[AUTO-RESOLVED] rid={rid[:12]} project={project} msg_id={msg_id} cmd='{data.get('command', '')[:60]}'")
|
|
if msg_id:
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
try:
|
|
msg = await channel.fetch_message(msg_id)
|
|
embed = discord.Embed(
|
|
title="✅ AG에서 직접 승인됨",
|
|
description=f"```\n{data.get('command', '')[:500]}\n```",
|
|
color=discord.Color.green(),
|
|
)
|
|
embed.set_footer(text=f"ID: {rid}")
|
|
await msg.edit(embed=embed, view=None)
|
|
logger.info(f"[AUTO-RESOLVED] ✅ Discord message {msg_id} updated")
|
|
except discord.NotFound:
|
|
logger.warning(f"[AUTO-RESOLVED] Discord message {msg_id} not found")
|
|
else:
|
|
logger.warning(f"[AUTO-RESOLVED] No msg_id for rid={rid[:12]} — cannot edit Discord message")
|
|
f.unlink()
|
|
self._deferred_ids.pop(rid, None)
|
|
self._sent_commands.pop(rid, None)
|
|
self._approval_messages.pop(rid, None)
|
|
self._sent_approval_ids.discard(rid)
|
|
phase2_processed += 1
|
|
|
|
elif status == "expired":
|
|
msg_id = data.get("discord_message_id", 0)
|
|
project = data.get("project_name", Config.PROJECT_NAME)
|
|
if msg_id:
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
try:
|
|
msg = await channel.fetch_message(msg_id)
|
|
embed = discord.Embed(
|
|
title="⏰ 만료됨",
|
|
description=f"```\n{data.get('command', '')[:500]}\n```",
|
|
color=discord.Color.light_grey(),
|
|
)
|
|
embed.set_footer(text=f"ID: {rid}")
|
|
await msg.edit(embed=embed, view=None)
|
|
except discord.NotFound:
|
|
pass
|
|
f.unlink()
|
|
self._deferred_ids.pop(rid, None)
|
|
self._sent_commands.pop(rid, None)
|
|
self._sent_approval_ids.discard(rid)
|
|
phase2_processed += 1
|
|
|
|
elif status == "pending":
|
|
# MERGE check: step_probe updated command in already-sent pending
|
|
if rid not in self._sent_approval_ids:
|
|
continue
|
|
msg_id = data.get("discord_message_id", 0)
|
|
if not msg_id:
|
|
continue
|
|
new_cmd = data.get("command", "")
|
|
old_cmd = self._sent_commands.get(rid, "")
|
|
if new_cmd and new_cmd != old_cmd and len(new_cmd) > len(old_cmd):
|
|
self._sent_commands[rid] = new_cmd
|
|
project = data.get("project_name", Config.PROJECT_NAME)
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
try:
|
|
msg = await channel.fetch_message(msg_id)
|
|
buttons = data.get("buttons")
|
|
desc_parts = [f"**명령어:**\n```\n{new_cmd[:1000]}\n```"]
|
|
if buttons and len(buttons) > 1:
|
|
btn_names = [b.get("text", "?") for b in buttons]
|
|
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
|
|
desc = data.get("description", "")
|
|
if desc:
|
|
desc_parts.append(desc[:500])
|
|
embed = discord.Embed(
|
|
title="⚠️ 승인 요청",
|
|
description="\n".join(desc_parts),
|
|
color=discord.Color.orange(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
embed.set_footer(text=f"ID: {rid}")
|
|
await msg.edit(embed=embed)
|
|
logger.info(f"MERGE edit: {rid[:12]} cmd='{new_cmd[:60]}'")
|
|
except discord.NotFound:
|
|
pass
|
|
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
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
|
|
):
|
|
# Read buttons array from pending file (if present)
|
|
buttons = None
|
|
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
|
if pending_file.exists():
|
|
try:
|
|
pending_data = json.loads(
|
|
pending_file.read_text(encoding="utf-8-sig")
|
|
)
|
|
buttons = pending_data.get("buttons")
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
# Build embed description
|
|
desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"]
|
|
if buttons and len(buttons) > 1:
|
|
# Multi-choice: show all options in description
|
|
btn_names = [b.get("text", "?") for b in buttons]
|
|
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
|
|
if request.description:
|
|
desc_parts.append(request.description[:500])
|
|
|
|
embed = discord.Embed(
|
|
title="⚠️ 승인 요청",
|
|
description="\n".join(desc_parts),
|
|
color=discord.Color.orange(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
embed.set_footer(text=f"ID: {request.request_id}")
|
|
|
|
view = ApprovalView(self.bridge, request, buttons=buttons)
|
|
msg = await channel.send(embed=embed, view=view)
|
|
|
|
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]}")
|
|
self._approval_messages[request.request_id] = msg.id # FIX #4: Track msg_id for auto_resolved lookup
|
|
|
|
# ─── Discord → IDE Text Relay ─────────────────────────────────────
|
|
|
|
async def on_message(self, message: discord.Message):
|
|
if message.author == self.user:
|
|
return
|
|
|
|
# Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection
|
|
if message.id in self._processed_message_ids:
|
|
return
|
|
self._processed_message_ids.append(message.id)
|
|
|
|
# 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: !stop — cancel AI work
|
|
if text == "!stop":
|
|
self._write_command(project, "!stop", project_name=project)
|
|
embed = discord.Embed(
|
|
title="⏹️ AI 작업 중지",
|
|
description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.",
|
|
color=discord.Color.orange(),
|
|
)
|
|
await message.channel.send(embed=embed)
|
|
return
|
|
|
|
# Special command: !auto — toggle auto-approve
|
|
if text == "!auto":
|
|
# Toggle per-project auto-approve
|
|
if project in self.auto_approve_projects:
|
|
self.auto_approve_projects.discard(project)
|
|
enabled = False
|
|
else:
|
|
self.auto_approve_projects.add(project)
|
|
enabled = True
|
|
self._write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
|
emoji = "🟢" if enabled else "🔴"
|
|
mode = "자동 승인" if enabled else "수동 승인"
|
|
embed = discord.Embed(
|
|
title=f"{emoji} {mode} 모드",
|
|
description=f"프로젝트: **{project}**\n"
|
|
f"모든 승인 요청이 {'자동으로 승인됩니다' if enabled else '수동 확인이 필요합니다'}",
|
|
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._write_command(project, text, project_name=project)
|
|
await message.add_reaction("📨")
|
|
embed = discord.Embed(
|
|
description=f"📨 → **{project}** IDE에 전달됨\n`{text[:100]}`",
|
|
color=discord.Color.blurple(),
|
|
)
|
|
await message.channel.send(embed=embed, delete_after=10)
|
|
|
|
await self.process_commands(message)
|
|
|
|
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
|
|
|
|
@tasks.loop(seconds=5)
|
|
async def chat_snapshot_scanner(self):
|
|
"""Scan bridge/chat_snapshots/ for AI response dumps."""
|
|
try:
|
|
snapshot_dir = self.bridge.bridge_dir / "chat_snapshots"
|
|
if not snapshot_dir.exists():
|
|
return
|
|
|
|
for f in snapshot_dir.glob("*.json"):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
|
project = data.get("project_name", Config.PROJECT_NAME)
|
|
content = data.get("content", "")
|
|
attached_files = data.get("attached_files", [])
|
|
|
|
if content or attached_files:
|
|
channel = await self._get_channel(project)
|
|
if not channel:
|
|
logger.warning(f"[SNAPSHOT] No Discord channel for project={project} — snapshot skipped (len={len(content)})")
|
|
elif channel:
|
|
import io
|
|
|
|
# ── Send attached files (from Extension's writeChatSnapshotWithFiles) ──
|
|
discord_files = []
|
|
for af in attached_files:
|
|
af_name = af.get("name", "document.md")
|
|
af_content = af.get("content", "")
|
|
if af_content:
|
|
discord_files.append(discord.File(
|
|
io.BytesIO(af_content.encode("utf-8")),
|
|
filename=af_name,
|
|
))
|
|
|
|
FILE_ATTACH_THRESHOLD = 4000
|
|
if len(content) > FILE_ATTACH_THRESHOLD:
|
|
# Long chat content → summary embed + file attachment
|
|
summary = content[:500].rsplit('\n', 1)[0]
|
|
embed = discord.Embed(
|
|
title="💬 AI 대화 내용",
|
|
description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)",
|
|
color=discord.Color.purple(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
# Add content itself as file attachment
|
|
discord_files.append(discord.File(
|
|
io.BytesIO(content.encode("utf-8")),
|
|
filename="chat_message.md",
|
|
))
|
|
try:
|
|
await channel.send(embed=embed, files=discord_files)
|
|
except discord.NotFound:
|
|
logger.warning(f"Channel deleted for {project}, re-creating...")
|
|
self.project_channels.pop(project, None)
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
# Re-create files (discord.File consumed after send)
|
|
discord_files2 = []
|
|
for af in attached_files:
|
|
af_name = af.get("name", "document.md")
|
|
af_content = af.get("content", "")
|
|
if af_content:
|
|
discord_files2.append(discord.File(
|
|
io.BytesIO(af_content.encode("utf-8")),
|
|
filename=af_name,
|
|
))
|
|
discord_files2.append(discord.File(
|
|
io.BytesIO(content.encode("utf-8")),
|
|
filename="chat_message.md",
|
|
))
|
|
await channel.send(embed=embed, files=discord_files2)
|
|
else:
|
|
# Short content → inline embed (original)
|
|
embed = discord.Embed(
|
|
title="💬 AI 대화 내용",
|
|
description=content,
|
|
color=discord.Color.purple(),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
try:
|
|
await channel.send(
|
|
embed=embed,
|
|
files=discord_files if discord_files else discord.utils.MISSING,
|
|
)
|
|
except discord.NotFound:
|
|
logger.warning(f"Channel deleted for {project}, re-creating...")
|
|
self.project_channels.pop(project, None)
|
|
channel = await self._get_channel(project)
|
|
if channel:
|
|
await channel.send(embed=embed)
|
|
|
|
f.unlink() # Cleanup
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"Bad chat snapshot {f.name}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error scanning chat snapshots: {e}")
|
|
|
|
@chat_snapshot_scanner.before_loop
|
|
async def before_chat_scanner(self):
|
|
await self.wait_until_ready()
|