153 lines
5.9 KiB
Plaintext
153 lines
5.9 KiB
Plaintext
# wechat_callback.dspy - 微信回调(验签 + 验证码绑定 + 消息接收 + openid映射 + gateway路由 + 被动回复)
|
||
# GET: 服务器配置验证(验签返回 echostr)
|
||
# POST: 接收消息 → 绑定验证码 或 openid映射 user_id → gateway.run_message → 回复 XML
|
||
|
||
import json
|
||
import hashlib
|
||
import re
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
|
||
signature = (params_kw or {}).get('signature', '')
|
||
timestamp = (params_kw or {}).get('timestamp', '')
|
||
nonce = (params_kw or {}).get('nonce', '')
|
||
echostr = (params_kw or {}).get('echostr', '')
|
||
|
||
dbname = get_module_dbname('pipeline-sdlc')
|
||
SYS_ORG_ID = '0' # 系统级公众号配置
|
||
|
||
|
||
async def _get_channel_config(sor):
|
||
"""取系统级公众号配置(org_id='0' 单条)。"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT appid, token, encoding_aes_key FROM wechat_channel_config "
|
||
"WHERE org_id=${o}$ AND enabled='1' LIMIT 1", {"o": SYS_ORG_ID})
|
||
if recs:
|
||
r = recs[0]
|
||
return {
|
||
'appid': getattr(r, 'appid', ''),
|
||
'token': getattr(r, 'token', ''),
|
||
'encoding_aes_key': getattr(r, 'encoding_aes_key', ''),
|
||
}
|
||
return None
|
||
|
||
|
||
async def _resolve_user(sor, openid):
|
||
"""openid → user_id(未绑定返回空)。"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT user_id FROM wechat_user_binding WHERE openid=${o}$", {"o": openid})
|
||
return getattr(recs[0], 'user_id', '') if recs else ''
|
||
|
||
|
||
# ── GET 验签 ──
|
||
if echostr:
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
cfg = await _get_channel_config(sor)
|
||
if not cfg:
|
||
return "未配置微信通道"
|
||
tmp = sorted([cfg['token'], timestamp, nonce])
|
||
sha1 = hashlib.sha1(''.join(tmp).encode('utf-8')).hexdigest()
|
||
if sha1 == signature:
|
||
return echostr
|
||
return "验签失败"
|
||
|
||
|
||
# ── POST 消息处理 ──
|
||
# 微信消息 XML 在 request body。Sage DSPY 环境:优先取 params_kw 里的 xml,否则取 request body。
|
||
xml_body = (params_kw or {}).get('xml', '')
|
||
if not xml_body:
|
||
try:
|
||
xml_body = await request.text() if hasattr(request, 'text') else ''
|
||
except Exception:
|
||
xml_body = ''
|
||
if not xml_body:
|
||
try:
|
||
xml_body = (await request.read()).decode('utf-8') if hasattr(request, 'read') else ''
|
||
except Exception:
|
||
xml_body = ''
|
||
|
||
if not xml_body:
|
||
return ""
|
||
|
||
try:
|
||
root = ET.fromstring(xml_body)
|
||
except Exception:
|
||
return ""
|
||
|
||
def _x(node, name):
|
||
e = node.find(name)
|
||
return e.text if e is not None else ''
|
||
|
||
to_user = _x(root, 'ToUserName') # 公众号原始ID
|
||
from_user = _x(root, 'FromUserName') # openid
|
||
msg_type = _x(root, 'MsgType')
|
||
content = _x(root, 'Content').strip()
|
||
|
||
if msg_type != 'text' or not from_user:
|
||
reply_text = "暂只支持文本消息"
|
||
else:
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
# ── 验证码绑定:用户发 "绑定 123456" 或 "bd 123456" ──
|
||
bind_match = re.match(r'^(?:绑定|bd)\s*(\d{6})\s*$', content, re.IGNORECASE)
|
||
if bind_match:
|
||
code = bind_match.group(1)
|
||
recs = await sor.sqlExe(
|
||
"SELECT user_id, org_id FROM wechat_bind_code "
|
||
"WHERE code=${c}$ AND expires_at > NOW()", {"c": code})
|
||
if not recs:
|
||
reply_text = "验证码无效或已过期,请在系统中重新生成"
|
||
else:
|
||
b = recs[0]
|
||
bind_uid = getattr(b, 'user_id', '')
|
||
bind_org = getattr(b, 'org_id', '')
|
||
# openid 是否已被其他账号绑定
|
||
ex = await sor.sqlExe(
|
||
"SELECT user_id FROM wechat_user_binding WHERE openid=${o}$", {"o": from_user})
|
||
if ex and getattr(ex[0], 'user_id', '') != bind_uid:
|
||
reply_text = "该微信已绑定其他账号"
|
||
else:
|
||
await sor.sqlExe(
|
||
"DELETE FROM wechat_user_binding WHERE user_id=${u}$", {"u": bind_uid})
|
||
await sor.C('wechat_user_binding', {
|
||
'id': getID(), 'openid': from_user, 'user_id': bind_uid,
|
||
'org_id': bind_org,
|
||
})
|
||
await sor.sqlExe(
|
||
"DELETE FROM wechat_bind_code WHERE code=${c}$", {"c": code})
|
||
reply_text = "✅ 绑定成功!现在可以直接发消息给我了"
|
||
else:
|
||
# ── 正常消息:openid → user_id → gateway ──
|
||
user_id = await _resolve_user(sor, from_user)
|
||
if not user_id:
|
||
reply_text = "您还未绑定账号。请先在系统中「微信通道 → 绑定微信」生成验证码,然后回复「绑定 验证码」"
|
||
else:
|
||
from pipeline_service.gateway import get_gateway
|
||
gw = get_gateway()
|
||
parts = []
|
||
try:
|
||
async for chunk in gw.run_message("wechat", user_id, content):
|
||
d = json.loads(chunk)
|
||
t = d.get('type', '')
|
||
if t == 'reply':
|
||
parts.append(d.get('message', ''))
|
||
elif t == 'ask_user':
|
||
parts.append("❓ " + d.get('message', ''))
|
||
elif t == 'error':
|
||
parts.append("⚠️ " + d.get('message', ''))
|
||
# tool_call/tool_result 不推送给微信(避免刷屏)
|
||
except Exception as e:
|
||
parts.append(f"ERROR: {str(e)[:200]}")
|
||
reply_text = "\n".join(parts) if parts else "(无回复)"
|
||
|
||
# ── 被动回复 XML ──
|
||
reply_xml = (
|
||
"<xml>"
|
||
f"<ToUserName><![CDATA[{from_user}]]></ToUserName>"
|
||
f"<FromUserName><![CDATA[{to_user}]]></FromUserName>"
|
||
f"<CreateTime>{int(time.time())}</CreateTime>"
|
||
"<MsgType><![CDATA[text]]></MsgType>"
|
||
f"<Content><![CDATA[{reply_text}]]></Content>"
|
||
"</xml>"
|
||
)
|
||
return reply_xml
|