feat(phase2): 승인/거부 버튼 + bridge 프로토콜 + 테이블 변환 수정 + 중복 메시지 해결
This commit is contained in:
169
bot.py
169
bot.py
@@ -25,10 +25,61 @@ from parser import (
|
|||||||
format_task_embed_text,
|
format_task_embed_text,
|
||||||
)
|
)
|
||||||
from watcher import BrainEvent, EventType
|
from watcher import BrainEvent, EventType
|
||||||
|
from bridge import BridgeProtocol, ApprovalRequest, UserResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def detect_project_name(conv_dir: Path) -> str:
|
||||||
"""Extract a human-readable project name from conversation artifacts.
|
"""Extract a human-readable project name from conversation artifacts.
|
||||||
|
|
||||||
@@ -133,6 +184,8 @@ class GravityBot(commands.Bot):
|
|||||||
self.session_names: dict[str, str] = {}
|
self.session_names: dict[str, str] = {}
|
||||||
# Locks to prevent duplicate channel creation
|
# Locks to prevent duplicate channel creation
|
||||||
self._channel_locks: dict[str, asyncio.Lock] = {}
|
self._channel_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
# Bridge protocol for bidirectional communication
|
||||||
|
self.bridge = BridgeProtocol()
|
||||||
# Category for session channels
|
# Category for session channels
|
||||||
self.session_category: discord.CategoryChannel | None = None
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
# Guild reference
|
# Guild reference
|
||||||
@@ -142,7 +195,8 @@ class GravityBot(commands.Bot):
|
|||||||
"""Called after login, before processing events."""
|
"""Called after login, before processing events."""
|
||||||
self.loop.create_task(self._process_events())
|
self.loop.create_task(self._process_events())
|
||||||
self.session_cleanup_loop.start()
|
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):
|
async def on_ready(self):
|
||||||
"""Called when bot is ready."""
|
"""Called when bot is ready."""
|
||||||
@@ -316,7 +370,7 @@ class GravityBot(commands.Bot):
|
|||||||
async def _send_artifact_content(
|
async def _send_artifact_content(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
):
|
):
|
||||||
"""Send artifact file content as Discord text messages."""
|
"""Send artifact change notification (compact — metadata handler sends summary)."""
|
||||||
labels = {
|
labels = {
|
||||||
"implementation_plan.md": "📐 구현 계획",
|
"implementation_plan.md": "📐 구현 계획",
|
||||||
"walkthrough.md": "📝 작업 결과 요약",
|
"walkthrough.md": "📝 작업 결과 요약",
|
||||||
@@ -324,13 +378,23 @@ class GravityBot(commands.Bot):
|
|||||||
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
||||||
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
|
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)
|
embed = discord.Embed(
|
||||||
for chunk in chunks:
|
title=f"{label} ({event_label}됨)",
|
||||||
if chunk.strip():
|
description=preview[:1000],
|
||||||
await channel.send(chunk)
|
color=discord.Color.blue(),
|
||||||
await asyncio.sleep(0.5)
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
async def _send_metadata_update(
|
async def _send_metadata_update(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
@@ -404,3 +468,92 @@ class GravityBot(commands.Bot):
|
|||||||
@session_cleanup_loop.before_loop
|
@session_cleanup_loop.before_loop
|
||||||
async def before_cleanup(self):
|
async def before_cleanup(self):
|
||||||
await self.wait_until_ready()
|
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)
|
||||||
|
|
||||||
|
|||||||
127
bridge.py
Normal file
127
bridge.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Bridge protocol — file-based communication between Discord bot and Antigravity.
|
||||||
|
|
||||||
|
Bridge directory: ~/.gemini/antigravity/bridge/
|
||||||
|
Structure:
|
||||||
|
bridge/
|
||||||
|
pending/ ← Bot writes approval requests for Discord
|
||||||
|
response/ ← Bot writes user responses from Discord
|
||||||
|
commands/ ← Bot writes user text input from Discord
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. VS Code Extension detects pending approval → writes JSON to pending/
|
||||||
|
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
|
||||||
|
3. User clicks button → Bot writes JSON to response/
|
||||||
|
4. VS Code Extension reads response/ → executes action
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApprovalRequest:
|
||||||
|
"""An approval request from Antigravity."""
|
||||||
|
request_id: str
|
||||||
|
conversation_id: str
|
||||||
|
command: str # The command/action needing approval
|
||||||
|
description: str # Human-readable description
|
||||||
|
timestamp: float
|
||||||
|
status: str = "pending"
|
||||||
|
discord_message_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserResponse:
|
||||||
|
"""A user response from Discord."""
|
||||||
|
request_id: str
|
||||||
|
approved: bool
|
||||||
|
user_input: str = ""
|
||||||
|
timestamp: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeProtocol:
|
||||||
|
"""Manages the file-based bridge protocol."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bridge_dir = Config.BRAIN_PATH.parent / "bridge"
|
||||||
|
self.pending_dir = self.bridge_dir / "pending"
|
||||||
|
self.response_dir = self.bridge_dir / "response"
|
||||||
|
self.commands_dir = self.bridge_dir / "commands"
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
for d in [self.pending_dir, self.response_dir, self.commands_dir]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Bridge protocol initialized: {self.bridge_dir}")
|
||||||
|
|
||||||
|
def get_pending_requests(self) -> list[ApprovalRequest]:
|
||||||
|
"""Read all pending approval requests."""
|
||||||
|
requests = []
|
||||||
|
for f in self.pending_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
if data.get("status") == "pending":
|
||||||
|
requests.append(ApprovalRequest(**data))
|
||||||
|
except (json.JSONDecodeError, TypeError, OSError) as e:
|
||||||
|
logger.warning(f"Bad pending request {f.name}: {e}")
|
||||||
|
return requests
|
||||||
|
|
||||||
|
def write_response(self, response: UserResponse):
|
||||||
|
"""Write a user response to the response directory."""
|
||||||
|
response.timestamp = time.time()
|
||||||
|
filename = f"{response.request_id}.json"
|
||||||
|
filepath = self.response_dir / filename
|
||||||
|
|
||||||
|
filepath.write_text(
|
||||||
|
json.dumps(asdict(response), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
logger.info(f"Response written: {filename} (approved={response.approved})")
|
||||||
|
|
||||||
|
# Mark pending request as processed
|
||||||
|
pending_file = self.pending_dir / filename
|
||||||
|
if pending_file.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||||
|
data["status"] = "approved" if response.approved else "rejected"
|
||||||
|
pending_file.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def write_command(self, conversation_id: str, text: str):
|
||||||
|
"""Write a user text command for Antigravity to consume."""
|
||||||
|
cmd_id = f"{int(time.time() * 1000)}"
|
||||||
|
filepath = self.commands_dir / f"{cmd_id}.json"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"id": cmd_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"text": text,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"consumed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
filepath.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
logger.info(f"Command written: {cmd_id} for {conversation_id[:8]}")
|
||||||
|
return cmd_id
|
||||||
27
parser.py
27
parser.py
@@ -87,10 +87,21 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
|||||||
output_lines = []
|
output_lines = []
|
||||||
in_mermaid = False
|
in_mermaid = False
|
||||||
in_code_block = False
|
in_code_block = False
|
||||||
|
table_buffer = []
|
||||||
|
|
||||||
|
def flush_table():
|
||||||
|
"""Convert buffered table rows to a code block."""
|
||||||
|
if table_buffer:
|
||||||
|
output_lines.append("```")
|
||||||
|
for row in table_buffer:
|
||||||
|
output_lines.append(row)
|
||||||
|
output_lines.append("```")
|
||||||
|
table_buffer.clear()
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
# Skip mermaid blocks
|
# Skip mermaid blocks
|
||||||
if re.match(r'^```mermaid', line):
|
if re.match(r'^```mermaid', line):
|
||||||
|
flush_table()
|
||||||
in_mermaid = True
|
in_mermaid = True
|
||||||
output_lines.append("*(mermaid 다이어그램 생략)*")
|
output_lines.append("*(mermaid 다이어그램 생략)*")
|
||||||
continue
|
continue
|
||||||
@@ -101,6 +112,7 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
|||||||
|
|
||||||
# Track code blocks
|
# Track code blocks
|
||||||
if re.match(r'^```', line) and not in_mermaid:
|
if re.match(r'^```', line) and not in_mermaid:
|
||||||
|
flush_table()
|
||||||
in_code_block = not in_code_block
|
in_code_block = not in_code_block
|
||||||
output_lines.append(line)
|
output_lines.append(line)
|
||||||
continue
|
continue
|
||||||
@@ -111,8 +123,21 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
|||||||
|
|
||||||
# Skip HTML comments
|
# Skip HTML comments
|
||||||
if re.match(r'^\s*<!--.*-->\s*$', line):
|
if re.match(r'^\s*<!--.*-->\s*$', line):
|
||||||
|
flush_table()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Detect table rows (lines with | separators)
|
||||||
|
if re.match(r'^\s*\|', line):
|
||||||
|
# Skip separator rows (|---|---|)
|
||||||
|
if re.match(r'^\s*\|[\s\-:|]+\|\s*$', line):
|
||||||
|
continue
|
||||||
|
# Clean up table row
|
||||||
|
cells = [c.strip() for c in line.strip().strip('|').split('|')]
|
||||||
|
table_buffer.append(" ".join(cells))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
flush_table()
|
||||||
|
|
||||||
# Skip alert syntax but keep content
|
# Skip alert syntax but keep content
|
||||||
alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line)
|
alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line)
|
||||||
if alert_match:
|
if alert_match:
|
||||||
@@ -153,6 +178,8 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
|||||||
# Pass through everything else
|
# Pass through everything else
|
||||||
output_lines.append(line)
|
output_lines.append(line)
|
||||||
|
|
||||||
|
flush_table()
|
||||||
|
|
||||||
# Join and split into chunks
|
# Join and split into chunks
|
||||||
full_text = "\n".join(output_lines).strip()
|
full_text = "\n".join(output_lines).strip()
|
||||||
return split_text(full_text, max_length)
|
return split_text(full_text, max_length)
|
||||||
|
|||||||
Reference in New Issue
Block a user