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

282
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:
if (isinstance(ch, discord.TextChannel)
and ch.category_id == self.session_category.id
and ch.name == target_name):
matches.append(ch)
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})")
for dup in matches[1:]:
try: try:
await ch.delete(reason="Duplicate channel cleanup") await dup.delete(reason="Duplicate project channel cleanup")
logger.info(f"Deleted duplicate channel: #{ch.name}") logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})")
except (discord.Forbidden, discord.HTTPException) as e: except (discord.Forbidden, discord.HTTPException) as e:
logger.warning(f"Failed to delete duplicate #{ch.name}: {e}") logger.warning(f"Failed to delete duplicate: {e}")
logger.info(f"Reconnected to {count} channels, cleaned {len(duplicates)} duplicates") # Also delete any OLD-style channels with different names
for ch in all_channels:
async def _recover_task_message( if (isinstance(ch, discord.TextChannel)
self, channel: discord.TextChannel, conversation_id: str and ch.category_id == self.session_category.id
): and ch.name != target_name
if conversation_id in self.session_status_messages: and ch.topic and "Antigravity Session:" in ch.topic):
return
try: try:
async for msg in channel.history(limit=10): await ch.delete(reason="Old-style channel cleanup")
if msg.author == self.user and msg.embeds: logger.info(f"Deleted old channel: #{ch.name}")
embed = msg.embeds[0] except (discord.Forbidden, discord.HTTPException) as e:
if embed.title and "Task" in embed.title: logger.warning(f"Failed to delete old channel: {e}")
self.session_status_messages[conversation_id] = msg.id else:
return logger.info(f"No existing project channel found. Will create on first event.")
except (discord.Forbidden, discord.HTTPException):
pass
# ─── Channel Management ────────────────────────────────────────── async def _get_project_channel(self) -> discord.TextChannel:
"""Get the project channel. Create if it doesn't exist yet.
async def _ensure_channel( Thread-safe: only ONE channel will ever be created because
self, conversation_id: str, project_name: str self.project_channel acts as a singleton guard.
) -> discord.TextChannel: """
"""Get or create a channel. ONE channel per conv_id, guaranteed.""" if self.project_channel:
return self.project_channel
# Fast path: this conv_id already has a channel — ALWAYS return it # Create the channel
# (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: try:
channel = await self.guild.create_text_channel( self.project_channel = await self.guild.create_text_channel(
name=channel_name, name=self._channel_name,
category=self.session_category, category=self.session_category,
topic=f"Antigravity Session: {conversation_id}", topic=f"Gravity Control — Antigravity Bridge",
) )
self.session_channels[conversation_id] = channel logger.info(f"Created project channel: #{self._channel_name}")
self.session_names[conversation_id] = project_name
logger.info(f"Created channel #{channel_name}")
embed = discord.Embed( embed = discord.Embed(
title=f"🚀 {project_name}", title=f"🚀 {Config.PROJECT_NAME}",
description=f"Antigravity 세션 연결됨\nSession: `{conversation_id}`", description=(
f"Antigravity Bridge 연결됨\n"
f"모든 세션 이벤트가 이 채널로 전달됩니다."
),
color=discord.Color.blue(), color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
await channel.send(embed=embed) await self.project_channel.send(embed=embed)
return channel
except discord.errors.Forbidden: except discord.errors.Forbidden:
logger.error(f"No permission to create channel: {channel_name}") logger.error(f"No permission to create channel: {self._channel_name}")
return None
# ─── Event Processing (SINGLE ROUTE) ───────────────────────────── return self.project_channel
# ─── Event Processing ─────────────────────────────────────────────
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,21 +374,16 @@ 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 "수동 승인"
@@ -475,13 +395,11 @@ class GravityBot(commands.Bot):
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]: