feat(phase2): 승인/거부 버튼 + bridge 프로토콜 + 테이블 변환 수정 + 중복 메시지 해결

This commit is contained in:
2026-03-07 11:12:56 +09:00
parent 26dcb51130
commit 5d95185ecd
3 changed files with 315 additions and 8 deletions

169
bot.py
View File

@@ -25,10 +25,61 @@ from parser import (
format_task_embed_text,
)
from watcher import BrainEvent, EventType
from bridge import BridgeProtocol, ApprovalRequest, UserResponse
logger = logging.getLogger(__name__)
class ApprovalView(discord.ui.View):
"""Discord buttons for approving/rejecting Antigravity actions."""
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
super().__init__(timeout=300) # 5 min timeout
self.bridge = bridge
self.request = request
self.responded = False
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True)
return
self.responded = True
self.bridge.write_response(UserResponse(
request_id=self.request.request_id,
approved=True,
))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = discord.Color.green()
embed.set_footer(text=f"✅ 승인됨 by {interaction.user.display_name}")
await interaction.response.edit_message(embed=embed, view=None)
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True)
return
self.responded = True
self.bridge.write_response(UserResponse(
request_id=self.request.request_id,
approved=False,
))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = discord.Color.red()
embed.set_footer(text=f"❌ 거부됨 by {interaction.user.display_name}")
await interaction.response.edit_message(embed=embed, view=None)
async def on_timeout(self):
"""Auto-timeout after 5 minutes."""
if not self.responded:
self.bridge.write_response(UserResponse(
request_id=self.request.request_id,
approved=False,
))
def detect_project_name(conv_dir: Path) -> str:
"""Extract a human-readable project name from conversation artifacts.
@@ -133,6 +184,8 @@ class GravityBot(commands.Bot):
self.session_names: dict[str, str] = {}
# Locks to prevent duplicate channel creation
self._channel_locks: dict[str, asyncio.Lock] = {}
# Bridge protocol for bidirectional communication
self.bridge = BridgeProtocol()
# Category for session channels
self.session_category: discord.CategoryChannel | None = None
# Guild reference
@@ -142,7 +195,8 @@ class GravityBot(commands.Bot):
"""Called after login, before processing events."""
self.loop.create_task(self._process_events())
self.session_cleanup_loop.start()
logger.info("Bot setup complete, event processor started")
self.pending_approval_scanner.start()
logger.info("Bot setup complete, event processor + approval scanner started")
async def on_ready(self):
"""Called when bot is ready."""
@@ -316,7 +370,7 @@ class GravityBot(commands.Bot):
async def _send_artifact_content(
self, channel: discord.TextChannel, event: BrainEvent
):
"""Send artifact file content as Discord text messages."""
"""Send artifact change notification (compact — metadata handler sends summary)."""
labels = {
"implementation_plan.md": "📐 구현 계획",
"walkthrough.md": "📝 작업 결과 요약",
@@ -324,13 +378,23 @@ class GravityBot(commands.Bot):
label = labels.get(event.file_name, f"📄 {event.file_name}")
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
await channel.send(f"**{label} ({event_label}됨)**")
# Only send first few lines as preview instead of full content
lines = event.content.strip().splitlines()
preview_lines = []
for line in lines[:8]:
if line.strip():
preview_lines.append(line)
preview = "\n".join(preview_lines)
if len(lines) > 8:
preview += f"\n... (+{len(lines) - 8} lines)"
chunks = md_to_discord_text(event.content)
for chunk in chunks:
if chunk.strip():
await channel.send(chunk)
await asyncio.sleep(0.5)
embed = discord.Embed(
title=f"{label} ({event_label}됨)",
description=preview[:1000],
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(embed=embed)
async def _send_metadata_update(
self, channel: discord.TextChannel, event: BrainEvent
@@ -404,3 +468,92 @@ class GravityBot(commands.Bot):
@session_cleanup_loop.before_loop
async def before_cleanup(self):
await self.wait_until_ready()
@tasks.loop(seconds=3)
async def pending_approval_scanner(self):
"""Scan bridge/pending/ for new approval requests and send Discord buttons."""
try:
requests = self.bridge.get_pending_requests()
for req in requests:
# Find the right channel
if req.conversation_id in self.session_channels:
channel = self.session_channels[req.conversation_id]
elif req.discord_message_id == 0:
# Try to find channel by conversation_id
conv_dir = Config.BRAIN_PATH / req.conversation_id
if conv_dir.exists():
project_name = detect_project_name(conv_dir)
channel = await self._ensure_channel(req.conversation_id, project_name)
else:
continue
else:
continue
if channel and req.discord_message_id == 0:
await self._send_approval_request(channel, req)
except Exception as e:
logger.error(f"Error scanning pending 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
):
"""Send an approval request with buttons to Discord."""
embed = discord.Embed(
title="⚠️ 승인 요청",
description=(
f"**명령어:**\n```\n{request.command[:1000]}\n```\n"
f"{request.description[:500]}"
),
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"ID: {request.request_id}")
view = ApprovalView(self.bridge, request)
msg = await channel.send(embed=embed, view=view)
# Update pending file with discord message id
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists():
try:
data = json.loads(pending_file.read_text(encoding="utf-8"))
data["discord_message_id"] = msg.id
pending_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
except (json.JSONDecodeError, OSError):
pass
logger.info(f"Sent approval request: {request.request_id[:8]}")
async def on_message(self, message: discord.Message):
"""Handle user messages in AG channels → relay as text input."""
# Ignore bot's own messages
if message.author == self.user:
return
# Check if message is in an AG session channel
if not message.channel.name.startswith(Config.CHANNEL_PREFIX + "-"):
return
# Find conversation_id for this channel
conv_id = None
for cid, ch in self.session_channels.items():
if ch.id == message.channel.id:
conv_id = cid
break
if conv_id and message.content.strip():
# Write user input to bridge commands
cmd_id = self.bridge.write_command(conv_id, message.content.strip())
await message.add_reaction("📨")
logger.info(f"User input relayed: {message.content[:50]}... → {conv_id[:8]}")
# Process commands (e.g., !approve, !reject)
await self.process_commands(message)