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:
parent
bb8ade9e2b
commit
f6df6e2c95
308
backend/package/yuxi/channel/extensions/kuaishou/__init__.py
Normal file
308
backend/package/yuxi/channel/extensions/kuaishou/__init__.py
Normal 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())
|
||||||
@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
131
backend/package/yuxi/channel/extensions/kuaishou/api.py
Normal file
131
backend/package/yuxi/channel/extensions/kuaishou/api.py
Normal 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 授权 URL(authorization_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()
|
||||||
75
backend/package/yuxi/channel/extensions/kuaishou/config.py
Normal file
75
backend/package/yuxi/channel/extensions/kuaishou/config.py
Normal 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_info(API 未开放前无需配置)",
|
||||||
|
"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"],
|
||||||
|
}
|
||||||
29
backend/package/yuxi/channel/extensions/kuaishou/dedupe.py
Normal file
29
backend/package/yuxi/channel/extensions/kuaishou/dedupe.py
Normal 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)
|
||||||
31
backend/package/yuxi/channel/extensions/kuaishou/format.py
Normal file
31
backend/package/yuxi/channel/extensions/kuaishou/format.py
Normal 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)
|
||||||
102
backend/package/yuxi/channel/extensions/kuaishou/gateway.py
Normal file
102
backend/package/yuxi/channel/extensions/kuaishou/gateway.py
Normal 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 未开放,不自动获取 token(authorization_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
|
||||||
34
backend/package/yuxi/channel/extensions/kuaishou/media.py
Normal file
34
backend/package/yuxi/channel/extensions/kuaishou/media.py
Normal 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
|
||||||
21
backend/package/yuxi/channel/extensions/kuaishou/monitor.py
Normal file
21
backend/package/yuxi/channel/extensions/kuaishou/monitor.py
Normal 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,
|
||||||
|
}
|
||||||
93
backend/package/yuxi/channel/extensions/kuaishou/outbound.py
Normal file
93
backend/package/yuxi/channel/extensions/kuaishou/outbound.py
Normal 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
|
||||||
25
backend/package/yuxi/channel/extensions/kuaishou/pairing.py
Normal file
25
backend/package/yuxi/channel/extensions/kuaishou/pairing.py
Normal 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
|
||||||
31
backend/package/yuxi/channel/extensions/kuaishou/plugin.json
Normal file
31
backend/package/yuxi/channel/extensions/kuaishou/plugin.json
Normal 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"
|
||||||
|
}
|
||||||
31
backend/package/yuxi/channel/extensions/kuaishou/security.py
Normal file
31
backend/package/yuxi/channel/extensions/kuaishou/security.py
Normal 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),
|
||||||
|
}
|
||||||
@ -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)
|
||||||
24
backend/package/yuxi/channel/extensions/kuaishou/status.py
Normal file
24
backend/package/yuxi/channel/extensions/kuaishou/status.py
Normal 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,
|
||||||
|
}
|
||||||
@ -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
|
||||||
56
backend/package/yuxi/channel/extensions/kuaishou/types.py
Normal file
56
backend/package/yuxi/channel/extensions/kuaishou/types.py
Normal 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
|
||||||
66
backend/package/yuxi/channel/extensions/kuaishou/webhook.py
Normal file
66
backend/package/yuxi/channel/extensions/kuaishou/webhook.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user