feat(channel): 添加快手渠道扩展

新增快手(Kuaishou)渠道扩展,支持在 Yuxi 平台中集成快手客服渠道。

包含以下功能模块:
- api: 快手 API 客户端封装
- accounts: 账户管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- media: 媒体资源处理
- types: 类型定义
This commit is contained in:
Kris 2026-05-21 11:09:37 +08:00
parent bb8ade9e2b
commit f6df6e2c95
18 changed files with 1156 additions and 0 deletions

View File

@ -0,0 +1,308 @@
from __future__ import annotations
import logging
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.errors import classify_error as _default_classify_error
from yuxi.channel.errors import is_retryable as _default_is_retryable
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.plugins.registry import ChannelPluginRegistry
from .api import KuaishouAPIError
from .config import COMMON_CONFIG_SCHEMA
from .dedupe import KuaishouDedupeStore
from .format import KuaishouFormat
from .gateway import KuaishouGateway
from .media import KuaishouMedia
from .monitor import to_unified_message
from .outbound import KuaishouOutbound
from .pairing import KuaishouPairingStore
from .security import DMPolicyMode, KuaishouSecurity
from .status import KuaishouStatus
from .streaming import KuaishouBlockStreaming
from .types import KuaishouAccount
from .webhook import kwaisign_verify, parse_inbound_message
logger = logging.getLogger(__name__)
class KuaishouPlugin(BaseChannelPlugin):
id = "kuaishou"
name = "快手"
order = 120
label = "Kuaishou"
aliases = ["kuaishou", "ks", "gifshow"]
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct"],
message_types=["text"],
reactions=False,
typing_indicator=False,
threads=False,
edit=False,
unsend=False,
reply=False,
media=False,
native_commands=False,
polls=False,
group_management=False,
streaming=False,
streaming_mode="block",
block_streaming=False,
experimental=True,
)
def __init__(self):
super().__init__()
self._gateway: KuaishouGateway | None = None
self._outbound: KuaishouOutbound | None = None
self._status: KuaishouStatus | None = None
self._media: KuaishouMedia | None = None
self._streaming: KuaishouBlockStreaming | None = None
self._format = KuaishouFormat()
self._dedupe = KuaishouDedupeStore()
self._pairing = KuaishouPairingStore()
self._security = KuaishouSecurity()
# ── ConfigProtocol ──────────────────────────────────
def config_schema(self) -> dict:
return COMMON_CONFIG_SCHEMA
def list_account_ids(self, config: dict) -> list[str]:
kuaishou_config = config.get("kuaishou", {})
accounts = kuaishou_config.get("accounts", [])
if not accounts:
return ["default"]
return [a.get("account_id", "default") for a in accounts]
async def resolve_account(self, account_id: str) -> dict:
config = getattr(self, "_current_config", None) or {}
kuaishou_config = config.get("kuaishou", {})
accounts = kuaishou_config.get("accounts", [])
for acct in accounts:
if acct.get("account_id", "default") == account_id:
return acct
return {"account_id": account_id, "app_id": "", "app_secret": ""}
def is_configured(self, account: dict) -> bool:
return bool(account.get("app_id") and account.get("app_secret"))
def is_enabled(self, account: dict) -> bool:
return account.get("enabled", False) and self.is_configured(account)
def disabled_reason(self, account: dict) -> str:
if not self.is_configured(account):
return "未配置 App ID / App Secret"
if not account.get("enabled", False):
return "快手 IM API 尚未开放,仅作预研占位"
return "快手 IM API 尚未开放 (2026-05 调研结论),启用后仅作占位"
# ── GatewayProtocol ─────────────────────────────────
async def start(self, ctx) -> object:
account_id = getattr(ctx, "account_id", "default")
account_dict = await self.resolve_account(account_id)
if not self.is_configured(account_dict):
return {"running": False, "reason": "not-configured"}
account = KuaishouAccount(
account_id=account_id,
app_id=account_dict.get("app_id", ""),
app_secret=account_dict.get("app_secret", ""),
webhook_token=account_dict.get("webhook_token", ""),
dm_policy=account_dict.get("dm_policy", "open"),
streaming=account_dict.get("streaming", True),
max_text_length=account_dict.get("max_text_length", 2000),
http_timeout_ms=account_dict.get("http_timeout_ms", 15000),
redirect_uri=account_dict.get("redirect_uri", ""),
scopes=account_dict.get("scopes", "user_info"),
)
self._gateway = KuaishouGateway(account)
result = await self._gateway.start(ctx)
self._media = KuaishouMedia(self._gateway)
self._outbound = KuaishouOutbound(self._gateway, self._media)
self._status = KuaishouStatus(self._gateway)
self._format = KuaishouFormat(account.max_text_length)
self._security = KuaishouSecurity(
policy=DMPolicyMode(account.dm_policy),
)
if account.streaming:
self._streaming = KuaishouBlockStreaming(self._outbound)
return result
async def stop(self, ctx) -> None:
if self._gateway:
await self._gateway.stop(ctx)
self._gateway = None
self._outbound = None
self._status = None
self._media = None
self._streaming = None
# ── OutboundProtocol ────────────────────────────────
async def send_text(self, target_id, content, *, reply_to_id=None, thread_id=None, account_id=None):
if not content or not self._outbound:
return
await self._outbound.send_text(target_id, content, reply_to_id=reply_to_id, thread_id=thread_id)
async def send_media(self, target_id, media_url, media_type, *, reply_to_id=None, thread_id=None):
if not self._outbound:
return
if media_type == "image":
media_id = await self._outbound.upload_media(media_url)
if media_id:
await self._outbound.send_image(target_id, media_id)
# ── StatusProtocol ──────────────────────────────────
async def probe(self, account) -> bool:
if self._status:
return await self._status.probe()
return False
def build_summary(self, snapshot) -> dict:
if self._status:
return self._status.build_summary(snapshot)
return {
"status": "unavailable",
"reason": "快手 IM API 尚未开放 (2026-05 调研结论)",
"monitor_url": "https://open.kuaishou.com/platform",
}
# ── SecurityProtocol ────────────────────────────────
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
return self._security.check_allow(peer_id)
def resolve_dm_policy(self) -> dict:
return self._security.resolve_policy()
# ── PairingProtocol ─────────────────────────────────
async def generate_code(self, peer_id: str) -> str:
return self._pairing.generate(peer_id) or ""
async def verify_code(self, peer_id: str, code: str) -> bool:
return self._pairing.verify(peer_id, code)
# ── FormatProtocol ──────────────────────────────────
def markdown_to_native(self, md_text: str) -> dict | str:
return self._format.markdown_to_native(md_text)
def sanitize_text(self, content: str) -> str:
return self._format.sanitize_text(content)
# ── DedupeProtocol ──────────────────────────────────
def is_duplicate(self, key: str) -> bool:
return self._dedupe.is_duplicate(key)
def mark_seen(self, key: str) -> None:
pass
# ── WebhookProtocol ──────────────────────────────────
async def handle_webhook(
self,
request: object,
account_id: str | None = None,
) -> object:
from fastapi import Request as FastAPIRequest
if not isinstance(request, FastAPIRequest):
return None
body_bytes = await request.body()
kwaisign = request.headers.get("kwaisign", "")
account_dict = await self.resolve_account(account_id or "default")
app_secret = account_dict.get("app_secret", "")
if not kwaisign_verify(body_bytes, app_secret, kwaisign):
return None
import json
event_data = json.loads(body_bytes.decode("utf-8"))
return event_data
def verify_signature(self, body: bytes, signature: str, secret: str) -> bool:
return kwaisign_verify(body, secret, signature)
# ── InboundHandlerProtocol ──────────────────────────
async def handle_raw_event(
self,
event: dict,
account: dict,
) -> object | None:
inbound = parse_inbound_message(event)
return inbound
def parse_to_unified(
self,
raw_event: dict,
account_id: str,
) -> object | None:
inbound = parse_inbound_message(raw_event)
if inbound is None:
return None
return to_unified_message(inbound)
# ── MediaProtocol ───────────────────────────────────
async def upload_media(
self,
file_path: str,
media_type: str,
*,
account_id: str | None = None,
) -> dict:
if self._media:
media_id = await self._media.upload(file_path, media_type)
if media_id:
return {"media_id": media_id, "success": True}
return {"success": False}
# ── StreamingProtocol ───────────────────────────────
async def create_draft_stream_session(self, to_user_id: str, content: str) -> list:
if self._streaming:
return await self._streaming.stream_text(to_user_id, content)
return []
# ── ErrorHandlingProtocol ───────────────────────────
def classify_error(self, error: BaseException) -> object:
from yuxi.channel.errors import ErrorSeverity
if isinstance(error, KuaishouAPIError):
from yuxi.channel.errors import ChannelError
code = error.code
if code == 429:
return ChannelError(str(error), severity=ErrorSeverity.RATE_LIMITED)
if code in (401, 403):
return ChannelError(str(error), severity=ErrorSeverity.FORBIDDEN)
return ChannelError(str(error), severity=ErrorSeverity.RETRYABLE, raw=error.raw)
return _default_classify_error(error)
def is_retryable(self, error: BaseException) -> bool:
if isinstance(error, KuaishouAPIError):
return error.code not in (401, 403)
return _default_is_retryable(error)
ChannelPluginRegistry.register(KuaishouPlugin())

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,131 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from .types import TokenInfo
logger = logging.getLogger(__name__)
KUWAISHOU_OPEN_API_BASE = "https://open.kuaishou.com"
# 快手官方 access_token 有效期 48h (172800s)
DEFAULT_EXPIRES_IN = 172800
# refresh_token 有效期 180 天 (15552000s)
DEFAULT_REFRESH_EXPIRES_IN = 15552000
class KuaishouAPIError(Exception):
def __init__(self, message: str, code: int = -1, raw: dict | None = None):
super().__init__(message)
self.code = code
self.raw = raw or {}
class KuaishouAPIClient:
def __init__(self, app_id: str, app_secret: str, timeout: float = 15.0):
self._app_id = app_id
self._app_secret = app_secret
self._http = httpx.AsyncClient(
base_url=KUWAISHOU_OPEN_API_BASE,
timeout=timeout,
)
self._token_info: TokenInfo | None = None
self._token_lock = asyncio.Lock()
async def close(self):
await self._http.aclose()
@property
def access_token(self) -> str | None:
if self._token_info and time.time() < self._token_info.expires_at - 300:
return self._token_info.access_token
return None
def get_authorization_url(self, redirect_uri: str, scope: str = "user_info") -> str:
"""生成快手 OAuth 授权 URLauthorization_code 模式)。"""
params = {
"app_id": self._app_id,
"redirect_uri": redirect_uri,
"scope": scope,
"response_type": "code",
}
return f"{KUWAISHOU_OPEN_API_BASE}/oauth2/authorize?{urlencode(params)}"
async def fetch_access_token(self, code: str, redirect_uri: str) -> TokenInfo:
"""使用 authorization_code 换取 access_token。
快手官方格式GET /oauth2/access_token?grant_type=authorization_code
&code={code}&redirect_uri={redirect_uri}&app_id={app_id}&app_secret={app_secret}
"""
async with self._token_lock:
params = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"app_id": self._app_id,
"app_secret": self._app_secret,
}
resp = await self._http.get("/oauth2/access_token", params=params)
data = resp.json()
if data.get("result") != 1:
raise KuaishouAPIError(
f"获取 access_token 失败: {data}",
code=data.get("error_code", -1),
raw=data,
)
self._token_info = TokenInfo(
access_token=data["access_token"],
expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN),
refresh_token=data.get("refresh_token", ""),
refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN),
)
return self._token_info
async def refresh_access_token(self) -> TokenInfo:
if not self._token_info or not self._token_info.refresh_token:
raise KuaishouAPIError("无 refresh_token无法刷新")
async with self._token_lock:
params = {
"grant_type": "refresh_token",
"refresh_token": self._token_info.refresh_token,
"app_id": self._app_id,
"app_secret": self._app_secret,
}
resp = await self._http.get("/oauth2/refresh_token", params=params)
data = resp.json()
if data.get("result") != 1:
raise KuaishouAPIError(
f"刷新 access_token 失败: {data}",
code=data.get("error_code", -1),
raw=data,
)
self._token_info = TokenInfo(
access_token=data["access_token"],
expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN),
refresh_token=data.get("refresh_token", ""),
refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN),
)
return self._token_info
async def ensure_token(self) -> str:
if token := self.access_token:
return token
if self._token_info and self._token_info.refresh_token:
info = await self.refresh_access_token()
return info.access_token
raise KuaishouAPIError("未获取 access_token请先完成 OAuth 授权")
async def post(self, path: str, json: dict | None = None, **kwargs) -> dict[str, Any]:
resp = await self._http.post(path, json=json, **kwargs)
return resp.json()
async def get(self, path: str, **kwargs) -> dict[str, Any]:
resp = await self._http.get(path, **kwargs)
return resp.json()

