feat: multi-project routing — project_name in bridge, per-project channels, extension filtering
This commit is contained in:
176
bot.py
176
bot.py
@@ -1,9 +1,10 @@
|
|||||||
"""Discord bot — relays Antigravity brain events to Discord channels.
|
"""Discord bot — relays Antigravity brain events to Discord channels.
|
||||||
|
|
||||||
Single project channel design:
|
Multi-project channel architecture:
|
||||||
- ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control)
|
- One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva)
|
||||||
- ALL conversations route to this single channel
|
- Each conversation maps to a project via conv_to_project dict
|
||||||
- Uses guild.fetch_channels() API, NOT cached text_channels
|
- Extension registers projects via bridge/pending/ files
|
||||||
|
- Commands include project_name for routing to correct IDE window
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -81,9 +82,10 @@ class ApprovalView(discord.ui.View):
|
|||||||
class GravityBot(commands.Bot):
|
class GravityBot(commands.Bot):
|
||||||
"""Discord bot for Antigravity session monitoring.
|
"""Discord bot for Antigravity session monitoring.
|
||||||
|
|
||||||
Single-channel architecture:
|
Multi-project architecture:
|
||||||
- ONE channel per project (ag-gravity_control)
|
- project_channels: project_name → TextChannel (ag-gravity_control, ag-deriva, etc.)
|
||||||
- self.project_channel is the singleton — trivially prevents duplication
|
- 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):
|
def __init__(self, event_queue: asyncio.Queue):
|
||||||
@@ -93,19 +95,21 @@ class GravityBot(commands.Bot):
|
|||||||
super().__init__(command_prefix="!", intents=intents)
|
super().__init__(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.project_channel: discord.TextChannel | None = None # THE channel
|
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.session_status_messages: dict[str, int] = {} # conv_id → msg_id
|
||||||
self._sent_approval_ids: set[str] = set()
|
self._sent_approval_ids: set[str] = set()
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
self._channel_lock = asyncio.Lock() # Prevents double-create race
|
self._channel_lock = asyncio.Lock()
|
||||||
self.bridge = BridgeProtocol()
|
self.bridge = BridgeProtocol()
|
||||||
self.session_category: discord.CategoryChannel | None = None
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
self.guild: discord.Guild | None = None
|
self.guild: discord.Guild | None = None
|
||||||
|
|
||||||
@property
|
@staticmethod
|
||||||
def _channel_name(self) -> str:
|
def _make_channel_name(project_name: str) -> str:
|
||||||
"""The ONE channel name: ag-gravity_control (lowercase)."""
|
"""ag-gravity_control, ag-deriva, etc."""
|
||||||
return f"{Config.CHANNEL_PREFIX}-{Config.PROJECT_NAME}".lower()
|
return f"{Config.CHANNEL_PREFIX}-{project_name}".lower()
|
||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
self.loop.create_task(self._process_events())
|
self.loop.create_task(self._process_events())
|
||||||
@@ -133,95 +137,69 @@ class GravityBot(commands.Bot):
|
|||||||
logger.error("No permission to create category!")
|
logger.error("No permission to create category!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the project channel + cleanup duplicates
|
# Discover existing project channels
|
||||||
await self._init_project_channel()
|
await self._discover_channels()
|
||||||
|
|
||||||
# Open the gate
|
# Open the gate
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
logger.info("Ready gate opened — event processing enabled")
|
logger.info("Ready gate opened — event processing enabled")
|
||||||
|
|
||||||
# ─── Channel Init (ONE channel, guild.fetch_channels API) ────────
|
# ─── Channel Management ──────────────────────────────────────────
|
||||||
|
|
||||||
async def _init_project_channel(self):
|
async def _discover_channels(self):
|
||||||
"""Find or create the single project channel. Delete any duplicates.
|
"""Find existing project channels via Discord API (not cache)."""
|
||||||
|
|
||||||
Uses guild.fetch_channels() — the REAL Discord API, not the cache.
|
|
||||||
"""
|
|
||||||
target_name = self._channel_name
|
|
||||||
|
|
||||||
# Fetch ALL channels from Discord API (not cache)
|
|
||||||
all_channels = await self.guild.fetch_channels()
|
all_channels = await self.guild.fetch_channels()
|
||||||
|
prefix = Config.CHANNEL_PREFIX.lower() + "-"
|
||||||
|
|
||||||
matches: list[discord.TextChannel] = []
|
|
||||||
for ch in all_channels:
|
for ch in all_channels:
|
||||||
if (isinstance(ch, discord.TextChannel)
|
if (isinstance(ch, discord.TextChannel)
|
||||||
and ch.category_id == self.session_category.id
|
and ch.category_id == self.session_category.id
|
||||||
and ch.name == target_name):
|
and ch.name.startswith(prefix)):
|
||||||
matches.append(ch)
|
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}")
|
||||||
|
|
||||||
if matches:
|
logger.info(f"Discovered {len(self.project_channels)} project channels")
|
||||||
# Keep the first, delete the rest
|
|
||||||
self.project_channel = matches[0]
|
|
||||||
logger.info(f"Found project channel: #{target_name} (id={self.project_channel.id})")
|
|
||||||
|
|
||||||
for dup in matches[1:]:
|
async def _get_channel(self, project_name: str) -> discord.TextChannel:
|
||||||
try:
|
"""Get or create a channel for a project. Lock-protected."""
|
||||||
await dup.delete(reason="Duplicate project channel cleanup")
|
if project_name in self.project_channels:
|
||||||
logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})")
|
return self.project_channels[project_name]
|
||||||
except (discord.Forbidden, discord.HTTPException) as e:
|
|
||||||
logger.warning(f"Failed to delete duplicate: {e}")
|
|
||||||
|
|
||||||
# Also delete any OLD-style channels with different names
|
|
||||||
for ch in all_channels:
|
|
||||||
if (isinstance(ch, discord.TextChannel)
|
|
||||||
and ch.category_id == self.session_category.id
|
|
||||||
and ch.name != target_name
|
|
||||||
and ch.topic and "Antigravity Session:" in ch.topic):
|
|
||||||
try:
|
|
||||||
await ch.delete(reason="Old-style channel cleanup")
|
|
||||||
logger.info(f"Deleted old channel: #{ch.name}")
|
|
||||||
except (discord.Forbidden, discord.HTTPException) as e:
|
|
||||||
logger.warning(f"Failed to delete old channel: {e}")
|
|
||||||
else:
|
|
||||||
logger.info(f"No existing project channel found. Will create on first event.")
|
|
||||||
|
|
||||||
async def _get_project_channel(self) -> discord.TextChannel:
|
|
||||||
"""Get the project channel. Create if it doesn't exist yet.
|
|
||||||
|
|
||||||
Uses asyncio.Lock to prevent race between event processor
|
|
||||||
and approval scanner both creating channels simultaneously.
|
|
||||||
"""
|
|
||||||
if self.project_channel:
|
|
||||||
return self.project_channel
|
|
||||||
|
|
||||||
async with self._channel_lock:
|
async with self._channel_lock:
|
||||||
# Double-check after acquiring lock
|
# Double-check
|
||||||
if self.project_channel:
|
if project_name in self.project_channels:
|
||||||
return self.project_channel
|
return self.project_channels[project_name]
|
||||||
|
|
||||||
# Create the channel
|
channel_name = self._make_channel_name(project_name)
|
||||||
try:
|
try:
|
||||||
self.project_channel = await self.guild.create_text_channel(
|
ch = await self.guild.create_text_channel(
|
||||||
name=self._channel_name,
|
name=channel_name,
|
||||||
category=self.session_category,
|
category=self.session_category,
|
||||||
topic=f"Gravity Control — Antigravity Bridge",
|
topic=f"Antigravity Bridge — {project_name}",
|
||||||
)
|
)
|
||||||
logger.info(f"Created project channel: #{self._channel_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(
|
embed = discord.Embed(
|
||||||
title=f"🚀 {Config.PROJECT_NAME}",
|
title=f"🚀 {project_name}",
|
||||||
description=(
|
description=f"Antigravity Bridge 연결됨",
|
||||||
f"Antigravity Bridge 연결됨\n"
|
|
||||||
f"모든 세션 이벤트가 이 채널로 전달됩니다."
|
|
||||||
),
|
|
||||||
color=discord.Color.blue(),
|
color=discord.Color.blue(),
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
await self.project_channel.send(embed=embed)
|
await ch.send(embed=embed)
|
||||||
|
return ch
|
||||||
except discord.errors.Forbidden:
|
except discord.errors.Forbidden:
|
||||||
logger.error(f"No permission to create channel: {self._channel_name}")
|
logger.error(f"No permission to create channel: {channel_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
return self.project_channel
|
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 ─────────────────────────────────────────────
|
# ─── Event Processing ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -243,14 +221,13 @@ class GravityBot(commands.Bot):
|
|||||||
logger.error(f"Error processing event: {e}", exc_info=True)
|
logger.error(f"Error processing event: {e}", exc_info=True)
|
||||||
|
|
||||||
async def _handle_event(self, event: BrainEvent):
|
async def _handle_event(self, event: BrainEvent):
|
||||||
"""Route brain events to the single project channel."""
|
"""Route brain events to the correct project channel."""
|
||||||
if event.event_type == EventType.SESSION_START:
|
project = self._resolve_project(event.conversation_id)
|
||||||
# Just ensure channel exists, no message needed
|
channel = await self._get_channel(project)
|
||||||
await self._get_project_channel()
|
if not channel:
|
||||||
return
|
return
|
||||||
|
|
||||||
channel = await self._get_project_channel()
|
if event.event_type == EventType.SESSION_START:
|
||||||
if not channel:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -259,15 +236,14 @@ class GravityBot(commands.Bot):
|
|||||||
else:
|
else:
|
||||||
await self._send_artifact_update(channel, event)
|
await self._send_artifact_update(channel, event)
|
||||||
except discord.NotFound:
|
except discord.NotFound:
|
||||||
self.project_channel = None # Channel was deleted, recreate next time
|
self.project_channels.pop(project, None)
|
||||||
logger.warning("Project channel was deleted, will recreate")
|
logger.warning(f"Channel deleted for project {project}, will recreate")
|
||||||
|
|
||||||
# ─── Message Senders ─────────────────────────────────────────────
|
# ─── Message Senders ─────────────────────────────────────────────
|
||||||
|
|
||||||
async def _send_task_update(
|
async def _send_task_update(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
):
|
):
|
||||||
"""Send/edit task progress embed (ONE message per conv_id, always edited)."""
|
|
||||||
progress = parse_task_progress(event.content)
|
progress = parse_task_progress(event.content)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -280,7 +256,6 @@ class GravityBot(commands.Bot):
|
|||||||
)
|
)
|
||||||
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
||||||
|
|
||||||
# Try to edit existing message for this conversation
|
|
||||||
msg_id = self.session_status_messages.get(event.conversation_id)
|
msg_id = self.session_status_messages.get(event.conversation_id)
|
||||||
if msg_id:
|
if msg_id:
|
||||||
try:
|
try:
|
||||||
@@ -296,7 +271,6 @@ class GravityBot(commands.Bot):
|
|||||||
async def _send_artifact_update(
|
async def _send_artifact_update(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
):
|
):
|
||||||
"""Send artifact update as single compact embed (preview only)."""
|
|
||||||
labels = {
|
labels = {
|
||||||
"implementation_plan.md": "📐 구현 계획",
|
"implementation_plan.md": "📐 구현 계획",
|
||||||
"walkthrough.md": "📝 작업 결과 요약",
|
"walkthrough.md": "📝 작업 결과 요약",
|
||||||
@@ -304,7 +278,6 @@ 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 "업데이트"
|
||||||
|
|
||||||
# Preview: first 6 non-empty lines only
|
|
||||||
lines = event.content.strip().splitlines()
|
lines = event.content.strip().splitlines()
|
||||||
preview = "\n".join(l for l in lines[:6] if l.strip())
|
preview = "\n".join(l for l in lines[:6] if l.strip())
|
||||||
if len(lines) > 6:
|
if len(lines) > 6:
|
||||||
@@ -332,7 +305,12 @@ class GravityBot(commands.Bot):
|
|||||||
if req.discord_message_id != 0:
|
if req.discord_message_id != 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel = await self._get_project_channel()
|
# Learn project mapping from pending approval
|
||||||
|
project = getattr(req, 'project_name', '') or Config.PROJECT_NAME
|
||||||
|
if req.conversation_id and req.conversation_id != '__global__':
|
||||||
|
self.conv_to_project[req.conversation_id] = project
|
||||||
|
|
||||||
|
channel = await self._get_channel(project)
|
||||||
if channel:
|
if channel:
|
||||||
self._sent_approval_ids.add(req.request_id)
|
self._sent_approval_ids.add(req.request_id)
|
||||||
await self._send_approval_request(channel, req)
|
await self._send_approval_request(channel, req)
|
||||||
@@ -360,11 +338,10 @@ class GravityBot(commands.Bot):
|
|||||||
view = ApprovalView(self.bridge, request)
|
view = ApprovalView(self.bridge, request)
|
||||||
msg = await channel.send(embed=embed, view=view)
|
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"
|
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
||||||
if pending_file.exists():
|
if pending_file.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(pending_file.read_text(encoding="utf-8"))
|
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
||||||
data["discord_message_id"] = msg.id
|
data["discord_message_id"] = msg.id
|
||||||
pending_file.write_text(
|
pending_file.write_text(
|
||||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
@@ -372,16 +349,17 @@ class GravityBot(commands.Bot):
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"Sent approval request: {request.request_id[:8]}")
|
logger.info(f"Sent approval request: {request.request_id[:12]}")
|
||||||
|
|
||||||
# ─── Discord → Antigravity Text Relay ─────────────────────────────
|
# ─── Discord → IDE Text Relay ─────────────────────────────────────
|
||||||
|
|
||||||
async def on_message(self, message: discord.Message):
|
async def on_message(self, message: discord.Message):
|
||||||
if message.author == self.user:
|
if message.author == self.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only respond in the project channel
|
# Determine project from channel
|
||||||
if not self.project_channel or message.channel.id != self.project_channel.id:
|
project = self.channel_to_project.get(message.channel.id)
|
||||||
|
if not project:
|
||||||
await self.process_commands(message)
|
await self.process_commands(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -389,13 +367,13 @@ class GravityBot(commands.Bot):
|
|||||||
|
|
||||||
# Special command: !auto on/off
|
# Special command: !auto on/off
|
||||||
if text in ("!auto on", "!auto off"):
|
if text in ("!auto on", "!auto off"):
|
||||||
self.bridge.write_command("__global__", text)
|
self.bridge.write_command(project, text, project_name=project)
|
||||||
enabled = text == "!auto on"
|
enabled = text == "!auto on"
|
||||||
emoji = "🟢" if enabled else "🔴"
|
emoji = "🟢" if enabled else "🔴"
|
||||||
mode = "자동 승인" if enabled else "수동 승인"
|
mode = "자동 승인" if enabled else "수동 승인"
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"{emoji} {mode} 모드",
|
title=f"{emoji} {mode} 모드",
|
||||||
description=f"Antigravity IDE 설정이 변경됩니다.\n"
|
description=f"프로젝트: **{project}**\n"
|
||||||
f"`chat.tools.autoApprove = {enabled}`\n"
|
f"`chat.tools.autoApprove = {enabled}`\n"
|
||||||
f"`chat.agent.autoApprove = {enabled}`",
|
f"`chat.agent.autoApprove = {enabled}`",
|
||||||
color=discord.Color.green() if enabled else discord.Color.red(),
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||||
@@ -403,9 +381,9 @@ class GravityBot(commands.Bot):
|
|||||||
await message.channel.send(embed=embed)
|
await message.channel.send(embed=embed)
|
||||||
return
|
return
|
||||||
|
|
||||||
# General text relay (broadcast to most recent session or global)
|
# General text relay — routed by project
|
||||||
if text:
|
if text:
|
||||||
self.bridge.write_command("__global__", text)
|
self.bridge.write_command(project, text, project_name=project)
|
||||||
await message.add_reaction("📨")
|
await message.add_reaction("📨")
|
||||||
|
|
||||||
await self.process_commands(message)
|
await self.process_commands(message)
|
||||||
|
|||||||
11
bridge.py
11
bridge.py
@@ -43,6 +43,7 @@ class ApprovalRequest:
|
|||||||
timestamp: float
|
timestamp: float
|
||||||
status: str = "pending"
|
status: str = "pending"
|
||||||
discord_message_id: int = 0
|
discord_message_id: int = 0
|
||||||
|
project_name: str = "" # Project routing key
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -72,11 +73,14 @@ class BridgeProtocol:
|
|||||||
def get_pending_requests(self) -> list[ApprovalRequest]:
|
def get_pending_requests(self) -> list[ApprovalRequest]:
|
||||||
"""Read all pending approval requests."""
|
"""Read all pending approval requests."""
|
||||||
requests = []
|
requests = []
|
||||||
|
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||||
for f in self.pending_dir.glob("*.json"):
|
for f in self.pending_dir.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||||
if data.get("status") == "pending":
|
if data.get("status") == "pending":
|
||||||
requests.append(ApprovalRequest(**data))
|
# Filter to known fields only
|
||||||
|
filtered = {k: v for k, v in data.items() if k in fields}
|
||||||
|
requests.append(ApprovalRequest(**filtered))
|
||||||
except (json.JSONDecodeError, TypeError, OSError) as e:
|
except (json.JSONDecodeError, TypeError, OSError) as e:
|
||||||
logger.warning(f"Bad pending request {f.name}: {e}")
|
logger.warning(f"Bad pending request {f.name}: {e}")
|
||||||
return requests
|
return requests
|
||||||
@@ -106,7 +110,7 @@ class BridgeProtocol:
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def write_command(self, conversation_id: str, text: str):
|
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||||
"""Write a user text command for Antigravity to consume."""
|
"""Write a user text command for Antigravity to consume."""
|
||||||
cmd_id = f"{int(time.time() * 1000)}"
|
cmd_id = f"{int(time.time() * 1000)}"
|
||||||
filepath = self.commands_dir / f"{cmd_id}.json"
|
filepath = self.commands_dir / f"{cmd_id}.json"
|
||||||
@@ -114,6 +118,7 @@ class BridgeProtocol:
|
|||||||
data = {
|
data = {
|
||||||
"id": cmd_id,
|
"id": cmd_id,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"project_name": project_name,
|
||||||
"text": text,
|
"text": text,
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"consumed": False,
|
"consumed": False,
|
||||||
@@ -123,5 +128,5 @@ class BridgeProtocol:
|
|||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
logger.info(f"Command written: {cmd_id} for {conversation_id[:8]}")
|
logger.info(f"Command written: {cmd_id} → project={project_name}")
|
||||||
return cmd_id
|
return cmd_id
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
||||||
|
},
|
||||||
|
"gravityBridge.projectName": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": "프로젝트 이름 (기본: 워크스페이스 폴더명, 예: gravity_control)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Multi-project routing:
|
||||||
* 1. Extension watches for tool approval notifications in VS Code
|
* - Each workspace has a project name (from settings or workspace folder name)
|
||||||
* 2. Writes pending approval to bridge/pending/
|
* - Extension only processes commands/responses matching its project_name
|
||||||
* 3. Discord bot sends buttons to user
|
* - Pending approvals include project_name for Discord channel routing
|
||||||
* 4. User clicks approve/reject
|
|
||||||
* 5. Bot writes response to bridge/response/
|
|
||||||
* 6. Extension reads response → sends keyboard command to approve/reject
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
@@ -18,15 +15,38 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
let watcher: fs.FSWatcher | null = null;
|
let watcher: fs.FSWatcher | null = null;
|
||||||
|
let commandsWatcher: fs.FSWatcher | null = null;
|
||||||
let statusBar: vscode.StatusBarItem;
|
let statusBar: vscode.StatusBarItem;
|
||||||
let bridgePath: string;
|
let bridgePath: string;
|
||||||
|
let projectName: string;
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
|
|
||||||
// Track pending approvals we've already sent
|
// Track pending approvals we've already sent
|
||||||
const sentPendingIds = new Set<string>();
|
const sentPendingIds = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect project name from workspace.
|
||||||
|
* Priority: settings > workspace folder name > fallback
|
||||||
|
*/
|
||||||
|
function detectProjectName(): string {
|
||||||
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
|
const configName = config.get<string>('projectName');
|
||||||
|
if (configName) { return configName; }
|
||||||
|
|
||||||
|
// Use workspace folder name
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
const folderName = folders[0].name;
|
||||||
|
// Convert to snake_case: "antig_web" → "antig_web", "My Project" → "my_project"
|
||||||
|
return folderName.toLowerCase().replace(/[\s\-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown_project';
|
||||||
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
console.log('Gravity Bridge: activating...');
|
projectName = detectProjectName();
|
||||||
|
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
|
||||||
|
|
||||||
// Determine bridge path
|
// Determine bridge path
|
||||||
const config = vscode.workspace.getConfiguration('gravityBridge');
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
@@ -45,8 +65,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
// Status bar
|
// Status bar
|
||||||
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.show();
|
statusBar.show();
|
||||||
context.subscriptions.push(statusBar);
|
context.subscriptions.push(statusBar);
|
||||||
|
|
||||||
@@ -64,18 +84,18 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
function startBridge() {
|
function startBridge() {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
vscode.window.showInformationMessage('Gravity Bridge is already running');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive = true;
|
isActive = true;
|
||||||
statusBar.text = '$(radio-tower) Bridge: On';
|
statusBar.text = `$(radio-tower) ${projectName}: On`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Active';
|
statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
|
||||||
statusBar.command = 'gravityBridge.stop';
|
statusBar.command = 'gravityBridge.stop';
|
||||||
|
|
||||||
// Watch bridge/response/ for Discord user responses
|
// Watch bridge/response/ for Discord user responses
|
||||||
const responsePath = path.join(bridgePath, 'response');
|
const responsePath = path.join(bridgePath, 'response');
|
||||||
const processedFiles = new Set<string>(); // Debounce: prevent double-processing
|
const processedFiles = new Set<string>(); // Debounce
|
||||||
try {
|
try {
|
||||||
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
||||||
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
@@ -92,7 +112,7 @@ function startBridge() {
|
|||||||
// Watch for commands (user text input from Discord)
|
// Watch for commands (user text input from Discord)
|
||||||
const commandsPath = path.join(bridgePath, 'commands');
|
const commandsPath = path.join(bridgePath, 'commands');
|
||||||
try {
|
try {
|
||||||
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
||||||
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
processedFiles.add(filename);
|
processedFiles.add(filename);
|
||||||
setTimeout(() => processedFiles.delete(filename), 2000);
|
setTimeout(() => processedFiles.delete(filename), 2000);
|
||||||
@@ -104,33 +124,30 @@ function startBridge() {
|
|||||||
console.error('Gravity Bridge: failed to watch commands dir', err);
|
console.error('Gravity Bridge: failed to watch commands dir', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Started');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Started`);
|
||||||
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`);
|
console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopBridge() {
|
function stopBridge() {
|
||||||
if (!isActive) { return; }
|
if (!isActive) { return; }
|
||||||
|
|
||||||
isActive = false;
|
isActive = false;
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
|
|
||||||
if (watcher) {
|
if (watcher) { watcher.close(); watcher = null; }
|
||||||
watcher.close();
|
if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; }
|
||||||
watcher = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Stopped');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a response from Discord (approve/reject).
|
* Handle a response from Discord (approve/reject).
|
||||||
* Reads the response JSON and simulates the appropriate action.
|
* Only processes responses — no project filtering needed since request_id is unique.
|
||||||
*/
|
*/
|
||||||
async function handleResponse(filePath: string) {
|
async function handleResponse(filePath: string) {
|
||||||
try {
|
try {
|
||||||
// Small delay to ensure file is fully written
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) { return; }
|
if (!fs.existsSync(filePath)) { return; }
|
||||||
@@ -140,11 +157,9 @@ async function handleResponse(filePath: string) {
|
|||||||
|
|
||||||
if (response.approved === undefined) { return; }
|
if (response.approved === undefined) { return; }
|
||||||
|
|
||||||
console.log(`Gravity Bridge: response received — approved=${response.approved}`);
|
console.log(`Gravity Bridge [${projectName}]: response — approved=${response.approved}`);
|
||||||
|
|
||||||
if (response.approved) {
|
if (response.approved) {
|
||||||
// Simulate pressing Enter or clicking approve
|
|
||||||
// Strategy: Use VS Code command to accept suggestion
|
|
||||||
await simulateApproval();
|
await simulateApproval();
|
||||||
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -152,9 +167,7 @@ async function handleResponse(filePath: string) {
|
|||||||
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: delete the response file after processing
|
|
||||||
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
|
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: error handling response', err);
|
console.error('Gravity Bridge: error handling response', err);
|
||||||
}
|
}
|
||||||
@@ -162,7 +175,7 @@ async function handleResponse(filePath: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a text command from Discord.
|
* Handle a text command from Discord.
|
||||||
* Supports special commands (!auto on/off) and general text relay.
|
* ONLY processes commands matching this project's name.
|
||||||
*/
|
*/
|
||||||
async function handleCommand(filePath: string) {
|
async function handleCommand(filePath: string) {
|
||||||
try {
|
try {
|
||||||
@@ -175,15 +188,21 @@ async function handleCommand(filePath: string) {
|
|||||||
|
|
||||||
if (command.consumed || !command.text) { return; }
|
if (command.consumed || !command.text) { return; }
|
||||||
|
|
||||||
|
// ★ PROJECT FILTER — only process commands for THIS project
|
||||||
|
const cmdProject = command.project_name || '';
|
||||||
|
if (cmdProject && cmdProject !== projectName) {
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: skipping command for "${cmdProject}"`);
|
||||||
|
return; // Not for us — leave file for the correct Extension instance
|
||||||
|
}
|
||||||
|
|
||||||
const text = command.text.trim();
|
const text = command.text.trim();
|
||||||
console.log(`Gravity Bridge: command received — "${text.substring(0, 50)}"`);
|
console.log(`Gravity Bridge [${projectName}]: command — "${text.substring(0, 50)}"`);
|
||||||
|
|
||||||
// Special command: auto-approve toggle
|
// Special command: auto-approve toggle
|
||||||
if (text === '!auto on' || text === '!auto off') {
|
if (text === '!auto on' || text === '!auto off') {
|
||||||
const enabled = text === '!auto on';
|
const enabled = text === '!auto on';
|
||||||
await toggleAutoApprove(enabled);
|
await toggleAutoApprove(enabled);
|
||||||
|
|
||||||
// Mark as consumed
|
|
||||||
command.consumed = true;
|
command.consumed = true;
|
||||||
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
return;
|
return;
|
||||||
@@ -202,7 +221,7 @@ async function handleCommand(filePath: string) {
|
|||||||
command.consumed = true;
|
command.consumed = true;
|
||||||
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
|
|
||||||
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
|
vscode.window.showInformationMessage(`📨 [${projectName}] Discord: ${command.text.substring(0, 50)}...`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: error handling command', err);
|
console.error('Gravity Bridge: error handling command', err);
|
||||||
}
|
}
|
||||||
@@ -215,31 +234,25 @@ async function toggleAutoApprove(enabled: boolean) {
|
|||||||
const config = vscode.workspace.getConfiguration();
|
const config = vscode.workspace.getConfiguration();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Core auto-approve settings
|
|
||||||
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
|
||||||
// Terminal auto-execution
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
|
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
|
||||||
}
|
}
|
||||||
|
|
||||||
// File edits auto-accept
|
|
||||||
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
|
||||||
// Update status bar
|
|
||||||
statusBar.text = enabled
|
statusBar.text = enabled
|
||||||
? '$(radio-tower) Bridge: Auto ✅'
|
? `$(radio-tower) ${projectName}: Auto ✅`
|
||||||
: '$(radio-tower) Bridge: Manual 🔒';
|
: `$(radio-tower) ${projectName}: Manual 🔒`;
|
||||||
|
|
||||||
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
|
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
|
||||||
vscode.window.showInformationMessage(`Gravity Bridge: ${mode}`);
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`);
|
||||||
console.log(`Gravity Bridge: auto-approve set to ${enabled}`);
|
|
||||||
|
|
||||||
// Write status back to bridge for bot to report
|
|
||||||
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
|
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
|
||||||
fs.writeFileSync(statusPath, JSON.stringify({
|
fs.writeFileSync(statusPath, JSON.stringify({
|
||||||
id: `auto-status-${Date.now()}`,
|
id: `auto-status-${Date.now()}`,
|
||||||
|
project_name: projectName,
|
||||||
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
|
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
consumed: true,
|
consumed: true,
|
||||||
@@ -248,40 +261,28 @@ async function toggleAutoApprove(enabled: boolean) {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: failed to toggle auto-approve', err);
|
console.error('Gravity Bridge: failed to toggle auto-approve', err);
|
||||||
vscode.window.showErrorMessage(`Auto-approve toggle failed: ${err}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate approval — try multiple strategies.
|
|
||||||
*/
|
|
||||||
async function simulateApproval() {
|
async function simulateApproval() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Try executing the accept command if available
|
|
||||||
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
||||||
} catch {
|
} catch {
|
||||||
// Strategy 2: Send Enter key via type command
|
|
||||||
try {
|
try {
|
||||||
await vscode.commands.executeCommand('type', { text: '\n' });
|
await vscode.commands.executeCommand('type', { text: '\n' });
|
||||||
} catch {
|
} catch {
|
||||||
// Strategy 3: Focus terminal and send Enter
|
|
||||||
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate rejection — try multiple strategies.
|
|
||||||
*/
|
|
||||||
async function simulateRejection() {
|
async function simulateRejection() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Escape key
|
|
||||||
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await vscode.commands.executeCommand('cancelSelection');
|
await vscode.commands.executeCommand('cancelSelection');
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: just notify
|
|
||||||
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +290,6 @@ async function simulateRejection() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual approve/reject from command palette.
|
* Manual approve/reject from command palette.
|
||||||
* Writes a pending request for testing purposes.
|
|
||||||
*/
|
*/
|
||||||
function handleManualAction(approved: boolean) {
|
function handleManualAction(approved: boolean) {
|
||||||
const requestId = `manual-${Date.now()}`;
|
const requestId = `manual-${Date.now()}`;
|
||||||
@@ -304,15 +304,13 @@ function handleManualAction(approved: boolean) {
|
|||||||
|
|
||||||
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
|
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
|
||||||
|
|
||||||
if (approved) {
|
if (approved) { simulateApproval(); }
|
||||||
simulateApproval();
|
else { simulateRejection(); }
|
||||||
} else {
|
|
||||||
simulateRejection();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
||||||
|
* Includes project_name for correct channel routing.
|
||||||
*/
|
*/
|
||||||
export function writePendingApproval(
|
export function writePendingApproval(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
@@ -325,6 +323,7 @@ export function writePendingApproval(
|
|||||||
const request = {
|
const request = {
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
project_name: projectName, // ★ Project routing
|
||||||
command: command,
|
command: command,
|
||||||
description: description,
|
description: description,
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
@@ -335,7 +334,7 @@ export function writePendingApproval(
|
|||||||
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
||||||
sentPendingIds.add(requestId);
|
sentPendingIds.add(requestId);
|
||||||
|
|
||||||
console.log(`Gravity Bridge: pending approval written — ${requestId}`);
|
console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`);
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user