refactor: single project channel - guild.fetch_channels API + project_channel singleton
This commit is contained in:
282
bot.py
282
bot.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user