View File

@ -0,0 +1,75 @@
from __future__ import annotations
COMMON_CONFIG_SCHEMA = {
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"title": "快手渠道配置",
"properties": {
"appId": {
"type": "string",
"title": "App ID",
"description": "快手开放平台应用 ID (https://open.kuaishou.com)",
},
"appSecret": {
"type": "string",
"title": "App Secret",
"x-ui-password": True,
"description": "快手开放平台应用密钥",
},
"redirectUri": {
"type": "string",
"title": "OAuth 回调地址",
"description": "快手 OAuth 授权回调地址,用于 authorization_code 模式API 未开放前无需配置)",
"default": "",
},
"scopes": {
"type": "string",
"title": "授权范围",
"description": "快手 OAuth 授权范围,默认 user_infoAPI 未开放前无需配置)",
"default": "user_info",
},
"webhookToken": {
"type": "string",
"title": "Webhook 验证 Token",
"x-ui-password": True,
"description": "用于验证快手服务器回调的签名 (预留API 未开放前无需配置)",
},
"webhookPath": {
"type": "string",
"title": "Webhook 回调路径",
"default": "/webhook/kuaishou/callback",
},
"dmPolicy": {
"type": "string",
"title": "DM 安全策略",
"enum": ["open", "pairing", "allowlist", "disabled"],
"default": "open",
"description": "私信安全策略(本地逻辑,无需 API 即可生效)",
},
"streaming": {
"type": "boolean",
"title": "启用流式输出",
"default": True,
"description": "块级流式输出API 未开放前无实际效果)",
},
"maxTextLength": {
"type": "integer",
"title": "单条消息最大字符数",
"default": 2000,
"maximum": 2000,
"description": "单条消息最大长度限制(本地逻辑)",
},
"httpTimeoutMs": {
"type": "integer",
"title": "HTTP 请求超时(毫秒)",
"default": 15000,
},
"enabled": {
"type": "boolean",
"title": "启用",
"default": False,
"description": "当前快手 API 尚未开放 IM 能力,启用后仅作占位",
},
},
"required": ["appId", "appSecret"],
}

