feat(bridge): Watcher, Parser, Bot, Main 핵심 컴포넌트 구현 #task-215 #task-216 #task-217 #task-218
This commit is contained in:
263
bot.py
Normal file
263
bot.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""Discord bot — relays Antigravity brain events to Discord channels.
|
||||||
|
|
||||||
|
Creates per-session text channels in Discord when new Antigravity sessions are
|
||||||
|
detected, relays task progress and artifact content as text messages and embeds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from parser import (
|
||||||
|
parse_task_progress,
|
||||||
|
md_to_discord_text,
|
||||||
|
format_task_embed_text,
|
||||||
|
)
|
||||||
|
from watcher import BrainEvent, EventType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GravityBot(commands.Bot):
|
||||||
|
"""Discord bot for Antigravity session monitoring."""
|
||||||
|
|
||||||
|
def __init__(self, event_queue: asyncio.Queue):
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
intents.guilds = True
|
||||||
|
super().__init__(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
self.event_queue = event_queue
|
||||||
|
# conversation_id -> channel_id
|
||||||
|
self.session_channels: dict[str, int] = {}
|
||||||
|
# conversation_id -> status message id (to edit in-place)
|
||||||
|
self.session_status_messages: dict[str, int] = {}
|
||||||
|
# Main channel for creating session threads
|
||||||
|
self.main_channel: discord.TextChannel | None = None
|
||||||
|
# Category for session channels
|
||||||
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
|
|
||||||
|
async def setup_hook(self):
|
||||||
|
"""Called after login, before processing events."""
|
||||||
|
self.loop.create_task(self._process_events())
|
||||||
|
logger.info("Bot setup complete, event processor started")
|
||||||
|
|
||||||
|
async def on_ready(self):
|
||||||
|
"""Called when bot is ready."""
|
||||||
|
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
|
||||||
|
|
||||||
|
# Find or create main channel
|
||||||
|
if Config.DISCORD_CHANNEL_ID:
|
||||||
|
self.main_channel = self.get_channel(Config.DISCORD_CHANNEL_ID)
|
||||||
|
if self.main_channel:
|
||||||
|
logger.info(f"Main channel: #{self.main_channel.name}")
|
||||||
|
|
||||||
|
# Find or create session category
|
||||||
|
guild = self.main_channel.guild
|
||||||
|
category_name = "Antigravity Sessions"
|
||||||
|
self.session_category = discord.utils.get(
|
||||||
|
guild.categories, name=category_name
|
||||||
|
)
|
||||||
|
if not self.session_category:
|
||||||
|
try:
|
||||||
|
self.session_category = await guild.create_category(category_name)
|
||||||
|
logger.info(f"Created category: {category_name}")
|
||||||
|
except discord.errors.Forbidden:
|
||||||
|
logger.warning("No permission to create category, using main channel")
|
||||||
|
|
||||||
|
# Send startup message
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🛰️ Gravity Control 연결됨",
|
||||||
|
description="Antigravity 세션 모니터링을 시작합니다.",
|
||||||
|
color=discord.Color.green(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name="Brain Path",
|
||||||
|
value=f"`{Config.BRAIN_PATH}`",
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
await self.main_channel.send(embed=embed)
|
||||||
|
|
||||||
|
async def _process_events(self):
|
||||||
|
"""Main event processing loop — consumes brain events."""
|
||||||
|
await self.wait_until_ready()
|
||||||
|
|
||||||
|
while not self.is_closed():
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(
|
||||||
|
self.event_queue.get(), timeout=5.0
|
||||||
|
)
|
||||||
|
await self._handle_event(event)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing event: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _handle_event(self, event: BrainEvent):
|
||||||
|
"""Route brain events to appropriate handlers."""
|
||||||
|
if event.event_type == EventType.SESSION_START:
|
||||||
|
await self._handle_session_start(event)
|
||||||
|
elif event.event_type in (EventType.FILE_CREATED, EventType.FILE_CHANGED):
|
||||||
|
await self._handle_file_event(event)
|
||||||
|
|
||||||
|
async def _handle_session_start(self, event: BrainEvent):
|
||||||
|
"""Create a new Discord channel for the session."""
|
||||||
|
conv_id = event.conversation_id
|
||||||
|
short_id = conv_id[:8]
|
||||||
|
|
||||||
|
if self.session_category:
|
||||||
|
guild = self.session_category.guild
|
||||||
|
channel_name = f"session-{short_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel = await guild.create_text_channel(
|
||||||
|
name=channel_name,
|
||||||
|
category=self.session_category,
|
||||||
|
topic=f"Antigravity Session: {conv_id}",
|
||||||
|
)
|
||||||
|
self.session_channels[conv_id] = channel.id
|
||||||
|
logger.info(f"Created channel #{channel_name} for session {conv_id}")
|
||||||
|
|
||||||
|
# Send welcome embed
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"🚀 새 Antigravity 세션",
|
||||||
|
description=f"Session ID: `{conv_id}`",
|
||||||
|
color=discord.Color.blue(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
|
# Notify main channel
|
||||||
|
if self.main_channel:
|
||||||
|
await self.main_channel.send(
|
||||||
|
f"📡 새 세션 시작: {channel.mention} (`{short_id}`)"
|
||||||
|
)
|
||||||
|
except discord.errors.Forbidden:
|
||||||
|
logger.warning(f"No permission to create channel for {conv_id}")
|
||||||
|
# Fall back to main channel
|
||||||
|
self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID
|
||||||
|
else:
|
||||||
|
# No category — use main channel
|
||||||
|
self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID
|
||||||
|
if self.main_channel:
|
||||||
|
await self.main_channel.send(
|
||||||
|
f"📡 새 세션 감지: `{short_id}`"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_file_event(self, event: BrainEvent):
|
||||||
|
"""Handle file creation/modification events."""
|
||||||
|
channel = await self._get_session_channel(event.conversation_id)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.file_name == "task.md":
|
||||||
|
await self._send_task_update(channel, event)
|
||||||
|
else:
|
||||||
|
await self._send_artifact_content(channel, event)
|
||||||
|
|
||||||
|
async def _send_task_update(
|
||||||
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
|
):
|
||||||
|
"""Send task progress update as an embed."""
|
||||||
|
progress = parse_task_progress(event.content)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="📋 Task 진행 현황",
|
||||||
|
description=format_task_embed_text(progress),
|
||||||
|
color=discord.Color.gold() if progress.in_progress > 0
|
||||||
|
else discord.Color.green() if progress.done == progress.total
|
||||||
|
else discord.Color.greyple(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
||||||
|
|
||||||
|
# Edit existing status message or send new one
|
||||||
|
msg_id = self.session_status_messages.get(event.conversation_id)
|
||||||
|
if msg_id:
|
||||||
|
try:
|
||||||
|
msg = await channel.fetch_message(msg_id)
|
||||||
|
await msg.edit(embed=embed)
|
||||||
|
return
|
||||||
|
except (discord.NotFound, discord.HTTPException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg = await channel.send(embed=embed)
|
||||||
|
self.session_status_messages[event.conversation_id] = msg.id
|
||||||
|
|
||||||
|
async def _send_artifact_content(
|
||||||
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
|
):
|
||||||
|
"""Send artifact file content as Discord text messages."""
|
||||||
|
# File type label
|
||||||
|
labels = {
|
||||||
|
"implementation_plan.md": "📐 구현 계획",
|
||||||
|
"walkthrough.md": "📝 작업 결과 요약",
|
||||||
|
}
|
||||||
|
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
||||||
|
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
|
||||||
|
|
||||||
|
# Header message
|
||||||
|
await channel.send(f"**{label} ({event_label}됨)**")
|
||||||
|
|
||||||
|
# Convert and send content
|
||||||
|
chunks = md_to_discord_text(event.content)
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.strip():
|
||||||
|
await channel.send(chunk)
|
||||||
|
# Small delay to avoid rate limits
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
async def _get_session_channel(
|
||||||
|
self, conversation_id: str
|
||||||
|
) -> discord.TextChannel | None:
|
||||||
|
"""Get the Discord channel for a session."""
|
||||||
|
channel_id = self.session_channels.get(conversation_id)
|
||||||
|
|
||||||
|
# If no channel mapped, check if this is a known session and create one
|
||||||
|
if not channel_id:
|
||||||
|
# Auto-create for sessions that started before the bot
|
||||||
|
await self._handle_session_start(BrainEvent(
|
||||||
|
event_type=EventType.SESSION_START,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
))
|
||||||
|
channel_id = self.session_channels.get(conversation_id)
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
channel = self.get_channel(channel_id)
|
||||||
|
if channel:
|
||||||
|
return channel
|
||||||
|
|
||||||
|
return self.main_channel
|
||||||
|
|
||||||
|
async def cleanup_session(self, conversation_id: str):
|
||||||
|
"""Clean up when an Antigravity session ends."""
|
||||||
|
channel_id = self.session_channels.pop(conversation_id, None)
|
||||||
|
self.session_status_messages.pop(conversation_id, None)
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
channel = self.get_channel(channel_id)
|
||||||
|
if channel and channel.id != Config.DISCORD_CHANNEL_ID:
|
||||||
|
try:
|
||||||
|
# Send closing message before archiving
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🔴 세션 종료",
|
||||||
|
description=f"Session `{conversation_id[:8]}` 이 종료되었습니다.",
|
||||||
|
color=discord.Color.red(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
|
# Archive channel (move to bottom, read-only)
|
||||||
|
await channel.edit(
|
||||||
|
name=f"closed-{conversation_id[:8]}",
|
||||||
|
sync_permissions=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Archived channel for session {conversation_id[:8]}")
|
||||||
|
except discord.errors.Forbidden:
|
||||||
|
logger.warning(f"No permission to archive channel for {conversation_id}")
|
||||||
48
config.py
Normal file
48
config.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Configuration module — loads settings from .env file or environment variables."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load .env from project root
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Bridge configuration."""
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "")
|
||||||
|
DISCORD_CHANNEL_ID: int = int(os.getenv("DISCORD_CHANNEL_ID", "0"))
|
||||||
|
|
||||||
|
# Antigravity Brain path
|
||||||
|
BRAIN_PATH: Path = Path(os.getenv(
|
||||||
|
"BRAIN_PATH",
|
||||||
|
os.path.expanduser("~/.gemini/antigravity/brain")
|
||||||
|
))
|
||||||
|
|
||||||
|
# Watcher settings
|
||||||
|
DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2"))
|
||||||
|
|
||||||
|
# Files to monitor within each conversation directory
|
||||||
|
WATCHED_FILES: set = {
|
||||||
|
"task.md",
|
||||||
|
"implementation_plan.md",
|
||||||
|
"walkthrough.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discord message limits
|
||||||
|
DISCORD_MSG_LIMIT: int = 2000
|
||||||
|
DISCORD_EMBED_DESC_LIMIT: int = 4096
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls) -> list[str]:
|
||||||
|
"""Return list of configuration errors."""
|
||||||
|
errors = []
|
||||||
|
if not cls.DISCORD_TOKEN:
|
||||||
|
errors.append("DISCORD_TOKEN is not set")
|
||||||
|
if not cls.DISCORD_CHANNEL_ID:
|
||||||
|
errors.append("DISCORD_CHANNEL_ID is not set")
|
||||||
|
if not cls.BRAIN_PATH.exists():
|
||||||
|
errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}")
|
||||||
|
return errors
|
||||||
79
main.py
Normal file
79
main.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Gravity Control — Antigravity Discord Bridge.
|
||||||
|
|
||||||
|
Entry point that runs the brain watcher and Discord bot together.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from watcher import BrainWatcher
|
||||||
|
from bot import GravityBot
|
||||||
|
|
||||||
|
# Logging setup
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.FileHandler("gravity_control.log", encoding="utf-8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("gravity_control")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run the bridge: watcher + Discord bot."""
|
||||||
|
|
||||||
|
# Validate config
|
||||||
|
errors = Config.validate()
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
logger.error(f"Config error: {e}")
|
||||||
|
logger.error("Fix configuration issues and restart.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info("Gravity Control — Antigravity Discord Bridge")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(f"Brain path: {Config.BRAIN_PATH}")
|
||||||
|
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
|
||||||
|
|
||||||
|
# Shared event queue
|
||||||
|
event_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
# Get the running loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Create components
|
||||||
|
watcher = BrainWatcher(event_queue, loop)
|
||||||
|
bot = GravityBot(event_queue)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start watcher (runs in a separate thread via watchdog)
|
||||||
|
watcher.start()
|
||||||
|
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
|
||||||
|
|
||||||
|
# Run Discord bot (blocks until bot disconnects)
|
||||||
|
await bot.start(Config.DISCORD_TOKEN)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received keyboard interrupt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
watcher.stop()
|
||||||
|
if not bot.is_closed():
|
||||||
|
await bot.close()
|
||||||
|
logger.info("Gravity Control shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
218
parser.py
Normal file
218
parser.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""Markdown → Discord text parser.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- task.md checkbox progress extraction
|
||||||
|
- MD → Discord-friendly text conversion
|
||||||
|
- Long text splitting for Discord's 2000 char limit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskProgress:
|
||||||
|
"""Parsed progress from task.md."""
|
||||||
|
total: int = 0
|
||||||
|
done: int = 0
|
||||||
|
in_progress: int = 0
|
||||||
|
pending: int = 0
|
||||||
|
current_task: str = ""
|
||||||
|
sections: list = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.sections is None:
|
||||||
|
self.sections = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary_line(self) -> str:
|
||||||
|
bar_len = 10
|
||||||
|
filled = round(self.done / max(self.total, 1) * bar_len)
|
||||||
|
bar = "█" * filled + "░" * (bar_len - filled)
|
||||||
|
return f"[{bar}] {self.done}/{self.total} 완료"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_progress(content: str) -> TaskProgress:
|
||||||
|
"""Parse task.md and extract checkbox progress."""
|
||||||
|
progress = TaskProgress()
|
||||||
|
current_section = ""
|
||||||
|
|
||||||
|
for line in content.splitlines():
|
||||||
|
# Section headers
|
||||||
|
header_match = re.match(r'^#{1,3}\s+(.+)', line)
|
||||||
|
if header_match:
|
||||||
|
current_section = header_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Checkboxes
|
||||||
|
checkbox_match = re.match(r'^\s*-\s*\[([ x/])\]\s*(.+)', line)
|
||||||
|
if checkbox_match:
|
||||||
|
state, text = checkbox_match.groups()
|
||||||
|
progress.total += 1
|
||||||
|
|
||||||
|
if state == 'x':
|
||||||
|
progress.done += 1
|
||||||
|
elif state == '/':
|
||||||
|
progress.in_progress += 1
|
||||||
|
progress.current_task = text.strip()
|
||||||
|
else:
|
||||||
|
progress.pending += 1
|
||||||
|
|
||||||
|
progress.sections.append({
|
||||||
|
"section": current_section,
|
||||||
|
"state": state,
|
||||||
|
"text": text.strip()
|
||||||
|
})
|
||||||
|
|
||||||
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
||||||
|
"""Convert markdown to Discord-friendly text, splitting into chunks.
|
||||||
|
|
||||||
|
Preserves:
|
||||||
|
- Headers → **bold**
|
||||||
|
- Code blocks → unchanged (Discord supports ```)
|
||||||
|
- Checkboxes → emoji representation
|
||||||
|
- Tables → simplified text
|
||||||
|
|
||||||
|
Strips:
|
||||||
|
- Mermaid diagrams
|
||||||
|
- HTML comments
|
||||||
|
- Alert syntax (> [!NOTE] etc.)
|
||||||
|
|
||||||
|
Returns list of text chunks, each under max_length.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
output_lines = []
|
||||||
|
in_mermaid = False
|
||||||
|
in_code_block = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Skip mermaid blocks
|
||||||
|
if re.match(r'^```mermaid', line):
|
||||||
|
in_mermaid = True
|
||||||
|
output_lines.append("*(mermaid 다이어그램 생략)*")
|
||||||
|
continue
|
||||||
|
if in_mermaid:
|
||||||
|
if line.strip() == '```':
|
||||||
|
in_mermaid = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Track code blocks
|
||||||
|
if re.match(r'^```', line) and not in_mermaid:
|
||||||
|
in_code_block = not in_code_block
|
||||||
|
output_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_code_block:
|
||||||
|
output_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip HTML comments
|
||||||
|
if re.match(r'^\s*<!--.*-->\s*$', line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip alert syntax but keep content
|
||||||
|
alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line)
|
||||||
|
if alert_match:
|
||||||
|
alert_type = alert_match.group(1)
|
||||||
|
emoji_map = {
|
||||||
|
"NOTE": "📝", "TIP": "💡", "IMPORTANT": "❗",
|
||||||
|
"WARNING": "⚠️", "CAUTION": "🔴"
|
||||||
|
}
|
||||||
|
output_lines.append(f"{emoji_map.get(alert_type, '📌')} **{alert_type}**")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert headers to bold
|
||||||
|
header_match = re.match(r'^(#{1,3})\s+(.+)', line)
|
||||||
|
if header_match:
|
||||||
|
level = len(header_match.group(1))
|
||||||
|
text = header_match.group(2)
|
||||||
|
if level == 1:
|
||||||
|
output_lines.append(f"\n**━━ {text} ━━**\n")
|
||||||
|
elif level == 2:
|
||||||
|
output_lines.append(f"\n**▸ {text}**")
|
||||||
|
else:
|
||||||
|
output_lines.append(f"**{text}**")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert checkboxes to emoji
|
||||||
|
checkbox_match = re.match(r'^(\s*)-\s*\[([ x/])\]\s*(.+)', line)
|
||||||
|
if checkbox_match:
|
||||||
|
indent, state, text = checkbox_match.groups()
|
||||||
|
emoji = {"x": "✅", "/": "🔄", " ": "⬜"}.get(state, "⬜")
|
||||||
|
output_lines.append(f"{indent}{emoji} {text}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert blockquote markers
|
||||||
|
if line.startswith("> "):
|
||||||
|
output_lines.append(f"│ {line[2:]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pass through everything else
|
||||||
|
output_lines.append(line)
|
||||||
|
|
||||||
|
# Join and split into chunks
|
||||||
|
full_text = "\n".join(output_lines).strip()
|
||||||
|
return split_text(full_text, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
def split_text(text: str, max_length: int = 1900) -> list[str]:
|
||||||
|
"""Split text into chunks respecting Discord's message limit.
|
||||||
|
|
||||||
|
Tries to split on newlines first, then on spaces.
|
||||||
|
"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
for line in text.split("\n"):
|
||||||
|
if len(current) + len(line) + 1 > max_length:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
# If single line is too long, split on spaces
|
||||||
|
if len(line) > max_length:
|
||||||
|
words = line.split(" ")
|
||||||
|
for word in words:
|
||||||
|
if len(current) + len(word) + 1 > max_length:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = word
|
||||||
|
else:
|
||||||
|
current = f"{current} {word}" if current else word
|
||||||
|
else:
|
||||||
|
current = line
|
||||||
|
else:
|
||||||
|
current = f"{current}\n{line}" if current else line
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def format_task_embed_text(progress: TaskProgress) -> str:
|
||||||
|
"""Format task progress as a compact Discord text message."""
|
||||||
|
lines = [
|
||||||
|
f"📋 **진행 상황** {progress.summary_line}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if progress.current_task:
|
||||||
|
lines.append(f"🔄 현재: {progress.current_task}")
|
||||||
|
|
||||||
|
# Group by section
|
||||||
|
current_section = ""
|
||||||
|
for item in progress.sections:
|
||||||
|
if item["section"] != current_section:
|
||||||
|
current_section = item["section"]
|
||||||
|
lines.append(f"\n**{current_section}**")
|
||||||
|
|
||||||
|
emoji = {"x": "✅", "/": "🔄", " ": "⬜"}.get(item["state"], "⬜")
|
||||||
|
lines.append(f" {emoji} {item['text']}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
171
watcher.py
Normal file
171
watcher.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Brain directory watcher — monitors Antigravity's brain/ for file changes.
|
||||||
|
|
||||||
|
Uses watchdog to detect file creation/modification events in the brain directory.
|
||||||
|
Emits events to an asyncio queue for the Discord bot to consume.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
"""Types of brain events."""
|
||||||
|
SESSION_START = "session_start" # New conversation directory created
|
||||||
|
SESSION_END = "session_end" # Conversation directory removed (or program exit)
|
||||||
|
FILE_CHANGED = "file_changed" # Watched file created/modified
|
||||||
|
FILE_CREATED = "file_created" # Watched file first created
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrainEvent:
|
||||||
|
"""An event from the brain directory."""
|
||||||
|
event_type: EventType
|
||||||
|
conversation_id: str
|
||||||
|
file_name: str = ""
|
||||||
|
file_path: Path = None
|
||||||
|
content: str = ""
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
class BrainEventHandler(FileSystemEventHandler):
|
||||||
|
"""Watchdog handler that filters and debounces brain events."""
|
||||||
|
|
||||||
|
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
|
||||||
|
super().__init__()
|
||||||
|
self.event_queue = event_queue
|
||||||
|
self.loop = loop
|
||||||
|
self._last_events: dict[str, float] = {} # path -> timestamp (debounce)
|
||||||
|
self._known_sessions: set[str] = set()
|
||||||
|
self._initialize_known_sessions()
|
||||||
|
|
||||||
|
def _initialize_known_sessions(self):
|
||||||
|
"""Scan existing brain directories to establish baseline."""
|
||||||
|
brain_path = Config.BRAIN_PATH
|
||||||
|
if brain_path.exists():
|
||||||
|
for entry in brain_path.iterdir():
|
||||||
|
if entry.is_dir() and self._is_conversation_id(entry.name):
|
||||||
|
self._known_sessions.add(entry.name)
|
||||||
|
logger.info(f"Found {len(self._known_sessions)} existing sessions at startup")
|
||||||
|
|
||||||
|
def _is_conversation_id(self, name: str) -> bool:
|
||||||
|
"""Check if directory name looks like a UUID conversation ID."""
|
||||||
|
parts = name.split("-")
|
||||||
|
return len(parts) == 5 and all(len(p) >= 4 for p in parts)
|
||||||
|
|
||||||
|
def _get_conversation_id(self, path: Path) -> str | None:
|
||||||
|
"""Extract conversation ID from file path."""
|
||||||
|
brain_path = Config.BRAIN_PATH
|
||||||
|
try:
|
||||||
|
relative = path.relative_to(brain_path)
|
||||||
|
parts = relative.parts
|
||||||
|
if parts and self._is_conversation_id(parts[0]):
|
||||||
|
return parts[0]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_debounce(self, path_str: str) -> bool:
|
||||||
|
"""Check if this event should be debounced."""
|
||||||
|
now = time.time()
|
||||||
|
last = self._last_events.get(path_str, 0)
|
||||||
|
if now - last < Config.DEBOUNCE_SECONDS:
|
||||||
|
return True
|
||||||
|
self._last_events[path_str] = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _emit(self, event: BrainEvent):
|
||||||
|
"""Thread-safe emit to asyncio queue."""
|
||||||
|
self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event)
|
||||||
|
|
||||||
|
def on_created(self, event: FileSystemEvent):
|
||||||
|
if event.is_directory:
|
||||||
|
self._handle_directory_created(Path(event.src_path))
|
||||||
|
else:
|
||||||
|
self._handle_file_event(Path(event.src_path), EventType.FILE_CREATED)
|
||||||
|
|
||||||
|
def on_modified(self, event: FileSystemEvent):
|
||||||
|
if not event.is_directory:
|
||||||
|
self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED)
|
||||||
|
|
||||||
|
def _handle_directory_created(self, path: Path):
|
||||||
|
"""Detect new session directories."""
|
||||||
|
conv_id = self._get_conversation_id(path)
|
||||||
|
if conv_id and conv_id not in self._known_sessions:
|
||||||
|
# Check if this is a direct child of brain/
|
||||||
|
if path.parent == Config.BRAIN_PATH:
|
||||||
|
self._known_sessions.add(conv_id)
|
||||||
|
logger.info(f"New session detected: {conv_id}")
|
||||||
|
self._emit(BrainEvent(
|
||||||
|
event_type=EventType.SESSION_START,
|
||||||
|
conversation_id=conv_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _handle_file_event(self, path: Path, event_type: EventType):
|
||||||
|
"""Process file creation/modification events."""
|
||||||
|
conv_id = self._get_conversation_id(path)
|
||||||
|
if not conv_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_name = path.name
|
||||||
|
if file_name not in Config.WATCHED_FILES:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._should_debounce(str(path)):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError) as e:
|
||||||
|
logger.warning(f"Failed to read {path}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"File event: {event_type.value} {conv_id}/{file_name}")
|
||||||
|
self._emit(BrainEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
conversation_id=conv_id,
|
||||||
|
file_name=file_name,
|
||||||
|
file_path=path,
|
||||||
|
content=content,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class BrainWatcher:
|
||||||
|
"""Manages the watchdog observer for the brain directory."""
|
||||||
|
|
||||||
|
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
|
||||||
|
self.event_queue = event_queue
|
||||||
|
self.loop = loop
|
||||||
|
self.observer = Observer()
|
||||||
|
self.handler = BrainEventHandler(event_queue, loop)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start watching the brain directory."""
|
||||||
|
brain_path = Config.BRAIN_PATH
|
||||||
|
if not brain_path.exists():
|
||||||
|
logger.error(f"Brain path does not exist: {brain_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.observer.schedule(self.handler, str(brain_path), recursive=True)
|
||||||
|
self.observer.start()
|
||||||
|
logger.info(f"Watching brain directory: {brain_path}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the watcher."""
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join()
|
||||||
|
logger.info("Brain watcher stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def known_sessions(self) -> set[str]:
|
||||||
|
return self.handler._known_sessions
|
||||||
Reference in New Issue
Block a user