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.
|
||||
|
||||
Single project channel design:
|
||||
- ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control)
|
||||
- ALL conversations route to this single channel
|
||||
- Uses guild.fetch_channels() API, NOT cached text_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
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -81,9 +82,10 @@ class ApprovalView(discord.ui.View):
|
||||
class GravityBot(commands.Bot):
|
||||
"""Discord bot for Antigravity session monitoring.
|
||||
|
||||
Single-channel architecture:
|
||||
- ONE channel per project (ag-gravity_control)
|
||||
- self.project_channel is the singleton — trivially prevents duplication
|
||||
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):
|
||||
@@ -93,19 +95,21 @@ class GravityBot(commands.Bot):
|
||||
super().__init__(command_prefix="!", intents=intents)
|
||||
|
||||
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._sent_approval_ids: set[str] = set()
|
||||
self._ready_event = asyncio.Event()
|
||||
self._channel_lock = asyncio.Lock() # Prevents double-create race
|
||||
self._channel_lock = asyncio.Lock()
|
||||
self.bridge = BridgeProtocol()
|
||||
self.session_category: discord.CategoryChannel | None = None
|
||||
self.guild: discord.Guild | None = None
|
||||
|
||||
@property
|
||||
def _channel_name(self) -> str:
|
||||
"""The ONE channel name: ag-gravity_control (lowercase)."""
|
||||
return f"{Config.CHANNEL_PREFIX}-{Config.PROJECT_NAME}".lower()
|
||||
@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())
|
||||
@@ -133,95 +137,69 @@ class GravityBot(commands.Bot):
|
||||
logger.error("No permission to create category!")
|
||||
return
|
||||
|
||||
# Find the project channel + cleanup duplicates
|
||||
await self._init_project_channel()
|
||||
# Discover existing project channels
|
||||
await self._discover_channels()
|
||||
|
||||
# Open the gate
|
||||
self._ready_event.set()
|
||||
logger.info("Ready gate opened — event processing enabled")
|
||||
|
||||
# ─── Channel Init (ONE channel, guild.fetch_channels API) ────────
|
||||
# ─── Channel Management ──────────────────────────────────────────
|
||||
|
||||
async def _init_project_channel(self):
|
||||
"""Find or create the single project channel. Delete any duplicates.
|
||||
|
||||
Uses guild.fetch_channels() — the REAL Discord API, not the cache.
|
||||
"""
|
||||
target_name = self._channel_name
|
||||
|
||||
# Fetch ALL channels from Discord API (not cache)
|
||||
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() + "-"
|
||||
|
||||
matches: list[discord.TextChannel] = []
|
||||
for ch in all_channels:
|
||||
if (isinstance(ch, discord.TextChannel)
|
||||
and ch.category_id == self.session_category.id
|
||||
and ch.name == target_name):
|
||||
matches.append(ch)
|
||||
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}")
|
||||
|
||||
if matches:
|
||||
# Keep the first, delete the rest
|
||||
self.project_channel = matches[0]
|
||||
logger.info(f"Found project channel: #{target_name} (id={self.project_channel.id})")
|
||||
logger.info(f"Discovered {len(self.project_channels)} project channels")
|
||||
|
||||
for dup in matches[1:]:
|
||||
try:
|
||||
await dup.delete(reason="Duplicate project channel cleanup")
|
||||
logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})")
|
||||
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 def _get_channel(self, project_name: str) -> discord.TextChannel:
|
||||
"""Get or create a channel for a project. Lock-protected."""
|
||||
if project_name in self.project_channels:
|
||||
return self.project_channels[project_name]
|
||||
|
||||
async with self._channel_lock:
|
||||
# Double-check after acquiring lock
|
||||
if self.project_channel:
|
||||
return self.project_channel
|
||||
# Double-check
|
||||
if project_name in self.project_channels:
|
||||
return self.project_channels[project_name]
|
||||
|
||||
# Create the channel
|
||||
channel_name = self._make_channel_name(project_name)
|
||||
try:
|
||||
self.project_channel = await self.guild.create_text_channel(
|
||||
name=self._channel_name,
|
||||
ch = await self.guild.create_text_channel(
|
||||
name=channel_name,
|
||||
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(
|
||||
title=f"🚀 {Config.PROJECT_NAME}",
|
||||
description=(
|
||||
f"Antigravity Bridge 연결됨\n"
|
||||
f"모든 세션 이벤트가 이 채널로 전달됩니다."
|
||||
),
|
||||
title=f"🚀 {project_name}",
|
||||
description=f"Antigravity Bridge 연결됨",
|
||||
color=discord.Color.blue(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
await self.project_channel.send(embed=embed)
|
||||
await ch.send(embed=embed)
|
||||
return ch
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -243,14 +221,13 @@ class GravityBot(commands.Bot):
|
||||
logger.error(f"Error processing event: {e}", exc_info=True)
|
||||
|
||||
async def _handle_event(self, event: BrainEvent):
|
||||
"""Route brain events to the single project channel."""
|
||||
if event.event_type == EventType.SESSION_START:
|
||||
# Just ensure channel exists, no message needed
|
||||
await self._get_project_channel()
|
||||
"""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
|
||||
|
||||
channel = await self._get_project_channel()
|
||||
if not channel:
|
||||
if event.event_type == EventType.SESSION_START:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -259,15 +236,14 @@ class GravityBot(commands.Bot):
|
||||
else:
|
||||
await self._send_artifact_update(channel, event)
|
||||
except discord.NotFound:
|
||||
self.project_channel = None # Channel was deleted, recreate next time
|
||||
logger.warning("Project channel was deleted, will recreate")
|
||||
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
|
||||
):
|
||||
"""Send/edit task progress embed (ONE message per conv_id, always edited)."""
|
||||
progress = parse_task_progress(event.content)
|
||||
|
||||
embed = discord.Embed(
|
||||
@@ -280,7 +256,6 @@ class GravityBot(commands.Bot):
|
||||
)
|
||||
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)
|
||||
if msg_id:
|
||||
try:
|
||||
@@ -296,7 +271,6 @@ class GravityBot(commands.Bot):
|
||||
async def _send_artifact_update(
|
||||
self, channel: discord.TextChannel, event: BrainEvent
|
||||
):
|
||||
"""Send artifact update as single compact embed (preview only)."""
|
||||
labels = {
|
||||
"implementation_plan.md": "📐 구현 계획",
|
||||
"walkthrough.md": "📝 작업 결과 요약",
|
||||
@@ -304,7 +278,6 @@ class GravityBot(commands.Bot):
|
||||
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
||||
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
|
||||
|
||||
# Preview: first 6 non-empty lines only
|
||||
lines = event.content.strip().splitlines()
|
||||
preview = "\n".join(l for l in lines[:6] if l.strip())
|
||||
if len(lines) > 6:
|
||||
@@ -332,7 +305,12 @@ class GravityBot(commands.Bot):
|
||||
if req.discord_message_id != 0:
|
||||
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:
|
||||
self._sent_approval_ids.add(req.request_id)
|
||||
await self._send_approval_request(channel, req)
|
||||
@@ -360,11 +338,10 @@ class GravityBot(commands.Bot):
|
||||
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 = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
||||
data["discord_message_id"] = msg.id
|
||||
pending_file.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
@@ -372,16 +349,17 @@ class GravityBot(commands.Bot):
|
||||
except (json.JSONDecodeError, OSError):
|
||||
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):
|
||||
if message.author == self.user:
|
||||
return
|
||||
|
||||
# Only respond in the project channel
|
||||
if not self.project_channel or message.channel.id != self.project_channel.id:
|
||||
# Determine project from channel
|
||||
project = self.channel_to_project.get(message.channel.id)
|
||||
if not project:
|
||||
await self.process_commands(message)
|
||||
return
|
||||
|
||||
@@ -389,13 +367,13 @@ class GravityBot(commands.Bot):
|
||||
|
||||
# Special command: !auto on/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"
|
||||
emoji = "🟢" if enabled else "🔴"
|
||||
mode = "자동 승인" if enabled else "수동 승인"
|
||||
embed = discord.Embed(
|
||||
title=f"{emoji} {mode} 모드",
|
||||
description=f"Antigravity IDE 설정이 변경됩니다.\n"
|
||||
description=f"프로젝트: **{project}**\n"
|
||||
f"`chat.tools.autoApprove = {enabled}`\n"
|
||||
f"`chat.agent.autoApprove = {enabled}`",
|
||||
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||
@@ -403,9 +381,9 @@ class GravityBot(commands.Bot):
|
||||
await message.channel.send(embed=embed)
|
||||
return
|
||||
|
||||
# General text relay (broadcast to most recent session or global)
|
||||
# General text relay — routed by project
|
||||
if text:
|
||||
self.bridge.write_command("__global__", text)
|
||||
self.bridge.write_command(project, text, project_name=project)
|
||||
await message.add_reaction("📨")
|
||||
|
||||
await self.process_commands(message)
|
||||
|
||||
Reference in New Issue
Block a user