View File

@ -0,0 +1,29 @@
from __future__ import annotations
import time
from collections import OrderedDict
class KuaishouDedupeStore:
def __init__(self, max_size: int = 10000, ttl_seconds: int = 3600):
self._store: OrderedDict[str, float] = OrderedDict()
self._max_size = max_size
self._ttl = ttl_seconds
def is_duplicate(self, msg_id: str) -> bool:
self._evict_expired()
if msg_id in self._store:
return True
self._store[msg_id] = time.time()
self._evict_overflow()
return False
def _evict_expired(self):
now = time.time()
expired = [k for k, v in self._store.items() if now - v > self._ttl]
for k in expired:
del self._store[k]
def _evict_overflow(self):
while len(self._store) > self._max_size:
self._store.popitem(last=False)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
import re
def sanitize_text(content: str, max_chars: int = 2000) -> str:
if len(content) <= max_chars:
return content
return content[: max_chars - 3] + "..."
def markdown_to_native(md_text: str) -> str:
return md_text
def strip_html(content: str) -> str:
return re.sub(r"<[^>]+>", "", content or "")
class KuaishouFormat:
def __init__(self, max_text_length: int = 2000):
self._max_text_length = max_text_length
def sanitize_text(self, content: str) -> str:
return sanitize_text(content, self._max_text_length)
def markdown_to_native(self, md_text: str) -> str:
return markdown_to_native(md_text)
def strip_html(self, content: str) -> str:
return strip_html(content)

