refactor(extension): 모듈 분리 + Hub 통합 테스트 #task-395

- extension.ts 3,446→1,289줄 (-63%)
- step-probe.ts (1,435줄): setupMonitor, processResponseFile, tryApprovalStrategies
- observer-script.ts (687줄): DOM observer script
- ws-client.ts (390줄): WSBridgeClient
- step-utils.ts (114줄): step 파싱 유틸
- auth.py (115줄): JWT + registration code
- hub.py (581줄): WSHub + per-client queue
- Hub WS 연동 테스트 통과 (auth, chat, register)
- VSIX v0.4.0 빌드
This commit is contained in:
Variet Worker
2026-03-17 06:41:42 +09:00
parent a372bd8b2d
commit 5f795b9a91
19 changed files with 5426 additions and 5538 deletions

322
bot.py
View File

@@ -5,9 +5,15 @@ Multi-project channel architecture:
- 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
Multi-PC UX:
- When multiple AG instances are active, messages get instance numbers (PC #1, #2)
- Users can target specific instances with !N <message> (e.g. !2 hello)
- When only one instance is active, natural conversation without numbers
"""
import asyncio
import re
import json
import logging
import time
@@ -184,17 +190,41 @@ class GravityBot(commands.Bot):
self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay
self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup)
self.gateway = None # Set by main.py in gateway mode
self.hub = None # Set by main.py in gateway mode (WSHub instance)
def _write_command(self, project: str, text: str, **kwargs):
"""Write command to bridge AND push to gateway (if gateway mode)."""
def _write_command(self, project: str, text: str, *,
target_instance: int | None = None, **kwargs):
"""Write command to bridge AND push to gateway/hub.
Args:
target_instance: If set, send only to this instance number (via Hub).
If None, broadcast to all instances.
"""
cmd_data = {
"text": text,
"project_name": kwargs.get('project_name', project),
}
# Hub route (preferred if available)
if self.hub:
import time as _time
cmd_data["id"] = str(int(_time.time() * 1000))
msg = {"type": "command", "data": cmd_data}
if target_instance is not None:
asyncio.create_task(
self.hub.send_to_instance(project, target_instance, msg)
)
else:
asyncio.create_task(
self.hub.broadcast_to_project(project, msg)
)
# Legacy routes (file bridge + gateway HTTP)
self.bridge.write_command(project, text, **kwargs)
if self.gateway:
import time
self.gateway.push_command(project, {
"id": str(int(time.time() * 1000)),
"text": text,
"project_name": kwargs.get('project_name', project),
})
import time as _time
cmd_data["id"] = cmd_data.get("id", str(int(_time.time() * 1000)))
self.gateway.push_command(project, cmd_data)
@staticmethod
def _make_channel_name(project_name: str) -> str:
@@ -206,6 +236,8 @@ class GravityBot(commands.Bot):
self.pending_approval_scanner.start()
self.chat_snapshot_scanner.start()
self._register_slash_commands()
# Register Hub handlers (if Hub is available, set after setup_hook by main.py)
asyncio.get_event_loop().call_soon(self._register_hub_handlers)
logger.info("Bot setup complete")
def _register_slash_commands(self):
@@ -826,7 +858,33 @@ class GravityBot(commands.Bot):
logger.info(f"Sent approval request: {request.request_id[:12]}")
self._approval_messages[request.request_id] = msg.id # FIX #4: Track msg_id for auto_resolved lookup
# ─── Discord → IDE Text Relay ─────────────────────────────────────
# ─── Discord → IDE Text Relay + Multi-PC UX ───────────────────────────
def _get_instance_header(self, project: str, instance_number: int) -> str:
"""Format instance header based on active count.
Single instance: empty string (natural conversation)
Multiple instances: **[PC #N]** prefix
"""
if not self.hub:
return ""
active = self.hub.get_active_count(project)
if active <= 1:
return ""
return f"**[PC #{instance_number}]** "
def _parse_instance_target(self, text: str) -> tuple[int | None, str]:
"""Parse !N prefix from message text.
Returns (target_instance, remaining_text).
'!2 hello' -> (2, 'hello')
'hello' -> (None, 'hello')
'!stop' -> (None, '!stop') # special commands not treated as targeting
"""
match = re.match(r'^!(\d+)\s+(.+)', text, re.DOTALL)
if match:
return int(match.group(1)), match.group(2).strip()
return None, text
async def on_message(self, message: discord.Message):
if message.author == self.user:
@@ -845,19 +903,24 @@ class GravityBot(commands.Bot):
text = message.content.strip()
# Parse !N instance targeting (before special commands)
target_instance, actual_text = self._parse_instance_target(text)
# Special command: !stop — cancel AI work
if text == "!stop":
self._write_command(project, "!stop", project_name=project)
if actual_text == "!stop":
self._write_command(project, "!stop", target_instance=target_instance,
project_name=project)
target_label = f" (PC #{target_instance})" if target_instance else ""
embed = discord.Embed(
title="⏹️ AI 작업 중지",
description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.",
description=f"프로젝트: **{project}**{target_label}\n중지 요청을 Extension에 전달했습니다.",
color=discord.Color.orange(),
)
await message.channel.send(embed=embed)
return
# Special command: !auto — toggle auto-approve
if text == "!auto":
if actual_text == "!auto":
# Toggle per-project auto-approve
if project in self.auto_approve_projects:
self.auto_approve_projects.discard(project)
@@ -865,7 +928,8 @@ class GravityBot(commands.Bot):
else:
self.auto_approve_projects.add(project)
enabled = True
self._write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
self._write_command(project, f"!auto {'on' if enabled else 'off'}",
target_instance=target_instance, project_name=project)
emoji = "🟢" if enabled else "🔴"
mode = "자동 승인" if enabled else "수동 승인"
embed = discord.Embed(
@@ -877,18 +941,240 @@ class GravityBot(commands.Bot):
await message.channel.send(embed=embed)
return
# General text relay — routed by project
if text:
self._write_command(project, text, project_name=project)
# General text relay — routed by project (+ optional instance targeting)
if actual_text:
self._write_command(project, actual_text, target_instance=target_instance,
project_name=project)
await message.add_reaction("📨")
target_label = f" PC #{target_instance}" if target_instance else ""
embed = discord.Embed(
description=f"📨 → **{project}** IDE에 전달됨\n`{text[:100]}`",
description=f"📨 → **{project}**{target_label} IDE에 전달됨\n`{actual_text[:100]}`",
color=discord.Color.blurple(),
)
await message.channel.send(embed=embed, delete_after=10)
await self.process_commands(message)
# ─── Hub Event Handlers ──────────────────────────────────────────
def _register_hub_handlers(self):
"""Register callbacks on the Hub for Extension->Bot messages."""
if not self.hub:
return
self.hub.set_bot_handlers(
on_pending=self._hub_on_pending,
on_chat=self._hub_on_chat,
on_register=self._hub_on_register,
on_auto_resolve=self._hub_on_auto_resolve,
on_brain_event=self._hub_on_brain_event,
)
logger.info("[BOT] Hub handlers registered")
async def _hub_on_pending(self, project: str, data: dict):
"""Handle pending approval from Hub (Extension->Hub->Bot)."""
try:
request_id = data.get("request_id", "")
if not request_id:
return
# Skip if already sent
if request_id in self._sent_approval_ids:
return
# Check auto_resolved status
status = data.get("status", "pending")
if status in ("auto_resolved", "expired"):
await self._handle_auto_resolved(request_id, status)
return
instance_number = data.get("_instance_number", 0)
pc_name = data.get("_pc_name", "")
header = self._get_instance_header(project, instance_number)
# Build approval request
request = ApprovalRequest(
request_id=request_id,
command=data.get("command", ""),
description=data.get("description", ""),
project_name=project,
step_type=data.get("step_type", ""),
status=status,
)
# Auto-approve check
if project in self.auto_approve_projects:
await self._auto_approve_via_hub(request)
return
# Send to Discord
channel = await self._get_channel(project)
if not channel:
logger.warning(f"[HUB-PENDING] No channel for project={project}")
return
buttons = data.get("buttons", [])
desc_parts = []
if header:
desc_parts.append(header)
desc_parts.append(f"**명령:** `{request.command[:200]}`")
if buttons:
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
if request.description:
desc_parts.append(request.description[:500])
embed = discord.Embed(
title="⚠️ 승인 요청",
description="\n".join(desc_parts),
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"ID: {request_id}")
view = ApprovalView(self.bridge, request, buttons=buttons)
msg = await channel.send(embed=embed, view=view)
self._sent_approval_ids.add(request_id)
self._approval_messages[request_id] = msg.id
logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project}")
except Exception as e:
logger.error(f"[HUB-PENDING] Error: {e}")
async def _auto_approve_via_hub(self, request: ApprovalRequest):
"""Auto-approve a pending request via Hub."""
if self.hub:
await self.hub.send_response_to_pending_owner(request.request_id, {
"type": "response",
"data": {
"request_id": request.request_id,
"approved": True,
"button_index": 0,
"step_type": request.step_type,
"project_name": request.project_name,
},
})
# Also write via legacy bridge
self.bridge.write_response(UserResponse(
request_id=request.request_id, approved=True,
step_type=request.step_type,
project_name=request.project_name,
))
logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]}")
async def _hub_on_chat(self, project: str, data: dict):
"""Handle chat snapshot from Hub (Extension->Hub->Bot->Discord)."""
try:
content = data.get("content", "")
attached_files = data.get("attached_files", [])
if not content and not attached_files:
return
instance_number = data.get("_instance_number", 0)
header = self._get_instance_header(project, instance_number)
channel = await self._get_channel(project)
if not channel:
return
import io as _io
discord_files = []
for af in attached_files:
af_name = af.get("name", "document.md")
af_content = af.get("content", "")
if af_content:
discord_files.append(discord.File(
_io.BytesIO(af_content.encode("utf-8")),
filename=af_name,
))
display_content = f"{header}{content}" if header else content
FILE_ATTACH_THRESHOLD = 4000
if len(display_content) > FILE_ATTACH_THRESHOLD:
summary = display_content[:500].rsplit('\n', 1)[0]
embed = discord.Embed(
title="💬 AI 대화 내용",
description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)",
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
discord_files.append(discord.File(
_io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
await channel.send(embed=embed, files=discord_files)
else:
embed = discord.Embed(
title="💬 AI 대화 내용",
description=display_content,
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(
embed=embed,
files=discord_files if discord_files else discord.utils.MISSING,
)
logger.info(f"[HUB-CHAT] Sent to #{channel.name} ({len(content)} chars)")
except Exception as e:
logger.error(f"[HUB-CHAT] Error: {e}")
async def _hub_on_register(self, data: dict):
"""Handle session registration from Hub."""
conv_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if conv_id and project:
self.conv_to_project[conv_id] = project
logger.info(f"[HUB-REG] {conv_id[:8]}{project}")
async def _hub_on_auto_resolve(self, project: str, data: dict):
"""Handle auto_resolve notification from Hub."""
request_id = data.get("request_id", "")
if request_id:
await self._handle_auto_resolved(request_id, "auto_resolved")
async def _hub_on_brain_event(self, project: str, data: dict):
"""Handle brain event from Hub (Extension->Hub->Bot->Discord)."""
try:
from watcher import BrainEvent, EventType
event = BrainEvent(
event_type=EventType(data.get("event_type", "file_changed")),
conversation_id=data.get("conversation_id", ""),
file_name=data.get("file_name", ""),
file_path=None,
content=data.get("content", ""),
timestamp=data.get("timestamp", time.time()),
)
await self.event_queue.put(event)
except Exception as e:
logger.error(f"[HUB-EVENT] Error: {e}")
async def _handle_auto_resolved(self, request_id: str, status: str):
"""Edit Discord message to show auto-resolved/expired status."""
msg_id = self._approval_messages.get(request_id)
if not msg_id:
return
# Find the channel containing this message
for channel in self.project_channels.values():
try:
msg = await channel.fetch_message(msg_id)
embed = msg.embeds[0] if msg.embeds else None
if embed:
if status == "auto_resolved":
embed.color = discord.Color.green()
embed.set_footer(text="✅ 자동 해결됨")
else:
embed.color = discord.Color.greyple()
embed.set_footer(text="⏰ 만료됨")
await msg.edit(embed=embed, view=None)
self._approval_messages.pop(request_id, None)
break
except (discord.NotFound, discord.Forbidden):
continue
except Exception:
break
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
@tasks.loop(seconds=5)