feat(collector): RemoteTransport + CollectorBridge 구현 — Collector↔Gateway HTTP 통신 완성

- bridge.py RemoteTransport: HTTP 클라이언트, API Key auth, Gateway API 매핑
- collector.py CollectorBridge: 3개 async loop (pending 전달, response 폴링, commands 폴링)
- main.py: BOT_MODE=remote → CollectorBridge 실행 (Discord bot 없이)
- config.py: GATEWAY_API_KEY 설정
- .env.example: 모든 설정 항목 업데이트
This commit is contained in:
Variet Worker
2026-03-11 20:10:45 +09:00
parent 95da3e9307
commit 95c2905e14
4 changed files with 256 additions and 21 deletions

View File

@@ -150,30 +150,107 @@ class LocalTransport(BridgeTransport):
class RemoteTransport(BridgeTransport):
"""HTTP-based transport for remote/multi-PC mode (skeleton).
"""HTTP-based transport for Collector → Gateway communication.
Future implementation: polls a remote bridge HTTP server that
exposes the same pending/response/commands JSON files via API.
Maps BridgeTransport methods to Gateway API endpoints:
list_json_files("pending") → GET /api/pending (returns list)
write_json("pending", ...) → POST /api/pending
read_json("response", ...) → GET /api/response/{rid}
write_json("commands", ...) → (not used by Collector, Gateway pushes commands)
etc.
"""
def __init__(self, base_url: str):
def __init__(self, base_url: str, api_key: str = ""):
self.base_url = base_url.rstrip("/")
logger.info(f"RemoteTransport: initialized with {self.base_url}")
self.api_key = api_key
self._headers = {"Content-Type": "application/json"}
if api_key:
self._headers["Authorization"] = f"Bearer {api_key}"
logger.info(f"RemoteTransport: {self.base_url} (auth={'yes' if api_key else 'no'})")
def _request(self, method: str, path: str, data: dict | None = None) -> dict | None:
"""Make HTTP request to Gateway API."""
import urllib.request
import urllib.error
url = f"{self.base_url}{path}"
body = json.dumps(data, ensure_ascii=False).encode("utf-8") if data else None
req = urllib.request.Request(url, data=body, headers=self._headers, method=method)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
logger.warning(f"RemoteTransport: {method} {path}{e.code} {e.reason}")
return None
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
logger.warning(f"RemoteTransport: {method} {path}{e}")
return None
def list_json_files(self, subdir: str) -> list[str]:
raise NotImplementedError("RemoteTransport not yet implemented")
"""List pending requests from Gateway."""
if subdir == "pending":
result = self._request("GET", "/api/pending")
if result and isinstance(result, list):
return [f"{r['request_id']}.json" for r in result]
elif subdir == "commands":
# Commands are polled per-project (handled separately)
return []
return []
def read_json(self, subdir: str, filename: str) -> dict | None:
raise NotImplementedError("RemoteTransport not yet implemented")
"""Read a JSON file from Gateway."""
rid = filename.replace(".json", "")
if subdir == "response":
return self._request("GET", f"/api/response/{rid}")
elif subdir == "pending":
# Pending data comes from list, not individual read
result = self._request("GET", "/api/pending")
if result and isinstance(result, list):
for r in result:
if r.get("request_id") == rid:
return r
return None
def write_json(self, subdir: str, filename: str, data: dict) -> None:
raise NotImplementedError("RemoteTransport not yet implemented")
"""Write data to Gateway via API."""
if subdir == "pending":
self._request("POST", "/api/pending", data)
elif subdir == "response":
rid = data.get("request_id", filename.replace(".json", ""))
self._request("POST", f"/api/response/{rid}", data)
elif subdir == "commands":
# Commands go through write_command in BridgeProtocol
self._request("POST", "/api/chat", data)
def delete_file(self, subdir: str, filename: str) -> bool:
raise NotImplementedError("RemoteTransport not yet implemented")
"""Delete not needed for remote — Gateway manages cleanup."""
return True
def ensure_dirs(self) -> None:
pass # Remote server manages its own directories
"""No local dirs needed for remote transport."""
pass
def poll_commands(self, project: str) -> list[dict]:
"""Poll Gateway for commands (Collector-specific, not in ABC)."""
result = self._request("GET", f"/api/commands/{project}")
if result and isinstance(result, dict):
return result.get("commands", [])
return []
def register_session(self, conversation_id: str, project_name: str) -> None:
"""Register session → project mapping on Gateway."""
self._request("POST", "/api/register", {
"conversation_id": conversation_id,
"project_name": project_name,
})
def send_chat(self, project_name: str, content: str) -> None:
"""Push chat snapshot to Gateway for relay to Discord."""
self._request("POST", "/api/chat", {
"project_name": project_name,
"content": content,
})
# ─── Bridge Protocol (uses Transport) ───