View File

@ -0,0 +1,102 @@
from __future__ import annotations
import asyncio
import logging
from .api import KuaishouAPIClient, KuaishouAPIError
from .types import KuaishouAccount, TokenInfo
logger = logging.getLogger(__name__)
# 快手官方 access_token 有效期 48h刷新周期设为 44h (158400s),留 4h 缓冲
TOKEN_REFRESH_INTERVAL = 158400
class KuaishouGateway:
def __init__(self, account: KuaishouAccount):
self._account = account
self._client: KuaishouAPIClient | None = None
self._running = False
self._refresh_task: asyncio.Task | None = None
self._awaiting_authorization = False
@property
def account_id(self) -> str:
return self._account.account_id
@property
def client(self) -> KuaishouAPIClient:
if self._client is None:
raise RuntimeError("Gateway 未启动")
return self._client
@property
def awaiting_authorization(self) -> bool:
return self._awaiting_authorization
async def start(self, ctx) -> object:
if not self._account.is_configured():
return {"running": False, "reason": "not-configured"}
self._client = KuaishouAPIClient(
app_id=self._account.app_id,
app_secret=self._account.app_secret,
timeout=self._account.http_timeout_ms / 1000,
)
# TODO(P1-3): API 开放后,如已有持久化 token尝试 refresh否则标记为 awaiting_authorization
# 当前阶段:快手 IM API 未开放,不自动获取 tokenauthorization_code 需用户授权)
self._awaiting_authorization = True
self._running = True
return {
"running": True,
"account_id": self._account.account_id,
"awaiting_authorization": True,
}
async def authorize(self, code: str, redirect_uri: str) -> TokenInfo:
"""OAuth 授权回调后,用 code 换取 token 并启动刷新循环。"""
if self._client is None:
raise RuntimeError("Gateway 未启动")
token_info = await self._client.fetch_access_token(code, redirect_uri)
self._awaiting_authorization = False
self._refresh_task = asyncio.create_task(self._token_refresh_loop())
logger.info("快手 OAuth 授权成功access_token 已获取")
return token_info
async def stop(self, ctx) -> None:
self._running = False
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
if self._client:
await self._client.close()
self._client = None
self._awaiting_authorization = False
async def _token_refresh_loop(self) -> None:
while self._running:
await asyncio.sleep(TOKEN_REFRESH_INTERVAL)
if not self._running:
return
try:
await self._client.refresh_access_token()
logger.info("快手 access_token 刷新成功")
except KuaishouAPIError as e:
logger.exception(f"快手 access_token 刷新失败: {e}")
# TODO(P1-3): 刷新失败后标记 awaiting_authorization提示重新授权
self._awaiting_authorization = True
except Exception:
logger.exception("快手 access_token 刷新失败")
async def probe(self) -> bool:
if not self._client:
return False
try:
await self._client.ensure_token()
return True
except Exception:
return False

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import logging
from pathlib import Path
from .gateway import KuaishouGateway
logger = logging.getLogger(__name__)
# TODO(P1-1): 当快手开放 IM API 后,替换为官方实际上传端点
KUAISHOU_IM_UPLOAD_ENDPOINT = "/openapi/im/material/upload"
class KuaishouMedia:
def __init__(self, gateway: KuaishouGateway):
self._gateway = gateway
async def upload(self, file_path: str | Path, media_type: str = "image") -> str | None:
try:
token = await self._gateway.client.ensure_token()
file_path = Path(file_path)
resp = await self._gateway.client._http.post(
KUAISHOU_IM_UPLOAD_ENDPOINT,
data={"access_token": token, "media_type": media_type},
files={"media": (file_path.name, file_path.open("rb"))},
)
data = resp.json()
if data.get("result") == 1:
return data.get("media_id", "")
return None
except Exception:
logger.exception(f"上传快手素材失败: {file_path}")
return None

