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:
322
bot.py
322
bot.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user