ForcePilot/backend/package/yuxi/channel/extensions/flock/gateway.py
Kris 0bad70ec19 feat(flock): 新增Flock团队协作IM渠道插件
实现了完整的Flock渠道接入能力,包含消息收发、Webhook事件监听、账号配置、安全校验、媒体文件处理等功能,支持私聊和群组聊天,适配ForcePilot插件规范。
2026-05-21 10:47:01 +08:00

166 lines
5.7 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from .constants import (
EVENT_APP_INSTALL,
EVENT_APP_UNINSTALL,
EVENT_CHAT_ACTION,
EVENT_MESSAGE_RECEIVE,
EVENT_SLASH_COMMAND,
WEBHOOK_PATH_EVENT_LISTENER,
WEBHOOK_PATH_OUTGOING,
X_FLOCK_EVENT_TOKEN,
)
from .message import (
flock_event_to_inbound,
inbound_to_unified,
parse_event_listener,
parse_outgoing_webhook,
)
logger = logging.getLogger(__name__)
class FlockGatewayAdapter:
def __init__(self):
self._queue: asyncio.Queue | None = None
self._accounts_config: dict = {}
self._router: APIRouter | None = None
@property
def router(self) -> APIRouter | None:
return self._router
async def start(self, ctx) -> object:
self._queue = ctx.queue
self._accounts_config = ctx.config
self._router = self._build_router()
logger.info("Flock gateway started, webhook routes registered")
return {"router": self._router, "queue": self._queue}
async def stop(self, ctx) -> None:
self._queue = None
self._accounts_config = {}
self._router = None
logger.info("Flock gateway stopped")
def resolve_gateway_auth_bypass_paths(self, config: dict) -> list[str]:
return [WEBHOOK_PATH_EVENT_LISTENER, WEBHOOK_PATH_OUTGOING]
def _build_router(self) -> APIRouter:
router = APIRouter(prefix="/api/channel/flock", tags=["flock"])
@router.post("/events")
async def handle_event_listener(request: Request):
raw_body = await request.body()
if not self._verify_event_token(request, raw_body):
logger.warning("Flock event token verification failed")
return JSONResponse(status_code=401, content={"error": "Invalid event token"})
try:
body = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
name = body.get("name", "")
logger.info("Flock event listener received: %s", name)
event = parse_event_listener(body)
if name in (EVENT_APP_INSTALL, EVENT_APP_UNINSTALL):
logger.info("Flock lifecycle event: %s", name)
return JSONResponse(status_code=200, content={"status": "ok"})
if name not in (EVENT_MESSAGE_RECEIVE, EVENT_SLASH_COMMAND, EVENT_CHAT_ACTION):
logger.debug("Flock unhandled event: %s", name)
return JSONResponse(status_code=200, content={"status": "ignored"})
if name == EVENT_SLASH_COMMAND:
command_name = body.get("event", {}).get("command", "")
logger.info("Flock slash command received: /%s", command_name)
inbound = flock_event_to_inbound(event)
account_id = self._resolve_account_id_for_event(inbound.to)
unified = inbound_to_unified(inbound, account_id)
if self._queue is not None:
await self._queue.put(unified)
logger.info("Flock message queued: %s", inbound.message_id)
else:
logger.warning("Flock message queue not available, dropping message: %s", inbound.message_id)
if name == EVENT_SLASH_COMMAND:
return JSONResponse(status_code=200, content={
"text": f"收到指令 /{command_name},正在处理...",
"responseType": "ephemeral",
})
return JSONResponse(status_code=200, content={"status": "ok"})
@router.post("/outgoing")
async def handle_outgoing_webhook(request: Request):
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
inbound = parse_outgoing_webhook(body)
account_id = self._resolve_account_id_for_event(inbound.to)
unified = inbound_to_unified(inbound, account_id)
if self._queue is not None:
await self._queue.put(unified)
logger.info("Flock outgoing webhook queued: %s", inbound.message_id)
else:
logger.warning("Flock message queue not available, dropping message: %s", inbound.message_id)
return JSONResponse(status_code=200, content={"status": "ok"})
return router
def _verify_event_token(self, request: Request, raw_body: bytes) -> bool:
event_token = request.headers.get(X_FLOCK_EVENT_TOKEN, "")
if not event_token:
return True
accounts = self._accounts_config.get("accounts", {})
app_secret = ""
for data in accounts.values():
candidate = data.get("app_secret", "")
if candidate:
app_secret = candidate
break
if not app_secret:
logger.debug("Flock app_secret not configured, skipping event token verification")
return True
expected = hmac.new(
app_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, event_token)
def _resolve_account_id_for_event(self, to: str) -> str:
accounts = self._accounts_config.get("accounts", {})
for aid, data in accounts.items():
event_token = data.get("event_listener_token", "")
if event_token and event_token in to:
return aid
if accounts:
return next(iter(accounts.keys()), "default")
return "default"