ForcePilot/backend/package/yuxi/channels/adapters/line/webhook.py
Kris a4ec94ef9d feat(line): 实现完整的 LINE 聊天适配器功能
新增 LINE 官方账号对接的全套功能,包括:
1. 基础的 Bot 探测、会话解析、消息格式化能力
2. 富媒体消息模板、快速回复、卡片指令支持
3. Webhook 签名验证、重放防护、多账户路由管理
4. 消息发送、回复、分块传输、用户绑定管理
5. 交互式配置向导与诊断工具
2026-05-12 00:45:33 +08:00

149 lines
4.1 KiB
Python

from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import time
from collections import OrderedDict
from dataclasses import dataclass, field
LINE_SIGNATURE_HEADER = "x-line-signature"
LINE_WEBHOOK_MAX_BODY_BYTES = 64 * 1024
_REPLAY_WINDOW_SECONDS = 600
_REPLAY_CACHE_MAX_ENTRIES = 4096
class RetryableError(Exception):
pass
class ReplayDetectedError(RetryableError):
pass
class WebhookBodyTooLargeError(Exception):
pass
class WebhookParseError(Exception):
pass
class ConcurrentRequestRejectedError(Exception):
pass
def validate_line_signature(raw_body: bytes, signature: str, channel_secret: str) -> bool:
if not signature or not channel_secret:
return False
if len(raw_body) > LINE_WEBHOOK_MAX_BODY_BYTES:
return False
try:
computed = hmac.new(
key=channel_secret.encode("utf-8"),
msg=raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
computed_b64 = base64.b64encode(computed).decode("utf-8")
return hmac.compare_digest(computed_b64, signature)
except Exception:
return False
def parse_webhook_body(raw_body: bytes) -> list[dict] | None:
try:
body_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
data = json.loads(body_str)
return data.get("events", [])
except (json.JSONDecodeError, UnicodeDecodeError):
return None
@dataclass
class WebhookReplayGuard:
_window_seconds: float = _REPLAY_WINDOW_SECONDS
_max_entries: int = _REPLAY_CACHE_MAX_ENTRIES
_seen_hashes: OrderedDict = field(default_factory=OrderedDict)
def check_and_claim(self, signature_hash: str) -> None:
now = time.time()
cutoff = now - self._window_seconds
expired = [k for k, ts in self._seen_hashes.items() if ts < cutoff]
for k in expired:
self._seen_hashes.pop(k, None)
if signature_hash in self._seen_hashes:
raise ReplayDetectedError(f"Replay detected for signature {signature_hash[:16]}...")
self._seen_hashes[signature_hash] = now
while len(self._seen_hashes) > self._max_entries:
self._seen_hashes.popitem(last=False)
def clear(self) -> None:
self._seen_hashes.clear()
@dataclass
class MultiAccountSignatureRouter:
_secrets: OrderedDict = field(default_factory=OrderedDict)
def register_account(self, account_id: str, channel_secret: str) -> None:
self._secrets[account_id] = channel_secret
def unregister_account(self, account_id: str) -> None:
self._secrets.pop(account_id, None)
def list_accounts(self) -> list[str]:
return list(self._secrets.keys())
def match_signature(self, raw_body: bytes, signature: str) -> str | None:
for account_id, secret in self._secrets.items():
if validate_line_signature(raw_body, signature, secret):
return account_id
return None
def clear(self) -> None:
self._secrets.clear()
@dataclass
class WebhookConcurrencyGuard:
_max_concurrent: int = 1
_locks: dict[str, asyncio.Lock] = field(default_factory=dict)
_in_flight: dict[str, int] = field(default_factory=dict)
async def acquire(self, path: str) -> bool:
if path not in self._locks:
self._locks[path] = asyncio.Lock()
self._in_flight[path] = 0
if self._in_flight[path] >= self._max_concurrent:
return False
self._in_flight[path] += 1
await self._locks[path].acquire()
return True
def release(self, path: str) -> None:
if path in self._locks:
lock = self._locks[path]
if lock.locked():
lock.release()
self._in_flight[path] = max(0, self._in_flight.get(path, 1) - 1)
def in_flight_count(self, path: str | None = None) -> int:
if path:
return self._in_flight.get(path, 0)
return sum(self._in_flight.values())
def clear(self) -> None:
self._locks.clear()
self._in_flight.clear()