View File

@ -0,0 +1,21 @@
from __future__ import annotations
from .types import InboundKuaishouMessage
def to_unified_message(inbound: InboundKuaishouMessage) -> dict:
return {
"message_id": inbound.msg_id,
"channel_type": "kuaishou",
"sender": {
"id": inbound.open_id,
"name": inbound.nickname,
"avatar_url": inbound.avatar_url,
},
"message_type": inbound.msg_type,
"content": inbound.content,
"timestamp": inbound.create_time,
"chat_type": inbound.chat_type,
"group_id": inbound.group_id,
"raw_event": inbound.raw_event,
}

View File

@ -0,0 +1,93 @@
from __future__ import annotations
import logging
from .api import KuaishouAPIError
from .gateway import KuaishouGateway
from .media import KuaishouMedia
from .types import OutboundResult
logger = logging.getLogger(__name__)
KUAISHOU_IM_SEND_ENDPOINT = "/openapi/im/message/send"
class KuaishouOutbound:
def __init__(self, gateway: KuaishouGateway, media: KuaishouMedia | None = None):
self._gateway = gateway
self._media = media
async def send_text(
self,
to_user_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
chat_id: str | None = None,
) -> OutboundResult:
try:
token = await self._gateway.client.ensure_token()
payload = {
"access_token": token,
"open_id": to_user_id,
"msg_type": "text",
"content": content[:2000],
}
if reply_to_id:
payload["reply_to_msg_id"] = reply_to_id
if chat_id:
payload["chat_id"] = chat_id
resp = await self._gateway.client.post(
KUAISHOU_IM_SEND_ENDPOINT,
json=payload,
)
success = resp.get("result") == 1
return OutboundResult(
success=success,
message_id=resp.get("msg_id", ""),
raw_response=resp,
)
except KuaishouAPIError as e:
logger.exception(f"发送快手文本消息失败: {e}")
return OutboundResult(success=False, error=str(e))
async def send_image(
self,
to_user_id: str,
media_id: str,
*,
reply_to_id: str | None = None,
chat_id: str | None = None,
) -> OutboundResult:
try:
token = await self._gateway.client.ensure_token()
payload = {
"access_token": token,
"open_id": to_user_id,
"msg_type": "image",
"media_id": media_id,
}
if reply_to_id:
payload["reply_to_msg_id"] = reply_to_id
if chat_id:
payload["chat_id"] = chat_id
resp = await self._gateway.client.post(
KUAISHOU_IM_SEND_ENDPOINT,
json=payload,
)
return OutboundResult(
success=resp.get("result") == 1,
message_id=resp.get("msg_id", ""),
raw_response=resp,
)
except KuaishouAPIError as e:
logger.exception(f"发送快手图片消息失败: {e}")
return OutboundResult(success=False, error=str(e))
async def upload_media(self, file_path: str, media_type: str = "image") -> str | None:
if self._media:
return await self._media.upload(file_path, media_type)
return None

