#970 微信Bot P1增强 - sendtyping + 语音ASR + Session Guard
CI / gate (push) Has been cancelled

1.语音消息ASR: 提取voice_item.text(微信自带转文字) 2.sendtyping正在输入状态: getconfig拿typing_ticket缓存24h + sendtyping发送 3.Session Guard增强: 会话过期记录告警日志供前台提示重扫
This commit is contained in:
qihu-bot committed 2026-08-15 18:13:47 +08:00
1 parent 46e9f1e1e6
commit e284d43cc7
1 file changed
+66 -2
+66 -2
View File
@@ -54,6 +54,9 @@ WECHAT_TEXT_MAX_LEN = 1500
# 每个已连接账号一个后台接收线程,key = 账号表主键 id # 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {} _wechat_receiver_threads: dict[int, threading.Thread] = {}
# typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效
_typing_ticket_cache: dict[str, tuple[str, float]] = {}
# ============================================================ # ============================================================
# 通用 DB 工具 # 通用 DB 工具
@@ -307,6 +310,45 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
return all_ok, last_ret, {} return all_ok, last_ret, {}
def _wechat_get_typing_ticket(baseurl: str, bot_token: str, ilink_user_id: str, context_token: str) -> str:
"""getconfig 获取 typing_ticket(按 ilink_user_id 缓存 24h)。"""
global _typing_ticket_cache
_now = time.time()
_hit = _typing_ticket_cache.get(ilink_user_id)
if _hit and (_now - _hit[1]) < 86400:
return _hit[0]
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getconfig",
headers=_wechat_headers(bot_token),
json={"ilink_user_id": ilink_user_id, "context_token": context_token},
timeout=10,
)
body = resp.json() if resp.content else {}
ticket = body.get("typing_ticket", "") if isinstance(body, dict) else ""
if ticket:
_typing_ticket_cache[ilink_user_id] = (ticket, _now)
return ticket
except Exception:
return ""
def _wechat_send_typing(baseurl: str, bot_token: str, ilink_user_id: str, typing_ticket: str, status: int = 1) -> bool:
"""发送/取消"正在输入"状态。status=1 typing,2 cancel。失败静默(不阻塞主流程)。"""
if not ilink_user_id or not typing_ticket:
return False
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/sendtyping",
headers=_wechat_headers(bot_token),
json={"ilink_user_id": ilink_user_id, "typing_ticket": typing_ticket, "status": status},
timeout=10,
)
return resp.ok
except Exception:
return False
def _platform_relay_url(raw_url: str) -> str: def _platform_relay_url(raw_url: str) -> str:
"""把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。 """把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。
@@ -367,8 +409,14 @@ def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_to
def _wechat_extract_text(msg: dict) -> str: def _wechat_extract_text(msg: dict) -> str:
parts = [] parts = []
for it in (msg.get("item_list") or []): for it in (msg.get("item_list") or []):
if it.get("type") == 1 and it.get("text_item", {}).get("text"): _t = it.get("type")
if _t == 1 and it.get("text_item", {}).get("text"):
parts.append(it["text_item"]["text"]) parts.append(it["text_item"]["text"])
elif _t == 3:
# 语音消息:微信已自带 ASR 转文字(voice_item.text),直接复用
voice = it.get("voice_item") or {}
if voice.get("text"):
parts.append("[语音] " + str(voice["text"]))
if not parts: if not parts:
for k in ("text", "content", "message", "msg"): for k in ("text", "content", "message", "msg"):
v = msg.get(k) v = msg.get(k)
@@ -480,7 +528,7 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
# 后台接收线程(每用户独立) # 后台接收线程(每用户独立)
# ============================================================ # ============================================================
def _mark_wechat_expired(account_id: int): def _mark_wechat_expired(account_id: int):
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" """发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。"""
db = None db = None
try: try:
db = SessionLocal() db = SessionLocal()
@@ -488,6 +536,14 @@ def _mark_wechat_expired(account_id: int):
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
"updated_at=:ts WHERE id=:aid" "updated_at=:ts WHERE id=:aid"
), {"aid": account_id, "ts": _beijing_now()}) ), {"aid": account_id, "ts": _beijing_now()})
# Session Guard:记录账号信息,供前台提示用户重扫
row = _execute_raw(db, text("SELECT user_id, username FROM im_bot_wechat_account WHERE id=:aid"), {"aid": account_id})
_uid = row[0][0] if row else "?"
try:
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SESSION_EXPIRED account_id=%s user_id=%s\n" % (account_id, _uid))
except Exception:
pass
except Exception: except Exception:
pass pass
finally: finally:
@@ -530,6 +586,14 @@ def _wechat_receiver_loop(account_id: int):
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n") + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
except Exception: except Exception:
pass pass
# 发送"正在输入"状态(尽力而为,失败静默)
try:
_bot_id = m.get("to_user_id") or d.get("ilink_bot_id") or ""
_ticket = _wechat_get_typing_ticket(baseurl, bot_token, _bot_id, ctx)
if _bot_id and _ticket:
_wechat_send_typing(baseurl, bot_token, _bot_id, _ticket, 1)
except Exception:
pass
# 先生成回复(此时历史尚未包含本条,避免重复) # 先生成回复(此时历史尚未包含本条,避免重复)
import logging as _lrx, inspect as _insp import logging as _lrx, inspect as _insp
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})') _lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})')