新增企业微信、微博、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
284 lines
11 KiB
Python
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) |