View File

@ -0,0 +1,25 @@
from __future__ import annotations
import secrets
import time
class KuaishouPairingStore:
def __init__(self, code_ttl: int = 300):
self._codes: dict[str, tuple[str, float]] = {}
self._ttl = code_ttl
def generate(self, open_id: str) -> str:
code = secrets.token_hex(3).upper()
self._codes[code] = (open_id, time.time())
return code
def verify(self, open_id: str, code: str) -> bool:
entry = self._codes.get(code)
if entry is None:
return False
stored_open_id, created_at = entry
if time.time() - created_at > self._ttl:
del self._codes[code]
return False
return stored_open_id == open_id

View File

@ -0,0 +1,31 @@
{
"id": "kuaishou",
"name": "快手",
"version": "1.0.0",
"label": "Kuaishou",
"aliases": ["kuaishou", "ks", "gifshow"],
"description": "快手渠道插件 — 预研占位,待快手开放 IM API 后启用",
"author": "ForcePilot Team",
"order": 120,
"dependencies": ["httpx"],
"capabilities": {
"chat_types": ["direct"],
"message_types": ["text"],
"reactions": false,
"typing_indicator": false,
"threads": false,
"edit": false,
"unsend": false,
"reply": false,
"media": false,
"native_commands": false,
"polls": false,
"group_management": false,
"streaming": false,
"streaming_mode": "block",
"block_streaming": false,
"experimental": true
},
"enabled": true,
"python_requires": ">=3.12"
}

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from enum import Enum
class DMPolicyMode(Enum):
OPEN = "open"
PAIRING = "pairing"
ALLOWLIST = "allowlist"
DISABLED = "disabled"
class KuaishouSecurity:
def __init__(self, policy: DMPolicyMode = DMPolicyMode.OPEN, allow_from: list[str] | None = None):
self._policy = policy
self._allowlist: set[str] = set(allow_from or [])
def check_allow(self, open_id: str) -> bool:
if self._policy == DMPolicyMode.OPEN:
return True
if self._policy == DMPolicyMode.DISABLED:
return False
if self._policy == DMPolicyMode.ALLOWLIST:
return open_id in self._allowlist
return True
def resolve_policy(self) -> dict:
return {
"mode": self._policy.value,
"allow_from": list(self._allowlist),
}

