From 5d95185ecde74f3a7516a0eabb1a52640d61b0d6 Mon Sep 17 00:00:00 2001 From: CD Date: Sat, 7 Mar 2026 11:12:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase2):=20=EC=8A=B9=EC=9D=B8/=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=20=EB=B2=84=ED=8A=BC=20+=20bridge=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20+=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EB=B3=80=ED=99=98=20=EC=88=98=EC=A0=95=20+=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++--- bridge.py | 127 ++++++++++++++++++++++++++++++++++++++++ parser.py | 27 +++++++++ 3 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 bridge.py diff --git a/bot.py b/bot.py index c261632..0e812f5 100644 --- a/bot.py +++ b/bot.py @@ -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) + diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..192be4e --- /dev/null +++ b/bridge.py @@ -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 diff --git a/parser.py b/parser.py index 363b56f..988e875 100644 --- a/parser.py +++ b/parser.py @@ -87,10 +87,21 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]: output_lines = [] in_mermaid = 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: # Skip mermaid blocks if re.match(r'^```mermaid', line): + flush_table() in_mermaid = True output_lines.append("*(mermaid 다이어그램 생략)*") continue @@ -101,6 +112,7 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]: # Track code blocks if re.match(r'^```', line) and not in_mermaid: + flush_table() in_code_block = not in_code_block output_lines.append(line) continue @@ -111,8 +123,21 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]: # Skip HTML comments if re.match(r'^\s*\s*$', line): + flush_table() 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 alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line) if alert_match: @@ -153,6 +178,8 @@ def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]: # Pass through everything else output_lines.append(line) + flush_table() + # Join and split into chunks full_text = "\n".join(output_lines).strip() return split_text(full_text, max_length)