Files
gravity_control/bot.py
Variet Worker 0fae7e32aa fix(ext,bot): 통신 아키텍처 감사 — writeRegistration 이중쓰기 + ApprovalView fallback + scanner 최적화
- step-probe.ts: writeRegistration WS 후 return 추가 (파일 이중쓰기 방지)
- bot.py: ApprovalView approve/reject/choice — send_response_to_pending_owner 반환값 확인 + file bridge fallback (5곳)
- bot.py: scanner 주기 3s/5s → 30s (Hub 모드 불필요 I/O 감소)
2026-03-17 21:30:05 +09:00

1343 lines
63 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
Multi-PC UX:
- When multiple AG instances are active, messages get instance numbers (PC #1, #2)
- Users can target specific instances with !N <message> (e.g. !2 hello)
- When only one instance is active, natural conversation without numbers
"""
import asyncio
import re
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, hub=None):
super().__init__(timeout=1800) # 30 minutes
self.bridge = bridge
self.hub = hub # WSHub instance for WS response routing
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
response_data = {
"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', ''),
}
# Hub WS route (primary — reaches remote Extensions)
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
# File bridge fallback (Hub unavailable OR owner disconnected)
self.bridge.write_response(UserResponse(**response_data))
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
response_data = {
"request_id": self.request.request_id, "approved": True,
"step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''),
}
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
self.bridge.write_response(UserResponse(**response_data))
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
response_data = {
"request_id": self.request.request_id, "approved": False,
"step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''),
}
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
self.bridge.write_response(UserResponse(**response_data))
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._last_auto_toggle: dict[str, float] = {} # project → timestamp (dedup for !auto embed)
self.gateway = None # Set by main.py in gateway mode
self.hub = None # Set by main.py in gateway mode (WSHub instance)
def _write_command(self, project: str, text: str, *,
target_instance: int | None = None, **kwargs):
"""Write command to Extension via Hub WS (primary) or file bridge (fallback).
When Hub is connected, ONLY use WS to prevent duplicate delivery.
File bridge + Gateway are legacy fallbacks for when Hub is unavailable.
Args:
target_instance: If set, send only to this instance number (via Hub).
If None, broadcast to all instances.
"""
cmd_data = {
"text": text,
"project_name": kwargs.get('project_name', project),
}
# Hub route (primary — skip file bridge to prevent double delivery)
if self.hub:
import time as _time
cmd_data["id"] = str(int(_time.time() * 1000))
msg = {"type": "command", "data": cmd_data}
if target_instance is not None:
asyncio.create_task(
self.hub.send_to_instance(project, target_instance, msg)
)
else:
asyncio.create_task(
self.hub.broadcast_to_project(project, msg)
)
return # ← WS sent, skip file bridge
# Legacy fallback (file bridge + gateway HTTP) — only when Hub is unavailable
self.bridge.write_command(project, text, **kwargs)
if self.gateway:
import time as _time
cmd_data["id"] = cmd_data.get("id", str(int(_time.time() * 1000)))
self.gateway.push_command(project, cmd_data)
@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()
# Register Hub handlers (if Hub is available, set after setup_hook by main.py)
asyncio.get_event_loop().call_soon(self._register_hub_handlers)
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=30) # Hub mode: WS is primary, file scan is fallback only
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, hub=self.hub)
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 + Multi-PC UX ───────────────────────────
def _get_instance_header(self, project: str, instance_number: int) -> str:
"""Format instance header based on active count.
Single instance: empty string (natural conversation)
Multiple instances: **[PC #N]** prefix
"""
if not self.hub:
return ""
active = self.hub.get_active_count(project)
if active <= 1:
return ""
return f"**[PC #{instance_number}]** "
def _parse_instance_target(self, text: str) -> tuple[int | None, str]:
"""Parse !N prefix from message text.
Returns (target_instance, remaining_text).
'!2 hello' -> (2, 'hello')
'hello' -> (None, 'hello')
'!stop' -> (None, '!stop') # special commands not treated as targeting
"""
match = re.match(r'^!(\d+)\s+(.+)', text, re.DOTALL)
if match:
return int(match.group(1)), match.group(2).strip()
return None, text
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()
# Parse !N instance targeting (before special commands)
target_instance, actual_text = self._parse_instance_target(text)
# Special command: !stop — cancel AI work
if actual_text == "!stop":
self._write_command(project, "!stop", target_instance=target_instance,
project_name=project)
target_label = f" (PC #{target_instance})" if target_instance else ""
embed = discord.Embed(
title="⏹️ AI 작업 중지",
description=f"프로젝트: **{project}**{target_label}\n중지 요청을 Extension에 전달했습니다.",
color=discord.Color.orange(),
)
await message.channel.send(embed=embed)
return
# Special command: !auto — toggle auto-approve
if actual_text == "!auto":
# Dedup: skip if toggled within 5s for same project (Gateway event replay)
now = time.time()
last = self._last_auto_toggle.get(project, 0)
if now - last < 5.0:
logger.info(f"[AUTO] Dedup: skipping duplicate !auto for {project} ({now-last:.1f}s ago)")
return
self._last_auto_toggle[project] = now
# 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'}",
target_instance=target_instance, 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 (+ optional instance targeting)
if actual_text:
self._write_command(project, actual_text, target_instance=target_instance,
project_name=project)
await message.add_reaction("📨")
target_label = f" PC #{target_instance}" if target_instance else ""
embed = discord.Embed(
description=f"📨 → **{project}**{target_label} IDE에 전달됨\n`{actual_text[:100]}`",
color=discord.Color.blurple(),
)
await message.channel.send(embed=embed, delete_after=10)
await self.process_commands(message)
# ─── Hub Event Handlers ──────────────────────────────────────────
def _register_hub_handlers(self):
"""Register callbacks on the Hub for Extension->Bot messages."""
if not self.hub:
return
self.hub.set_bot_handlers(
on_pending=self._hub_on_pending,
on_chat=self._hub_on_chat,
on_register=self._hub_on_register,
on_auto_resolve=self._hub_on_auto_resolve,
on_brain_event=self._hub_on_brain_event,
)
logger.info("[BOT] Hub handlers registered")
async def _hub_on_pending(self, project: str, data: dict):
"""Handle pending approval from Hub (Extension->Hub->Bot)."""
try:
request_id = data.get("request_id", "")
if not request_id:
return
# Skip if already sent
if request_id in self._sent_approval_ids:
return
# Check auto_resolved status
status = data.get("status", "pending")
if status in ("auto_resolved", "expired"):
await self._handle_auto_resolved(request_id, status)
return
instance_number = data.get("_instance_number", 0)
pc_name = data.get("_pc_name", "")
header = self._get_instance_header(project, instance_number)
# Build approval request
request = ApprovalRequest(
request_id=request_id,
conversation_id=data.get("conversation_id", ""),
command=data.get("command", ""),
description=data.get("description", ""),
timestamp=data.get("timestamp", time.time()),
project_name=project,
step_type=data.get("step_type", ""),
status=status,
)
# Auto-approve check
if project in self.auto_approve_projects:
await self._auto_approve_via_hub(request)
return
# Send to Discord
channel = await self._get_channel(project)
if not channel:
logger.warning(f"[HUB-PENDING] No channel for project={project}")
return
buttons = data.get("buttons", [])
desc_parts = []
if header:
desc_parts.append(header)
desc_parts.append(f"**명령:** `{request.command[:200]}`")
if buttons:
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_id}")
view = ApprovalView(self.bridge, request, buttons=buttons, hub=self.hub)
msg = await channel.send(embed=embed, view=view)
self._sent_approval_ids.add(request_id)
self._approval_messages[request_id] = msg.id
logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project}")
except Exception as e:
logger.error(f"[HUB-PENDING] Error: {e}")
async def _auto_approve_via_hub(self, request: ApprovalRequest):
"""Auto-approve a pending request via Hub."""
self._sent_approval_ids.add(request.request_id)
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(request.request_id, {
"type": "response",
"data": {
"request_id": request.request_id,
"approved": True,
"button_index": 0,
"step_type": request.step_type,
"project_name": request.project_name,
},
})
if not delivered:
# File bridge fallback (Hub unavailable OR owner disconnected)
self.bridge.write_response(UserResponse(
request_id=request.request_id, approved=True,
step_type=request.step_type,
project_name=request.project_name,
))
# Send compact auto-approved embed to Discord (was missing — caused silent approvals)
channel = await self._get_channel(request.project_name)
if channel:
try:
embed = discord.Embed(
title="🤖 자동 승인됨",
description=f"```\n{request.command[:500]}\n```",
color=discord.Color.green(),
)
embed.set_footer(text=f"auto-approve | {request.request_id[:12]}")
await channel.send(embed=embed)
except Exception as e:
logger.error(f"[HUB-AUTO] Discord send failed: {e}")
logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]} project={request.project_name}")
async def _hub_on_chat(self, project: str, data: dict):
"""Handle chat snapshot from Hub (Extension->Hub->Bot->Discord)."""
try:
content = data.get("content", "")
attached_files = data.get("attached_files", [])
if not content and not attached_files:
return
instance_number = data.get("_instance_number", 0)
header = self._get_instance_header(project, instance_number)
channel = await self._get_channel(project)
if not channel:
return
import io as _io
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,
))
display_content = f"{header}{content}" if header else content
FILE_ATTACH_THRESHOLD = 4000
if len(display_content) > FILE_ATTACH_THRESHOLD:
summary = display_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),
)
discord_files.append(discord.File(
_io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
await channel.send(embed=embed, files=discord_files)
else:
embed = discord.Embed(
title="💬 AI 대화 내용",
description=display_content,
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(
embed=embed,
files=discord_files if discord_files else discord.utils.MISSING,
)
logger.info(f"[HUB-CHAT] Sent to #{channel.name} ({len(content)} chars)")
except Exception as e:
logger.error(f"[HUB-CHAT] Error: {e}")
async def _hub_on_register(self, data: dict):
"""Handle session registration from Hub."""
conv_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if conv_id and project:
self.conv_to_project[conv_id] = project
logger.info(f"[HUB-REG] {conv_id[:8]}{project}")
async def _hub_on_auto_resolve(self, project: str, data: dict):
"""Handle auto_resolve notification from Hub."""
request_id = data.get("request_id", "")
if request_id:
await self._handle_auto_resolved(request_id, "auto_resolved")
async def _hub_on_brain_event(self, project: str, data: dict):
"""Handle brain event from Hub (Extension->Hub->Bot->Discord)."""
try:
from watcher import BrainEvent, EventType
event = BrainEvent(
event_type=EventType(data.get("event_type", "file_changed")),
conversation_id=data.get("conversation_id", ""),
file_name=data.get("file_name", ""),
file_path=None,
content=data.get("content", ""),
timestamp=data.get("timestamp", time.time()),
)
await self.event_queue.put(event)
except Exception as e:
logger.error(f"[HUB-EVENT] Error: {e}")
async def _handle_auto_resolved(self, request_id: str, status: str):
"""Edit Discord message to show auto-resolved/expired status."""
msg_id = self._approval_messages.get(request_id)
if not msg_id:
return
# Find the channel containing this message
for channel in self.project_channels.values():
try:
msg = await channel.fetch_message(msg_id)
embed = msg.embeds[0] if msg.embeds else None
if embed:
if status == "auto_resolved":
embed.color = discord.Color.green()
embed.set_footer(text="✅ 자동 해결됨")
else:
embed.color = discord.Color.greyple()
embed.set_footer(text="⏰ 만료됨")
await msg.edit(embed=embed, view=None)
self._approval_messages.pop(request_id, None)
break
except (discord.NotFound, discord.Forbidden):
continue
except Exception:
break
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
@tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only
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)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} (file, {len(content)} chars)")
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)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (file, {len(content)} chars)")
except Exception as e:
logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}")
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,
)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} (inline, {len(content)} chars)")
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)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (inline)")
except Exception as e:
logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}")
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()