View File

@ -0,0 +1,40 @@
from __future__ import annotations
import hashlib
import hmac
def sha1_sign(secret: str, timestamp: str, nonce: str, body: str) -> str:
"""SHA1 签名(历史兼容,快手官方 WebHook 不使用此算法)。"""
params = sorted([secret, timestamp, nonce, body])
sign_str = "".join(params)
return hashlib.sha1(sign_str.encode()).hexdigest()
def md5_sign(body: str, secret: str) -> str:
"""MD5 签名MD5(body + secret)。
快手官方 WebHook 签名方式通过 kwaisign header 传递 MD5(body + appsecret)
"""
return hashlib.md5((body + secret).encode()).hexdigest()
def kwaisign_verify(body: bytes, app_secret: str, signature: str) -> bool:
"""验证快手官方 WebHook 签名kwaisign header
官方算法MD5(request_body + appsecret)通过 kwaisign HTTP Header 传递
"""
expected = hashlib.md5(body + app_secret.encode()).hexdigest()
return hmac.compare_digest(expected, signature)
def verify_sha1(secret: str, timestamp: str, nonce: str, body: str, signature: str) -> bool:
"""验证 SHA1 签名(历史兼容)。"""
computed = sha1_sign(secret, timestamp, nonce, body)
return hmac.compare_digest(computed, signature)
def verify_md5(body: str, secret: str, signature: str) -> bool:
"""验证 MD5 签名。"""
computed = md5_sign(body, secret)
return hmac.compare_digest(computed, signature)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
import logging
from .gateway import KuaishouGateway
logger = logging.getLogger(__name__)
class KuaishouStatus:
def __init__(self, gateway: KuaishouGateway):
self._gateway = gateway
async def probe(self) -> bool:
return await self._gateway.probe()
def build_summary(self, snapshot) -> dict:
probe_ok = snapshot.get("probe_ok", False)
account_id = self._gateway.account_id
return {
"status": "running" if probe_ok else "error",
"reason": "" if probe_ok else "token 验证失败",
"account_id": account_id,
}

View File

