新增 LINE 官方账号对接的全套功能,包括: 1. 基础的 Bot 探测、会话解析、消息格式化能力 2. 富媒体消息模板、快速回复、卡片指令支持 3. Webhook 签名验证、重放防护、多账户路由管理 4. 消息发送、回复、分块传输、用户绑定管理 5. 交互式配置向导与诊断工具
149 lines
4.1 KiB
Python
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()
|