ForcePilot/backend/package/yuxi/channel/extensions/weibo/webhook.py
Kris 7215418610 feat(channel): 添加企业微信、微博、WhatsApp 和 Workplace 渠道扩展
新增企业微信、微博、WhatsApp、Workplace 四个渠道扩展。

企业微信渠道扩展主要模块:config, gateway, webhook, webhook_bot, outbound, streaming, pairing, security, crypto, dedupe, persistent_dedupe, card, directory, events, externalcontact, media, mentions, menu, message, oauth, status

微博渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, passive_reply, broadcast, message, menu, media, subscription, template, status

WhatsApp 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, monitor, status

Workplace 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, challenge, groups, media, mentions, menu, monitor, persona, quick_reply, signature, subscriptions, template, threading, users, status
2026-05-21 12:01:56 +08:00

284 lines
11 KiB
Python

import asyncio
import hashlib
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from yuxi.channel.extensions.weibo.config import WeiboConfig
from yuxi.channel.extensions.weibo.message import extract_content, parse_weibo_message
from yuxi.channel.extensions.weibo.outbound import WeiboOutbound, remove_markdown
from yuxi.channel.extensions.weibo.pairing import WeiboPairing
from yuxi.channel.extensions.weibo.security import WeiboSecurity
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.plugins.registry import ChannelPluginRegistry
from yuxi.channel.routing.models import PeerKind
from yuxi.channel.runtime.manager import gateway
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/weibo", tags=["weibo"])
_config = WeiboConfig()
_pairing = WeiboPairing()
def _get_plugin():
return ChannelPluginRegistry.get("weibo")
@router.get("/callback")
async def weibo_url_verify(
signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
account = _config.resolve_account()
if not account.is_configured():
return PlainTextResponse("not configured", status_code=503)
params = sorted([account.callback_token, str(timestamp), str(nonce)])
expected = hashlib.sha1("".join(params).encode("utf-8")).hexdigest()
if expected == signature:
return PlainTextResponse(echostr)
logger.warning("Weibo URL verification failed: signature mismatch")
return PlainTextResponse("signature verification failed", status_code=403)
@router.post("/callback")
async def weibo_message_callback(request: Request):
account = _config.resolve_account()
if not account.is_configured():
return JSONResponse({"result": False, "msg": "not configured"}, status_code=503)
body = await request.json()
try:
raw_msg = parse_weibo_message(body, account.app_key)
except Exception:
logger.exception("Weibo JSON parsing failed")
return JSONResponse({"result": False, "msg": "parse error"}, status_code=400)
if str(raw_msg.sender_id) == str(raw_msg.receiver_id):
return JSONResponse({"result": True, "msg": "ok"})
if raw_msg.msg_type == "event":
return await _handle_event(raw_msg, account)
plugin = _get_plugin()
if plugin is None:
logger.warning("Weibo plugin not registered")
return JSONResponse({"result": False, "msg": "plugin not ready"}, status_code=503)
if raw_msg.msg_id and plugin.is_duplicate(raw_msg.msg_id):
return JSONResponse({"result": True, "msg": "ok"})
gw = plugin.gateway
if gw:
gw.record_inbound(raw_msg.sender_id)
content = extract_content(raw_msg)
if not content:
return JSONResponse({"result": True, "msg": "ok"})
if account.remove_markdown:
content = remove_markdown(content)
security = WeiboSecurity(account)
policy = security.resolve_dm_policy()
if policy == "disabled":
return JSONResponse({"result": True, "msg": "ok"})
if policy == "pairing":
if not security.check_allowlist(raw_msg.sender_id):
if content.strip().startswith("配对 "):
code_input = content.strip()[3:].strip()
if _pairing.verify(raw_msg.sender_id, code_input):
security.add_to_allowlist(raw_msg.sender_id)
await _send_weibo_text(raw_msg.sender_id, "配对成功!现在可以开始对话了。")
else:
await _send_weibo_text(raw_msg.sender_id, "配对码无效或已过期,请重新发送消息获取配对码。")
else:
code = _pairing.generate_code(raw_msg.sender_id)
if code:
await _send_weibo_text(
raw_msg.sender_id,
f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)",
)
else:
await _send_weibo_text(raw_msg.sender_id, "配对请求过于频繁,请稍后再试。")
return JSONResponse({"result": True, "msg": "ok"})
if policy == "allowlist":
if not security.check_allowlist(raw_msg.sender_id):
return JSONResponse({"result": True, "msg": "ok"})
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available")
return JSONResponse({"result": False, "msg": "processor not ready"}, status_code=503)
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="weibo",
account_id="default",
content=content,
message_type=MessageType.TEXT,
sender=PeerInfo(
id=raw_msg.sender_id,
kind=PeerKind.DIRECT,
display_name=raw_msg.sender_id,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_json,
body_for_agent=content,
metadata={
"SenderId": raw_msg.sender_id,
"ReceiverId": raw_msg.receiver_id,
"MsgType": raw_msg.msg_type,
"SubType": raw_msg.sub_type,
"EventKey": raw_msg.event_key,
},
)
asyncio.create_task(
_dispatch_to_agent(processor, unified),
name=f"weibo-dispatch-{raw_msg.sender_id}",
)
return JSONResponse({"result": True, "msg": "ok"})
async def _handle_event(raw_msg, account) -> JSONResponse:
if raw_msg.sub_type == "follow":
welcome = account.subscribe_msg or ""
if welcome:
await _send_weibo_text(raw_msg.sender_id, welcome)
logger.info("Sent welcome message to %s", raw_msg.sender_id)
guide = getattr(account, "subscribe_guide", "")
if guide:
await _send_weibo_text(raw_msg.sender_id, guide)
logger.info("Sent subscribe guide to %s", raw_msg.sender_id)
elif raw_msg.sub_type == "subscribe":
logger.info("User %s subscribed (sent DY)", raw_msg.sender_id)
await _send_weibo_text(
raw_msg.sender_id,
"订阅成功!现在你可以接收服务消息了。如需退订,请发送 TD。",
)
elif raw_msg.sub_type == "unsubscribe":
logger.info("User %s unsubscribed (sent TD)", raw_msg.sender_id)
elif raw_msg.sub_type == "unfollow":
logger.info("User %s unfollowed, cleaning up state", raw_msg.sender_id)
plugin = _get_plugin()
if plugin:
plugin._dedup.reset()
security = WeiboSecurity(account)
security.remove_from_allowlist(raw_msg.sender_id)
elif raw_msg.sub_type == "click":
event_key = raw_msg.event_key or ""
logger.info("Menu click: key=%s, user=%s", event_key, raw_msg.sender_id)
processor = gateway._processor
if processor and event_key:
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="weibo",
account_id="default",
content=f"[菜单点击: {event_key}]",
message_type=MessageType.TEXT,
sender=PeerInfo(
id=raw_msg.sender_id,
kind=PeerKind.DIRECT,
display_name=raw_msg.sender_id,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_json,
body_for_agent=event_key,
metadata={
"SenderId": raw_msg.sender_id,
"ReceiverId": raw_msg.receiver_id,
"MsgType": "event",
"SubType": "click",
"EventKey": event_key,
},
)
asyncio.create_task(
_dispatch_to_agent(processor, unified),
name=f"weibo-menu-{raw_msg.sender_id}",
)
elif raw_msg.sub_type in ("scan", "scan_follow"):
event_key = raw_msg.event_key or ""
ticket = raw_msg.raw_json.get("ticket", "")
logger.info(
"Scan event: sub_type=%s, key=%s, ticket=%s, user=%s",
raw_msg.sub_type,
event_key,
ticket,
raw_msg.sender_id,
)
processor = gateway._processor
if processor:
scan_label = "扫码事件" if raw_msg.sub_type == "scan" else "扫码关注"
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="weibo",
account_id="default",
content=f"[{scan_label}] key={event_key}" + (f" ticket={ticket}" if ticket else ""),
message_type=MessageType.TEXT,
sender=PeerInfo(
id=raw_msg.sender_id,
kind=PeerKind.DIRECT,
display_name=raw_msg.sender_id,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_json,
body_for_agent=event_key,
metadata={
"SenderId": raw_msg.sender_id,
"ReceiverId": raw_msg.receiver_id,
"MsgType": "event",
"SubType": raw_msg.sub_type,
"EventKey": event_key,
"Ticket": ticket,
},
)
asyncio.create_task(
_dispatch_to_agent(processor, unified),
name=f"weibo-scan-{raw_msg.sender_id}",
)
elif raw_msg.sub_type == "masssend":
status = raw_msg.raw_json.get("status", "")
msg_id = raw_msg.raw_json.get("msg_id", "")
logger.info("Broadcast result: status=%s, msg_id=%s", status, msg_id)
return JSONResponse({"result": True, "msg": "ok"})
async def _dispatch_to_agent(processor, msg: UnifiedMessage) -> None:
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except asyncio.TimeoutError:
logger.error("Agent response timeout for weibo user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process Weibo message for user %s", msg.sender.id)
async def _send_weibo_text(to_user: str, content: str) -> None:
plugin = _get_plugin()
if plugin is None or plugin.gateway is None:
logger.warning("No Weibo gateway available")
return
outbound = WeiboOutbound(plugin.gateway)
try:
await outbound.send_text(to_user, content)
except Exception:
logger.exception("Failed to send Weibo text to %s", to_user)