@ -0,0 +1,58 @@
from __future__ import annotations
import asyncio
import logging
from .types import OutboundResult
logger = logging.getLogger(__name__)
# 默认块级流式参数经验值P2-2 阶段需根据官方频率限制调整)
DEFAULT_MIN_CHARS = 800
DEFAULT_MAX_CHARS = 1200
DEFAULT_BLOCK_DELAY_MS = 400
class KuaishouBlockStreaming:
def __init__(
self,
outbound,
min_chars: int = DEFAULT_MIN_CHARS,
max_chars: int = DEFAULT_MAX_CHARS,
block_delay_ms: int = DEFAULT_BLOCK_DELAY_MS,
):
self._outbound = outbound
self._min_chars = min_chars
self._max_chars = max_chars
self._block_delay_ms = block_delay_ms
async def stream_text(
self,
to_user_id: str,
content: str,
) -> list[OutboundResult]:
results: list[OutboundResult] = []
pos = 0
while pos < len(content):
chunk_end = min(pos + self._max_chars, len(content))
if chunk_end < len(content):
break_point = content.rfind("\n", pos, chunk_end)
if break_point > pos + self._min_chars:
chunk_end = break_point
chunk = content[pos:chunk_end]
try:
result = await self._outbound.send_text(to_user_id, chunk)
results.append(result)
except Exception:
logger.exception("流式发送块失败")
# TODO(P2-2): 收到 429 时实现指数退避重试
raise
pos = chunk_end
if pos < len(content):
await asyncio.sleep(self._block_delay_ms / 1000)
return results

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class KuaishouAccount:
account_id: str = "default"
app_id: str = ""
app_secret: str = ""
webhook_token: str = ""
dm_policy: str = "open"
streaming: bool = True
max_text_length: int = 2000
http_timeout_ms: int = 15000
enabled: bool = False
redirect_uri: str = ""
scopes: str = "user_info"
def is_configured(self) -> bool:
return bool(self.app_id and self.app_secret)
def is_enabled(self) -> bool:
return self.enabled and self.is_configured()
@dataclass
class InboundKuaishouMessage:
msg_id: str
open_id: str
msg_type: str
content: str
create_time: int
conversation_type: str
raw_event: dict = field(default_factory=dict)
nickname: str = ""
avatar_url: str = ""
# TODO(P2-3): 如官方支持群聊,增加以下字段
chat_type: str = "direct"
group_id: str = ""
@dataclass
class OutboundResult:
success: bool
message_id: str = ""
error: str = ""
raw_response: dict = field(default_factory=dict)
@dataclass
class TokenInfo:
access_token: str
expires_at: float
refresh_token: str = ""
refresh_expires_at: float = 0.0

View File

@ -0,0 +1,66 @@
from __future__ import annotations
import json
import logging
import time
from typing import Any
from fastapi import APIRouter, Request, Response
from .signature import kwaisign_verify
from .types import InboundKuaishouMessage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/kuaishou", tags=["kuaishou"])
# TODO(P1-2): 当快手开放 IM API 后,替换为官方实际事件类型
KUAISHOU_IM_EVENT_TYPE = "im_message_receive"
def parse_inbound_message(raw: dict[str, Any]) -> InboundKuaishouMessage | None:
try:
event = raw.get("event", "")
if event != KUAISHOU_IM_EVENT_TYPE:
return None
msg_data = raw.get("message", raw)
return InboundKuaishouMessage(
msg_id=str(msg_data.get("msg_id", "")),
open_id=str(msg_data.get("open_id", "")),
msg_type=msg_data.get("msg_type", "text"),
content=msg_data.get("content", ""),
create_time=msg_data.get("create_time", int(time.time() * 1000)),
conversation_type=msg_data.get("conversation_type", "1"),
nickname=msg_data.get("nickname", ""),
avatar_url=msg_data.get("avatar", ""),
raw_event=raw,
)
except Exception:
logger.exception("解析快手入站消息失败")
return None
def create_webhook_handler(app_secret: str):
@router.post("/callback")
async def kuaishou_webhook(request: Request):
body_bytes = await request.body()
# 快手官方 WebHook 签名通过 kwaisign header 传递算法MD5(body + appsecret)
kwaisign = request.headers.get("kwaisign", "")
if not kwaisign_verify(body_bytes, app_secret, kwaisign):
return Response(content='{"status":"signature_invalid"}', status_code=403)
event_data = json.loads(body_bytes.decode("utf-8"))
if event_data.get("event") != KUAISHOU_IM_EVENT_TYPE:
return {"status": "ok"}
inbound = parse_inbound_message(event_data)
if inbound is None:
return {"status": "ok"}
return {"status": "ok", "msg_id": inbound.msg_id}
return kuaishou_webhook