refactor: single project channel - guild.fetch_channels API + project_channel singleton

This commit is contained in:
2026-03-07 13:24:42 +09:00
parent 7c081e70b5
commit efaf29a6d2
2 changed files with 123 additions and 204 deletions

326
bot.py
View File

@@ -1,15 +1,14 @@
"""Discord bot — relays Antigravity brain events to Discord channels. """Discord bot — relays Antigravity brain events to Discord channels.
Dynamic channel management: Single project channel design:
- Creates `AG-{project_name}` channels only when file events arrive - ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control)
- NO startup channel creation — only reconnects to existing Discord channels - ALL conversations route to this single channel
- Archives channels after 10 minutes of inactivity - Uses guild.fetch_channels() API, NOT cached text_channels
""" """
import asyncio import asyncio
import json import json
import logging import logging
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -77,46 +76,15 @@ class ApprovalView(discord.ui.View):
)) ))
# ─── Project Name Detection ─────────────────────────────────────────
def detect_project_name(conv_dir: Path) -> str:
"""Extract project name from conversation artifacts.
Returns: lowercase_with_underscores (e.g. 'gravity_control')
Uses FIRST successful extraction and caches it.
"""
short_id = conv_dir.name[:8]
def _sanitize(raw: str) -> str:
for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획",
"Implementation Plan", "Walkthrough"]:
raw = raw.replace(suffix, "")
raw = raw.strip(" —-")
raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw)
raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower()
return raw[:30] if raw else ""
for fname in ["task.md", "implementation_plan.md"]:
fpath = conv_dir / fname
if fpath.exists():
try:
first_lines = fpath.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines:
match = re.match(r'^#\s+(.+)', line)
if match:
name = _sanitize(match.group(1))
# Require at least 5 chars to avoid short generic names
if name and name != "task" and len(name) >= 5:
return name
except (OSError, UnicodeDecodeError):
pass
return short_id
# ─── Bot ───────────────────────────────────────────────────────────── # ─── Bot ─────────────────────────────────────────────────────────────
class GravityBot(commands.Bot): class GravityBot(commands.Bot):
"""Discord bot for Antigravity session monitoring.""" """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
"""
def __init__(self, event_queue: asyncio.Queue): def __init__(self, event_queue: asyncio.Queue):
intents = discord.Intents.default() intents = discord.Intents.default()
@@ -125,20 +93,23 @@ 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.session_channels: dict[str, discord.TextChannel] = {} self.project_channel: discord.TextChannel | None = None # THE channel
self.session_status_messages: dict[str, int] = {} self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
self.session_names: dict[str, str] = {} self._sent_approval_ids: set[str] = set()
self._channel_create_lock = asyncio.Lock() # SINGLE global lock self._ready_event = asyncio.Event()
self._sent_approval_ids: set[str] = set() # Track sent approvals
self._ready_event = asyncio.Event() # Gate: wait until on_ready finishes
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
def _channel_name(self) -> str:
"""The ONE channel name: ag-gravity_control (lowercase)."""
return f"{Config.CHANNEL_PREFIX}-{Config.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())
self.pending_approval_scanner.start() self.pending_approval_scanner.start()
logger.info("Bot setup complete, event processor started") logger.info("Bot setup complete")
async def on_ready(self): async def on_ready(self):
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
@@ -161,131 +132,97 @@ class GravityBot(commands.Bot):
logger.error("No permission to create category!") logger.error("No permission to create category!")
return return
# ONLY reconnect to existing Discord channels (NO new creation) # Find the project channel + cleanup duplicates
await self._reconnect_existing_channels() await self._init_project_channel()
# NOW allow event processing to begin # 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")
async def _reconnect_existing_channels(self): # ─── Channel Init (ONE channel, guild.fetch_channels API) ────────
"""Scan existing Discord channels and map them — MERGE same-name channels."""
if not self.session_category:
return
# Group channels by normalized name async def _init_project_channel(self):
name_to_channel: dict[str, discord.TextChannel] = {} """Find or create the single project channel. Delete any duplicates.
duplicates: list[discord.TextChannel] = []
for ch in self.session_category.text_channels: Uses guild.fetch_channels() — the REAL Discord API, not the cache.
if ch.topic and "Antigravity Session:" in ch.topic: """
if ch.name in name_to_channel: target_name = self._channel_name
# DUPLICATE — mark for cleanup
duplicates.append(ch)
else:
name_to_channel[ch.name] = ch
# Map the primary channel for each name # Fetch ALL channels from Discord API (not cache)
count = 0 all_channels = await self.guild.fetch_channels()
for ch in name_to_channel.values():
conv_id = ch.topic.replace("Antigravity Session:", "").strip()
if conv_id:
self.session_channels[conv_id] = ch
await self._recover_task_message(ch, conv_id)
count += 1
# Delete duplicate channels matches: list[discord.TextChannel] = []
for ch in duplicates: for ch in all_channels:
try: if (isinstance(ch, discord.TextChannel)
await ch.delete(reason="Duplicate channel cleanup") and ch.category_id == self.session_category.id
logger.info(f"Deleted duplicate channel: #{ch.name}") and ch.name == target_name):
except (discord.Forbidden, discord.HTTPException) as e: matches.append(ch)
logger.warning(f"Failed to delete duplicate #{ch.name}: {e}")
logger.info(f"Reconnected to {count} channels, cleaned {len(duplicates)} duplicates") 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})")
async def _recover_task_message( for dup in matches[1:]:
self, channel: discord.TextChannel, conversation_id: str try:
): await dup.delete(reason="Duplicate project channel cleanup")
if conversation_id in self.session_status_messages: logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})")
return 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.
Thread-safe: only ONE channel will ever be created because
self.project_channel acts as a singleton guard.
"""
if self.project_channel:
return self.project_channel
# Create the channel
try: try:
async for msg in channel.history(limit=10): self.project_channel = await self.guild.create_text_channel(
if msg.author == self.user and msg.embeds: name=self._channel_name,
embed = msg.embeds[0] category=self.session_category,
if embed.title and "Task" in embed.title: topic=f"Gravity Control — Antigravity Bridge",
self.session_status_messages[conversation_id] = msg.id )
return logger.info(f"Created project channel: #{self._channel_name}")
except (discord.Forbidden, discord.HTTPException):
pass
# ─── Channel Management ────────────────────────────────────────── embed = discord.Embed(
title=f"🚀 {Config.PROJECT_NAME}",
description=(
f"Antigravity Bridge 연결됨\n"
f"모든 세션 이벤트가 이 채널로 전달됩니다."
),
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await self.project_channel.send(embed=embed)
except discord.errors.Forbidden:
logger.error(f"No permission to create channel: {self._channel_name}")
async def _ensure_channel( return self.project_channel
self, conversation_id: str, project_name: str
) -> discord.TextChannel:
"""Get or create a channel. ONE channel per conv_id, guaranteed."""
# Fast path: this conv_id already has a channel — ALWAYS return it # ─── Event Processing ─────────────────────────────────────────────
# (even if project name changed; name changes are cosmetic, not worth a new channel)
if conversation_id in self.session_channels:
return self.session_channels[conversation_id]
async with self._channel_create_lock:
# Double-check after lock
if conversation_id in self.session_channels:
return self.session_channels[conversation_id]
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
target_name = channel_name.lower().replace(" ", "-")
# Check ALL mapped channels for same name
for cid, ch in self.session_channels.items():
if ch.name == target_name:
self.session_channels[conversation_id] = ch
self.session_names[conversation_id] = project_name
logger.info(f"Reusing mapped channel #{ch.name} for {conversation_id[:8]}")
return ch
# Check Discord API — maybe channel exists but isn't in our dict
if self.session_category:
for ch in self.session_category.text_channels:
if ch.name == target_name:
self.session_channels[conversation_id] = ch
self.session_names[conversation_id] = project_name
logger.info(f"Found existing Discord channel #{ch.name} for {conversation_id[:8]}")
return ch
# Create new channel (truly no match anywhere)
try:
channel = await self.guild.create_text_channel(
name=channel_name,
category=self.session_category,
topic=f"Antigravity Session: {conversation_id}",
)
self.session_channels[conversation_id] = channel
self.session_names[conversation_id] = project_name
logger.info(f"Created channel #{channel_name}")
embed = discord.Embed(
title=f"🚀 {project_name}",
description=f"Antigravity 세션 연결됨\nSession: `{conversation_id}`",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(embed=embed)
return channel
except discord.errors.Forbidden:
logger.error(f"No permission to create channel: {channel_name}")
return None
# ─── Event Processing (SINGLE ROUTE) ─────────────────────────────
async def _process_events(self): async def _process_events(self):
"""Main event loop — ALL events go through here sequentially.""" """Main event loop — ALL events go through here sequentially."""
await self.wait_until_ready() await self.wait_until_ready()
await self._ready_event.wait() # Wait until on_ready + reconnect completes await self._ready_event.wait()
logger.info("Event processor started (ready gate passed)") logger.info("Event processor started (ready gate passed)")
while not self.is_closed(): while not self.is_closed():
@@ -300,16 +237,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 handlers — single entry point.""" """Route brain events to the single project channel."""
conv_dir = Config.BRAIN_PATH / event.conversation_id
project_name = detect_project_name(conv_dir)
if event.event_type == EventType.SESSION_START: if event.event_type == EventType.SESSION_START:
await self._ensure_channel(event.conversation_id, project_name) # Just ensure channel exists, no message needed
await self._get_project_channel()
return return
# FILE_CREATED or FILE_CHANGED channel = await self._get_project_channel()
channel = await self._ensure_channel(event.conversation_id, project_name)
if not channel: if not channel:
return return
@@ -319,17 +253,15 @@ 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:
# Channel was deleted while we held a reference self.project_channel = None # Channel was deleted, recreate next time
self.session_channels.pop(event.conversation_id, None) logger.warning("Project channel was deleted, will recreate")
self.session_status_messages.pop(event.conversation_id, None)
logger.warning(f"Channel deleted, cleared: {event.conversation_id[:8]}")
# ─── 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 (SINGLE message, always edited).""" """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(
@@ -342,7 +274,7 @@ class GravityBot(commands.Bot):
) )
embed.set_footer(text=f"Session: {event.conversation_id[:8]}") embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
# Always try to edit existing message first # 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:
@@ -378,6 +310,7 @@ class GravityBot(commands.Bot):
color=discord.Color.blue(), color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
await channel.send(embed=embed) await channel.send(embed=embed)
# ─── Approval Scanner ──────────────────────────────────────────── # ─── Approval Scanner ────────────────────────────────────────────
@@ -389,19 +322,11 @@ class GravityBot(commands.Bot):
requests = self.bridge.get_pending_requests() requests = self.bridge.get_pending_requests()
for req in requests: for req in requests:
if req.request_id in self._sent_approval_ids: if req.request_id in self._sent_approval_ids:
continue # Already sent continue
if req.discord_message_id != 0: if req.discord_message_id != 0:
continue continue
channel = self.session_channels.get(req.conversation_id) channel = await self._get_project_channel()
if not channel:
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
)
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)
@@ -449,39 +374,32 @@ class GravityBot(commands.Bot):
if message.author == self.user: if message.author == self.user:
return return
if not message.channel.name.startswith(Config.CHANNEL_PREFIX.lower() + "-"): # Only respond in the project channel
if not self.project_channel or message.channel.id != self.project_channel.id:
await self.process_commands(message)
return return
conv_id = None
for cid, ch in self.session_channels.items():
if ch.id == message.channel.id:
conv_id = cid
break
text = message.content.strip() text = message.content.strip()
# Special command: !auto on/off # Special command: !auto on/off
if text in ("!auto on", "!auto off"): if text in ("!auto on", "!auto off"):
if conv_id: self.bridge.write_command("__global__", text)
self.bridge.write_command(conv_id, text) 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"Antigravity IDE 설정이 변경됩니다.\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(), )
) await message.channel.send(embed=embed)
await message.channel.send(embed=embed)
else:
await message.reply("⚠️ 채널에 연결된 세션이 없습니다.")
return return
# General text relay # General text relay (broadcast to most recent session or global)
if conv_id and text: if text:
self.bridge.write_command(conv_id, text) self.bridge.write_command("__global__", text)
await message.add_reaction("📨") await message.add_reaction("📨")
await self.process_commands(message) await self.process_commands(message)

View File

@@ -37,6 +37,7 @@ class Config:
# Channel naming # Channel naming
CHANNEL_PREFIX: str = "AG" CHANNEL_PREFIX: str = "AG"
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]: