fix(wechat-bot): ①对话时间统一北京时间 ②媒体内联发送(微信直看图片/视频) ③媒体生成计入Token驾驶舱 ④清空技能市场

This commit is contained in:
QihuAI Design committed 2026-07-15 23:37:30 +08:00
1 parent 7739f17594
commit da89a27bca
3 files changed
+166 -42

No files matched your search

+74 -23
View File
@@ -17,7 +17,7 @@ import json
import random
import threading
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
import requests
@@ -31,6 +31,15 @@ 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)
# ============================================================
@@ -136,7 +145,7 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, "个人微信")
return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
@@ -164,9 +173,9 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
return [], cursor
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
"""发送文本回复(必须带 context_token 关联到正确的对话窗口)。
返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。"""
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 失效。"""
import uuid as _uuid
payload = {
"msg": {
@@ -176,7 +185,7 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text}}],
"item_list": item_list,
},
"base_info": {"channel_version": "1.0.2"},
}
@@ -197,7 +206,7 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
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(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n"
_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
@@ -210,6 +219,31 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
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):
"""发送纯文本回复。"""
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[{"type": 1, "text_item": {"text": text}}])
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str):
"""发送媒体(图片/视频)并附带文本链接兜底,确保用户可在微信内直接查看。
iLink 消息 item_list:
- type=2 图片:image_item: {url}
- type=5 视频:video_item: {url}
同时附带一条文本项(含直链),防止微信/iLink 不渲染外链时用户仍能复制查看。
"""
if media_type == "image":
media_item = {"type": 2, "image_item": {"url": url}}
caption = "🖼️ 图片已生成(若未直接显示,请点链接查看):" + url
else:
media_item = {"type": 5, "video_item": {"url": url}}
caption = "🎬 视频已生成(若未直接播放,请点链接查看):" + url
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[media_item, {"type": 1, "text_item": {"text": caption}}])
def _wechat_extract_text(msg: dict) -> str:
parts = []
for it in (msg.get("item_list") or []):
@@ -255,7 +289,7 @@ def _log_chat(user_id, platform, from_user, context_token, role, content):
), {
"uid": user_id, "platform": platform, "fu": from_user,
"ctx": context_token, "role": role, "content": content,
"created": datetime.now(),
"created": _beijing_now(),
})
except Exception:
pass
@@ -282,7 +316,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
now = datetime.now()
now = _beijing_now()
_execute_raw(db, text(
"INSERT INTO conversations "
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
@@ -298,7 +332,7 @@ def _append_ai_message(db: Session, conv_id: str, role: str, content: str, creat
"VALUES (:id, :cid, :role, :content, 0, :c)"
), {
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
"content": content, "c": created or datetime.now(),
"content": content, "c": created or _beijing_now(),
})
@@ -310,13 +344,13 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
try:
db = SessionLocal()
cid = _get_or_create_wechat_conv(db, user_id)
now = datetime.now()
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": datetime.now(), "id": cid})
{"u": _beijing_now(), "id": cid})
except Exception:
pass
finally:
@@ -335,7 +369,7 @@ def _mark_wechat_expired(account_id: int):
_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": datetime.now()})
), {"aid": account_id, "ts": _beijing_now()})
except Exception:
pass
finally:
@@ -374,24 +408,39 @@ def _wechat_receiver_loop(account_id: int):
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
try:
with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f:
_f.write(datetime.now().isoformat() + " sender=" + repr(sender)
_f.write(_beijing_now().isoformat() + " sender=" + repr(sender)
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
except Exception:
pass
# 先生成回复(此时历史尚未包含本条,避免重复)
reply = _qihua_reply(content, owner_uid)
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply)
# 媒体结构化返回:_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 窗与历史页均可显示)
reply_for_log = (
f'<img src="{media_url}" alt="生成图片"/>'
if media_type == "image"
else f'<video src="{media_url}" controls></video>'
)
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)
_log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply_for_log)
# 同步镜像到 AI 聊天的 WeXin Bot 会话窗
_mirror_to_ai_chat(owner_uid, content, reply)
_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(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n"
_f.write(_beijing_now().isoformat() + " SEND_FAIL ret=%s sender=%s\n"
% (ret, repr(sender)))
except Exception:
pass
@@ -511,8 +560,9 @@ async def wechat_qrcode_status(
"user_id": user.id, "username": user.username,
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
"baseurl": baseurl,
"connected_at": datetime.now(),
"created_at": datetime.now(), "updated_at": datetime.now(),
# 统一按北京时间存储,避免容器 UTC 导致显示偏差
"connected_at": _beijing_now(),
"created_at": _beijing_now(), "updated_at": _beijing_now(),
})
else:
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
@@ -523,8 +573,9 @@ async def wechat_qrcode_status(
{
"user_id": user.id, "username": user.username,
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
"baseurl": baseurl, "connected_at": datetime.now(),
"updated_at": datetime.now(), "id": _row_to_dict(row)["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)
aid = _row_to_dict(row)["id"] if row else 1
@@ -547,7 +598,7 @@ async def wechat_disconnect(user: User = Depends(get_current_user)):
_execute_raw(db, text(
"UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', "
"updated_at=:updated_at WHERE id=:id"
), {"updated_at": datetime.now(), "id": _row_to_dict(row)["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))