902 lines
39 KiB
Python
902 lines
39 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 models import BrainEvent, EventType, 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, request: ApprovalRequest,
|
|
buttons: list[dict] | None = None, hub=None):
|
|
super().__init__(timeout=1800) # 30 minutes
|
|
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:
|
|
await self.hub.send_response_to_pending_owner(self.request.request_id, {
|
|
"type": "response", "data": 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', ''),
|
|
}
|
|
if self.hub:
|
|
await self.hub.send_response_to_pending_owner(self.request.request_id, {
|
|
"type": "response", "data": 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', ''),
|
|
}
|
|
if self.hub:
|
|
await self.hub.send_response_to_pending_owner(self.request.request_id, {
|
|
"type": "response", "data": 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 and self.hub:
|
|
await self.hub.send_response_to_pending_owner(self.request.request_id, {
|
|
"type": "response", "data": {
|
|
"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: dict[str, bool] = {} # request_id → bool
|
|
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.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)
|
|
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)
|
|
)
|
|
|
|
def _cap_dict(self, d: dict, max_size: int = 5000):
|
|
"""Prevent memory leaks by capping dictionary sizes using insertion order (oldest first)."""
|
|
if len(d) >= max_size:
|
|
to_remove = len(d) - max_size + max_size // 10 # remove 10%
|
|
for k in list(d.keys())[:to_remove]:
|
|
d.pop(k, None)
|
|
|
|
@staticmethod
|
|
def _make_channel_name(project_name: str) -> str:
|
|
"""ag-gravity_control, ag-deriva, etc."""
|
|
return f"{Config.CHANNEL_PREFIX}-{project_name}".lower()
|
|
|
|
async def setup_hook(self):
|
|
self.loop.create_task(self._process_events())
|
|
self._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
|
|
|
|
# Start WS Hub processors by ensuring ready gate is open
|
|
self._ready_event.set()
|
|
logger.info("Ready gate opened — event processing enabled")
|
|
|
|
# ─── Channel Management ──────────────────────────────────────────
|
|
|
|
|
|
# ─── 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 ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ─── 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(request, buttons=buttons, hub=self.hub)
|
|
msg = await channel.send(embed=embed, view=view)
|
|
|
|
self._cap_dict(self._sent_approval_ids)
|
|
self._sent_approval_ids[request_id] = True
|
|
|
|
self._cap_dict(self._approval_messages)
|
|
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._cap_dict(self._sent_approval_ids)
|
|
self._sent_approval_ids[request.request_id] = True
|
|
|
|
if self.hub:
|
|
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,
|
|
},
|
|
})
|
|
# 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"✅ **{request.command}**\n\n```\n{request.description[:2000]}\n```" if getattr(request, "description", "") else f"✅ **{request.command}**",
|
|
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 models 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 ─────────────────────────────────────────
|
|
|
|
|