import asyncio
import logging
import secrets
import time
from datetime import datetime, UTC
from fastapi import APIRouter, Query, Request
from fastapi.responses import PlainTextResponse, Response
from yuxi.channel.extensions.wecom.__init__ import _wecom_plugin
from yuxi.channel.extensions.wecom.crypto import WeChatCrypto
from yuxi.channel.extensions.wecom.config import WeComConfig
from yuxi.channel.extensions.wecom.events import handle_event
from yuxi.channel.extensions.wecom.message import extract_content, parse_xml_to_message
from yuxi.channel.extensions.wecom.mentions import is_bot_mentioned
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
from yuxi.channel.runtime.manager import gateway
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/wecom", tags=["wecom"])
_config = WeComConfig()
def _build_crypto() -> WeChatCrypto | None:
account = _config.resolve_account()
if not account.is_configured():
return None
return WeChatCrypto(
token=account.token,
encoding_aes_key=account.encoding_aes_key,
app_id=account.corp_id,
)
def _build_outbound():
from yuxi.channel.extensions.wecom.gateway import _get_gateway
gw = _get_gateway()
if gw is None:
return None
from yuxi.channel.extensions.wecom.outbound import WeComOutbound
return WeComOutbound(gw)
def _build_passive_reply_xml(to_user: str, from_user: str, content: str, crypto: WeChatCrypto) -> str:
reply_xml = f"""
{int(time.time())}
"""
timestamp = str(int(time.time()))
nonce = secrets.token_hex(8)
encrypted = crypto.encrypt(reply_xml)
signature = crypto.compute_signature(timestamp, nonce, encrypted)
return f"""
{timestamp}
"""
@router.get("/callback")
async def wecom_url_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
crypto = _build_crypto()
if crypto is None:
return PlainTextResponse("", status_code=503)
if not crypto.verify_signature(msg_signature, timestamp, nonce, echostr):
logger.warning("WeCom URL verification: invalid signature")
return PlainTextResponse("", status_code=403)
try:
decrypted, _corp_id = crypto.decrypt(echostr)
return PlainTextResponse(decrypted)
except Exception:
logger.exception("WeCom echostr decryption failed")
return PlainTextResponse("", status_code=403)
@router.post("/callback")
async def wecom_message_callback(
request: Request,
msg_signature: str = Query(default=""),
timestamp: str = Query(default=""),
nonce: str = Query(default=""),
):
crypto = _build_crypto()
if crypto is None:
return PlainTextResponse("", status_code=503)
xml_body = await request.body()
xml_text = xml_body.decode("utf-8")
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_text)
encrypt_el = root.find("Encrypt")
encrypt = encrypt_el.text or "" if encrypt_el is not None else ""
if not crypto.verify_signature(msg_signature, timestamp, nonce, encrypt):
logger.warning("WeCom callback: invalid msg_signature")
return PlainTextResponse("", status_code=403)
try:
decrypted_xml, _corp_id = crypto.decrypt(encrypt)
except Exception:
logger.exception("WeCom message decryption failed")
return PlainTextResponse("", status_code=403)
try:
raw_msg = parse_xml_to_message(decrypted_xml)
except Exception:
logger.exception("WeCom XML parsing failed")
return PlainTextResponse("", status_code=400)
if raw_msg.from_user == raw_msg.to_user:
return PlainTextResponse("success")
account = _config.resolve_account()
plugin = _wecom_plugin
if raw_msg.msg_type == "event":
reply_content = await handle_event(raw_msg)
if reply_content:
passive_xml = _build_passive_reply_xml(
raw_msg.from_user,
raw_msg.to_user,
reply_content,
crypto,
)
return Response(content=passive_xml, media_type="application/xml")
return PlainTextResponse("success")
if not raw_msg.msg_id or plugin.is_duplicate(raw_msg.msg_id):
return PlainTextResponse("success")
content = extract_content(raw_msg)
if not content:
return PlainTextResponse("success")
if not raw_msg.is_group_chat:
policy = account.dm_policy
if policy == "disabled":
return PlainTextResponse("success")
if policy == "pairing":
if not plugin.check_allowlist(raw_msg.from_user, "direct"):
if content.strip().startswith("配对 "):
code_input = content.strip()[3:].strip()
if await plugin.verify_code(raw_msg.from_user, code_input):
plugin.add_to_allowlist(raw_msg.from_user)
passive_xml = _build_passive_reply_xml(
raw_msg.from_user,
raw_msg.to_user,
"配对成功!现在可以开始对话了。",
crypto,
)
return Response(content=passive_xml, media_type="application/xml")
else:
passive_xml = _build_passive_reply_xml(
raw_msg.from_user,
raw_msg.to_user,
"配对码无效或已过期。",
crypto,
)
return Response(content=passive_xml, media_type="application/xml")
else:
code = await plugin.generate_code(raw_msg.from_user)
if code:
passive_xml = _build_passive_reply_xml(
raw_msg.from_user,
raw_msg.to_user,
f"首次对话需要验证身份:\n\n配对 {code}\n(有效期 10 分钟)",
crypto,
)
return Response(content=passive_xml, media_type="application/xml")
return PlainTextResponse("success")
if policy == "allowlist":
if not plugin.check_allowlist(raw_msg.from_user, "direct"):
return PlainTextResponse("success")
else:
group_policy = account.group_policy
if group_policy == "disabled":
return PlainTextResponse("success")
if group_policy == "activate_on_mention":
if not is_bot_mentioned(content):
return PlainTextResponse("success")
processor = gateway._processor
if processor is None:
return PlainTextResponse("success")
peer_id = raw_msg.peer_id
peer_kind = PeerKind.GROUP if raw_msg.is_group_chat else PeerKind.DIRECT
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="wecom",
account_id="default",
content=content,
message_type=MessageType.TEXT,
sender=PeerInfo(
id=raw_msg.from_user,
kind=peer_kind,
display_name=raw_msg.from_user,
),
recipient=PeerInfo(
id=peer_id,
kind=peer_kind,
display_name=raw_msg.chat_id if raw_msg.is_group_chat else raw_msg.from_user,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=UTC) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_xml,
body_for_agent=content,
metadata={
"FromUserName": raw_msg.from_user,
"ToUserName": raw_msg.to_user,
"MsgType": raw_msg.msg_type,
"ChatType": raw_msg.chat_type,
"ChatId": raw_msg.chat_id,
"AgentID": raw_msg.agent_id,
"MediaId": raw_msg.media_id,
"PicUrl": raw_msg.pic_url,
},
)
asyncio.create_task(
_dispatch_to_agent(processor, unified),
name=f"wecom-dispatch-{raw_msg.from_user[:20]}",
)
return PlainTextResponse("success")
async def _dispatch_to_agent(processor, msg: UnifiedMessage) -> None:
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("Agent response timeout for wecom user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process WeCom message for user %s", msg.sender.id)