""" Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块 im_bot_user.py 为【平台注册用户】提供"各自绑定自己的个人微信"能力: - 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离) - 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN - 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用) 与后台 im_bot_admin 的区别: - 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信 端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。 - 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。 """ import base64 import json import os import random import threading import time from datetime import datetime, timedelta, timezone from typing import Optional from urllib.parse import quote as _quote import requests from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import text from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import SessionLocal from ..models import User router = APIRouter() # 北京时间(UTC+8)。容器系统时区为 UTC,所有对外展示/持久化的时间戳必须按北京时间存储, # 否则前台微信 Bot 对话窗会显示成 UTC(比系统时间早 8 小时)。 TZ_BEIJING = timezone(timedelta(hours=8)) def _beijing_now() -> datetime: """返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。""" return datetime.now(TZ_BEIJING).replace(tzinfo=None) # ============================================================ # iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID) # ============================================================ ILINK_BASE = "https://ilinkai.weixin.qq.com" # 微信/iLink 单条文本消息长度软上限。超过则分段发送,避免 iLink 返回 200 # 但用户侧静默收不到(实测 1698 字符 HTML 链接、3011 字符 Markdown 均丢失)。 WECHAT_TEXT_MAX_LEN = 1500 # 每个已连接账号一个后台接收线程,key = 账号表主键 id _wechat_receiver_threads: dict[int, threading.Thread] = {} # typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效 _typing_ticket_cache: dict[str, tuple[str, float]] = {} # ============================================================ # 通用 DB 工具 # ============================================================ def _execute_raw(db: Session, sql_text, params=None): result = db.execute(sql_text, params or {}) if result.returns_rows: return result.fetchall() db.commit() return result def _row_to_dict(row) -> dict: if row is None: return None d = {} for col in row._mapping.keys(): val = row._mapping[col] if isinstance(val, datetime): val = val.isoformat() d[col] = val return d # ============================================================ # iLink 协议请求工具 # ============================================================ def _wechat_headers(bot_token: str) -> dict: """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") return { "Content-Type": "application/json", "Authorization": f"Bearer {bot_token}", "AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": uin, } def _wechat_row_for_user(db: Session, user_id: str): rows = _execute_raw( db, text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"), {"uid": user_id}, ) return rows[0] if rows else None def _wechat_mask(row) -> dict: d = _row_to_dict(row) if d.get("bot_token"): bt = d["bot_token"] d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" d.pop("appid", None) d.pop("secret_key", None) return d # ============================================================ # 七虎AI 大脑(复用 channels 的真实 LLM 调用) # ============================================================ def _recent_history(user_id: str, limit: int = 6) -> str: """拼接最近若干轮对话,作为多轮上下文。""" db = None try: db = SessionLocal() rows = _execute_raw(db, text( "SELECT role, content FROM im_bot_chat_log " "WHERE user_id=:uid ORDER BY id DESC LIMIT :lim" ), {"uid": user_id, "lim": limit}) pairs = [] for r in reversed(rows): dd = _row_to_dict(r) role = "用户" if dd["role"] == "user" else "七虎AI" pairs.append(f"{role}:{dd['content']}") return "\n".join(pairs) except Exception: return "" finally: if db: db.close() def _qihua_reply(text: str, user_id: str = None) -> str: """调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。""" prompt = text if user_id: hist = _recent_history(user_id) if hist: prompt = ( "以下是你与该用户最近的对话历史,请结合上下文作答:\n" f"{hist}\n\n" f"用户当前问题:{text}" ) # 磁盘调试日志(不依赖 uvicorn logger) try: with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + f" _qihua_reply user={user_id} prompt_len={len(prompt)} prompt={repr(prompt)[:500]}\n") except Exception: pass db = None try: from app.api.im_llm import _call_real_llm db = SessionLocal() result = _call_real_llm(db, None, prompt, "个人微信", user_id=user_id) try: with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + f" _qihua_reply RESULT len={len(result) if result else 0} preview={repr(result)[:200]}\n") except Exception: pass return result except Exception as e: import traceback as _tb2 import logging _log = logging.getLogger('im_bot_user') _log.warning(f'[_qihua_reply] 异常: {e}\n{_tb2.format_exc()[:1000]}') try: with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + f" _qihua_reply EXCEPTION: {e}\n{_tb2.format_exc()[:800]}\n") except Exception: pass return f"(AI 回复生成失败:{e})" finally: if db: db.close() # ============================================================ # iLink 消息收发 # ============================================================ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" try: resp = requests.post( baseurl.rstrip("/") + "/ilink/bot/getupdates", headers=_wechat_headers(bot_token), json={"get_updates_buf": cursor}, timeout=40, ) if not resp.ok: return [], cursor j = resp.json() if resp.content else {} return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) except Exception: return [], cursor def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list): """向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。 ret=-14=会话过期需重扫;ret=-2=token 失效。""" payload = { "msg": { "to_user_id": to_user_id, "message_type": 2, "message_state": 2, "context_token": context_token, "item_list": item_list, } } last_body = {} # 07-30 diagnostic import json as _json3 try: # 增强诊断:记录每条消息类型、长度、内容摘要,用于定位“iLink 200 但用户收不到” _item_summaries = [] for _it in item_list: _t = _it.get("type") _txt = "" if _t == 1 and _it.get("text_item"): _txt = _it["text_item"].get("text", "") _item_summaries.append(f"type={_t} len={len(_txt)} text={repr(_txt[:80])}") with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " SEND_DEBUG token=" + repr(bot_token[:20] if bot_token else "NULL") + " to=" + repr(to_user_id[:30]) + " ctx=" + repr(context_token[:30]) + "\n") _f.write(" payload_keys=" + str(list(payload.get("msg", {}).keys())) + " items=[" + "; ".join(_item_summaries) + "]\n") except Exception: pass for _ in range(2): try: resp = requests.post( baseurl.rstrip("/") + "/ilink/bot/sendmessage", headers=_wechat_headers(bot_token), json=payload, timeout=20, ) try: body = resp.json() if resp.content else {} except Exception: body = {} last_body = body ret = body.get("ret") if isinstance(body, dict) else None try: with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) except Exception: pass if resp.ok and (ret is None or ret == 0 or not body): return True, ret, body if ret in (-2, -14): return False, ret, body # 鉴权/会话失效,立即停止重试 except Exception: pass return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str): """发送纯文本回复。长文本按 WECHAT_TEXT_MAX_LEN 分段,避免 iLink 200 但微信不显示。""" if not text: return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [{"type": 1, "text_item": {"text": "(无内容)"}}]) # 单条不超过软上限;保留完整段落优先在换行处切分 chunks = [] start = 0 while start < len(text): end = min(start + WECHAT_TEXT_MAX_LEN, len(text)) if end < len(text): # 尽量在换行处切断 _nl = text.rfind("\n", start, end) if _nl > start + WECHAT_TEXT_MAX_LEN // 2: end = _nl + 1 chunks.append(text[start:end]) start = end all_ok = True last_ret = None for idx, chunk in enumerate(chunks): suffix = "" if len(chunks) > 1: if idx == 0: suffix = "\n(内容较长,分条发送)" if idx == len(chunks) - 1: suffix = "\n(已发送完毕;完整内容请访问平台 AI 聊天的 WeXin Bot 窗口)" ok, ret, _ = _wechat_post_msg( baseurl, bot_token, to_user_id, context_token, [{"type": 1, "text_item": {"text": chunk + suffix}}] ) if not ok: all_ok = False last_ret = ret # 记录失败但不中断,尽量把已切好的消息发完 try: with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " SEGMENT_FAIL idx=%s ret=%s\n" % (idx, ret)) except Exception: pass # 分段之间稍作间隔,降低频率/风控影响 if idx < len(chunks) - 1: time.sleep(0.5) 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: """把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。 iLink 服务器(中国大陆)直连境外 AGNES 常被墙/超时,微信里只能看到"链接"。 改写为平台中转地址(平台服务器经香港代理可达 AGNES),微信即可内联显示图片/视频。 """ access = os.environ.get("APP_ACCESS_URL", "http://210.16.161.218:12888").rstrip("/") return f"{access}/app/api/chat/media/relay?url={_quote(raw_url, safe='')}" def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str, media_type: str, url: str): """发送媒体(图片/视频)。#969 iLink CDN 内联发送 优先走 iLink 官方 CDN 加密上传链路,实现图片/视频真正内联显示; 失败时降级为纯文本链接(点击访问),确保会话不中断。 """ relay_url = _platform_relay_url(url) # 尝试内联发送(iLink CDN 加密上传) try: from app.api.im_ilink_media import ( MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO, download_media_bytes, upload_media_to_wechat_cdn, build_image_item, build_video_item, ) media_bytes = download_media_bytes(url) mt = MEDIA_TYPE_IMAGE if media_type == "image" else MEDIA_TYPE_VIDEO uploaded = upload_media_to_wechat_cdn(baseurl, bot_token, to_user_id, media_bytes, mt) item = build_image_item(uploaded) if media_type == "image" else build_video_item(uploaded) ok, ret, _ = _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item]) if ok: try: with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " MEDIA_INLINE_OK type=%s size=%d\n" % (media_type, len(media_bytes))) except Exception: pass return True, ret, {} try: with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " MEDIA_INLINE_FAIL ret=%s → fallback text\n" % (ret,)) except Exception: pass except Exception as _e: try: with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " MEDIA_INLINE_EXC %s → fallback text\n" % (_e,)) except Exception: pass # 降级:纯文本链接 if media_type == "image": caption = "🖼️ 图片已生成,点击查看:" + relay_url else: caption = "🎬 视频已生成,点击查看:" + relay_url return _wechat_send_message(baseurl, bot_token, to_user_id, context_token, caption) def _wechat_extract_text(msg: dict) -> str: parts = [] for it in (msg.get("item_list") or []): _t = it.get("type") if _t == 1 and it.get("text_item", {}).get("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: for k in ("text", "content", "message", "msg"): v = msg.get(k) if isinstance(v, str) and v.strip(): parts.append(v) return "\n".join(parts) def _extract_and_save_inbound_media(msg: dict) -> str: """检测入站媒体消息(图片/语音/文件/视频),下载解密保存,返回给 LLM 的提示文本。 无媒体或解密失败返回空字符串。图片已保存到本地,供后续 vision 模型接入。 """ try: from app.api.im_ilink_media import extract_inbound_media import os as _os for it in (msg.get("item_list") or []): if it.get("type") in (2, 3, 4, 5): kind, data = extract_inbound_media(it) if kind and data: _dir = "/tmp/wechat_inbound_media" _os.makedirs(_dir, exist_ok=True) _ext = {"image": "jpg", "voice": "silk", "file": "bin", "video": "mp4"}.get(kind, "bin") _path = _os.path.join(_dir, "%d_%s.%s" % (int(time.time() * 1000), kind, _ext)) with open(_path, "wb") as _f: _f.write(data) _label = {"image": "图片", "voice": "语音", "file": "文件", "video": "视频"}.get(kind, kind) try: with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " %s saved=%s size=%d\n" % (kind, _path, len(data))) except Exception: pass return "[用户发送了%s,已保存到 %s(%d 字节)]" % (_label, _path, len(data)) return "" except Exception as _e: try: with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " MEDIA_EXTRACT_ERR %s\n" % (_e,)) except Exception: pass return "" def _wechat_sender_id(m: dict) -> str: """从入站消息中提取发送者 id(兼容多套字段名)。""" for k in ("from_user_id", "from_user_name", "from_username", "user_id", "sender_id", "wxid", "from_wxid", "username"): v = m.get(k) if v: return str(v) return "" def _wechat_context_token(m: dict) -> str: """提取 context_token(回复必须回带,否则不关联到对话窗口)。""" for k in ("context_token", "ctx_token", "conversation_id", "conv_id", "msg_id", "context_id", "session_id"): v = m.get(k) if v: return str(v) return "" def _log_chat(user_id, platform, from_user, context_token, role, content): """持久化 IM Bot 对话历史,便于平台内查阅。""" db = None try: db = SessionLocal() _execute_raw(db, text( "INSERT INTO im_bot_chat_log " "(user_id, platform, from_user, context_token, role, content, created_at) " "VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)" ), { "uid": user_id, "platform": platform, "fu": from_user, "ctx": context_token, "role": role, "content": content, "created": _beijing_now(), }) except Exception: pass finally: if db: db.close() # ============================================================ # 镜像到 AI 聊天(标准 conversations/messages) # 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中, # 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。 # ============================================================ WECHAT_CONV_TITLE = "WeXin Bot" def _get_or_create_wechat_conv(db: Session, user_id: str) -> str: """按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。""" rows = _execute_raw(db, text( "SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' " "AND status<>'archived' ORDER BY updated_at DESC LIMIT 1" ), {"uid": user_id}) if rows: return _row_to_dict(rows[0])["id"] cid = str(_uuid.uuid4()) now = _beijing_now() _execute_raw(db, text( "INSERT INTO conversations " "(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) " "VALUES (:id, :uid, :title, 'active', 0, 0, 'wechat_bot', :c, :u)" ), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "c": now, "u": now}) return cid def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None): _execute_raw(db, text( "INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) " "VALUES (:id, :cid, :role, :content, 0, :c)" ), { "id": str(_uuid.uuid4()), "cid": conv_id, "role": role, "content": content, "c": created or _beijing_now(), }) def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str): """把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。""" if not user_id: return db = None try: db = SessionLocal() cid = _get_or_create_wechat_conv(db, user_id) now = _beijing_now() _append_ai_message(db, cid, "user", user_content, created=now) # assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序 _append_ai_message(db, cid, "assistant", assistant_content, created=now + timedelta(milliseconds=50)) _execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"), {"u": _beijing_now(), "id": cid}) except Exception: pass finally: if db: db.close() # ============================================================ # 后台接收线程(每用户独立) # ============================================================ def _mark_wechat_expired(account_id: int): """发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。""" db = None try: db = SessionLocal() _execute_raw(db, text( "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " "updated_at=:ts WHERE id=:aid" ), {"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: pass finally: if db: db.close() def _wechat_receiver_loop(account_id: int): """长轮询个人微信消息 → 七虎AI 回复 → 发送。""" cursor = "" while True: db = SessionLocal() try: row = _execute_raw( db, text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"), {"aid": account_id}, ) if not row: return d = _row_to_dict(row[0]) if d.get("status") != "connected" or not d.get("bot_token"): return bot_token = d["bot_token"] baseurl = d.get("baseurl") or ILINK_BASE msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) owner_uid = d.get("user_id") for m in msgs: if m.get("message_type") == 2: continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 content = _wechat_extract_text(m) if not content: # 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知 content = _extract_and_save_inbound_media(m) if not content: continue sender = _wechat_sender_id(m) ctx = _wechat_context_token(m) # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 try: with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " sender=" + repr(sender) + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n") except Exception: 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 _lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})') # 验证使用的 _call_real_llm 版本 from app.api.im_llm import _call_real_llm as _crl _src = _insp.getsource(_crl) _lrx.warning(f'[receiver] _call_real_llm source len={len(_src)}, has _try_models={"_try_models" in _src}') reply = _qihua_reply(content, owner_uid) _lrx.warning(f'[receiver] reply ({type(reply).__name__}): {repr(reply)[:200]}') # 媒体结构化返回:_call_real_llm 生成图片/视频时返回 # {"__media__": True, "media_type": "image"/"video", "url": "..."} if isinstance(reply, dict) and reply.get("__media__"): media_type = reply.get("media_type") or "image" media_url = reply.get("url") or "" ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url) # 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示) relay_url = _platform_relay_url(media_url) reply_for_log = ( f'生成图片' if media_type == "image" else f'' ) else: reply_text = reply if isinstance(reply, str) else str(reply) ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text) reply_for_log = reply_text if ok: # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) if owner_uid: _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply_for_log) # 同步镜像到 AI 聊天的 WeXin Bot 会话窗 _mirror_to_ai_chat(owner_uid, content, reply_for_log) else: try: with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" % (ret, repr(sender))) except Exception: pass if ret == -14: # -14=会话过期,需重扫 _mark_wechat_expired(d["id"]) elif ret == -2: # -2=参数错误(如媒体消息格式不被 iLink 接受),并非 token 失效。 # 标记 expired 会导致连接假性断开,后续消息全部丢失,故仅记录。 pass time.sleep(1) except Exception: # 2026-07-31 修复:此前裸 except 静默吞掉所有异常,导致接收器出错时毫无日志、 # 完全无法排查。改为记录异常堆栈到磁盘,便于定位。 import traceback as _tb try: with open("/tmp/wechat_receiver_err.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " RECEIVER EXCEPTION:\n" + _tb.format_exc() + "\n") except Exception: pass time.sleep(5) finally: db.close() def _start_wechat_receiver(account_id: int): if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): return t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) t.start() _wechat_receiver_threads[account_id] = t # ============================================================ # 前台用户接口(按 user_id 隔离) # ============================================================ @router.get("/status") async def wechat_status(user: User = Depends(get_current_user)): """当前登录用户的个人微信连接状态""" db = SessionLocal() try: row = _wechat_row_for_user(db, user.id) if not row: return {"success": True, "data": {"connected": False, "configured": False}} d = _row_to_dict(row) return {"success": True, "data": { "connected": d.get("status") == "connected", "status": d.get("status"), "configured": True, "baseurl": d.get("baseurl"), "ilink_bot_id": d.get("ilink_bot_id"), "username": d.get("username"), "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.post("/qrcode") async def wechat_qrcode(user: User = Depends(get_current_user)): """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" try: resp = requests.get( ILINK_BASE + "/ilink/bot/get_bot_qrcode", params={"bot_type": 3}, timeout=20, ) if not resp.ok: return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} j = resp.json() if resp.content else {} qr_url = j.get("qrcode_img_content") or "" qr_img = None try: import io as _io import qrcode as _qrcode _buf = _io.BytesIO() _q = _qrcode.QRCode(box_size=10, border=2) _q.add_data(qr_url) _q.make(fit=True) _img = _q.make_image(fill_color="black", back_color="white") _img.save(_buf, format="PNG") qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() except Exception: qr_img = None return {"success": True, "data": { "qrcode": j.get("qrcode"), "qrcode_img_content": qr_url, "qrcode_img": qr_img, }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/qrcode/status") async def wechat_qrcode_status( qrcode: str = Query(...), user: User = Depends(get_current_user), ): """轮询扫码状态;confirmed 时保存该用户的 bot_token 并启动接收线程""" try: resp = requests.get( ILINK_BASE + "/ilink/bot/get_qrcode_status", params={"qrcode": qrcode}, timeout=40, ) if not resp.ok: return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} j = resp.json() if resp.content else {} data = j.get("data", j) if isinstance(j, dict) else j status = data.get("status") if status == "confirmed": # 07-30 diagnostic: log full iLink confirm response import json as _json try: with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " CONFIRM_RAW=" + _json.dumps(j, ensure_ascii=False)[:2000] + "\n") except Exception: pass creds = data.get("credentials", {}) or {} bot_token = data.get("bot_token") or creds.get("bot_token") baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") db = SessionLocal() try: row = _wechat_row_for_user(db, user.id) if not row: _execute_raw(db, text("""INSERT INTO im_bot_wechat_account (user_id, username, bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at) VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""), { "user_id": user.id, "username": user.username, "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, # 统一按北京时间存储,避免容器 UTC 导致显示偏差 "connected_at": _beijing_now(), "created_at": _beijing_now(), "updated_at": _beijing_now(), }) else: _execute_raw(db, text("""UPDATE im_bot_wechat_account SET user_id=:user_id, username=:username, bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""), { "user_id": user.id, "username": user.username, "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, "connected_at": _beijing_now(), "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"], }) row = _wechat_row_for_user(db, user.id) saved = _row_to_dict(row) if row else {} import json as _json2 try: with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f: _f.write(_beijing_now().isoformat() + " SAVED bot_token=" + repr(saved.get("bot_token", "NULL")[:20]) + " ilink_bot_id=" + repr(saved.get("ilink_bot_id")) + "\n") except Exception: pass aid = saved.get("id", 1) _start_wechat_receiver(aid) finally: db.close() return {"success": True, "data": {"status": "confirmed", "connected": True}} return {"success": True, "data": {"status": status, "connected": False}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/disconnect") async def wechat_disconnect(user: User = Depends(get_current_user)): """断开当前用户的个人微信连接(清除 bot_token)""" db = SessionLocal() try: row = _wechat_row_for_user(db, user.id) if row: _execute_raw(db, text( "UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', " "updated_at=:updated_at WHERE id=:id" ), {"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]}) return {"success": True, "message": "已断开个人微信连接"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() # ============================================================ # 启动恢复:为所有已连接用户账号拉起接收线程 # ============================================================ @router.get("/history") async def wechat_history( user: User = Depends(get_current_user), limit: int = Query(200), ): """查询当前用户通过个人微信与七虎AI 的对话历史""" db = SessionLocal() try: rows = _execute_raw( db, text("SELECT id, role, content, platform, from_user, created_at " "FROM im_bot_chat_log WHERE user_id=:uid ORDER BY id DESC LIMIT :lim"), {"uid": user.id, "lim": min(int(limit), 500)}, ) items = [_row_to_dict(r) for r in rows] items.reverse() return {"success": True, "data": {"messages": items, "total": len(items)}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() def start_all_wechat_receivers(): """在应用启动时调用,恢复此前已连接用户的个人微信接收线程。""" db = SessionLocal() try: rows = _execute_raw( db, text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"), ) for r in rows: _start_wechat_receiver(r._mapping["id"]) if rows: import logging logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程") except Exception as e: import logging logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}") finally: db.close()