""" 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 import uuid 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": { "from_user_id": "", "to_user_id": to_user_id, "client_id": uuid.uuid4().hex, "message_type": 2, "message_state": 2, "context_token": context_token, "item_list": item_list, "run_id": uuid.uuid4().hex, } } 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()