实现了完整的Flock渠道接入能力,包含消息收发、Webhook事件监听、账号配置、安全校验、媒体文件处理等功能,支持私聊和群组聊天,适配ForcePilot插件规范。
166 lines
5.7 KiB
Python
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"
|