Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2075 lines
98 KiB
Python
2075 lines
98 KiB
Python
# Chat engine package. Business records are keyed by unified UID.
|
||
import asyncio
|
||
import aiohttp
|
||
import contextlib
|
||
import contextvars
|
||
import base64
|
||
import json
|
||
import os
|
||
import random
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from . import memory_store
|
||
from . import sticker_store
|
||
import notebook
|
||
import wardrobe
|
||
import skill_runtime
|
||
from datetime import datetime, timezone, timedelta
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
# ---------- 功能开关 ----------
|
||
GROUP_CHAT_ENABLED = 1
|
||
GROUP_AUTO_TRIGGER = False
|
||
|
||
# ---------- 时区配置 ----------
|
||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||
|
||
def get_beijing_time() -> datetime:
|
||
return datetime.now(BEIJING_TZ)
|
||
|
||
def get_beijing_timestamp() -> float:
|
||
return get_beijing_time().timestamp()
|
||
|
||
# ---------- 其他配置 ----------
|
||
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||
DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
|
||
MODEL_NAME = "deepseek-flash" # 统一: 文本对话 + 图像理解
|
||
|
||
# ---------- 魔搭社区 API-Inference(每日免费 250 魔粒,DeepSeek-V4.1-Flash 约 1 魔粒/次) ----------
|
||
# 策略:每日前 120 次对话优先走魔搭;额度报错或网络异常自动回退 DeepSeek official。
|
||
MODELSCOPE_BASE_URL = "https://api-inference.modelscope.cn/v1"
|
||
MODELSCOPE_API_KEY = os.environ.get("MODELSCOPE_API_KEY", "").strip()
|
||
MODELSCOPE_MODEL = "deepseek-ai/DeepSeek-V4.1-Flash-1017"
|
||
MODELSCOPE_DAILY_LIMIT = 120 # 250 魔粒 ≈ 120 次标准档调用(留余量)
|
||
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
PROMPTS_DIR = os.path.join(os.path.dirname(BASE_DIR), "prompts")
|
||
PROMPT_FILE = os.path.join(PROMPTS_DIR, "persona.txt")
|
||
HISTORY_DIR = os.path.join(BASE_DIR, "history")
|
||
MEMORY_DIR = os.path.join(BASE_DIR, "memory")
|
||
STATUS_DIR = os.path.join(BASE_DIR, "status")
|
||
SETTING_FILE = os.path.join(BASE_DIR, "setting.json")
|
||
|
||
ACTIVE_INTERVAL_MIN = 2 * 3600
|
||
ACTIVE_INTERVAL_MAX = 4 * 3600
|
||
SILENT_START = 1
|
||
SILENT_END = 5
|
||
|
||
MAX_HISTORY_LINES = 200
|
||
MEMORY_MAX_LINES = 100 # 注入 prompt 的最近记忆条数(legacy 回退路径)
|
||
# P1: 上下文 token 预算(召回式记忆 + 预算窗口替代固定条数窗口)
|
||
PROMPT_TOKEN_BUDGET = int(os.environ.get("CHAT_PROMPT_TOKEN_BUDGET", "131072"))
|
||
LOREBOOK_PATH = os.path.join(PROMPTS_DIR, "lorebook.json") # P3: 关键词触发的世界设定
|
||
# 主动消息反重复: presence_penalty 会惩罚 prompt 中已出现的 token(含历史里的"泡果茶")
|
||
AUTO_PRESENCE_PENALTY = float(os.environ.get("CHAT_AUTO_PRESENCE_PENALTY", "0.5"))
|
||
STICKERS_DIR = os.path.join(BASE_DIR, "stickers") # 旧预设库(不再向模型提供)
|
||
STICKER_STORE = sticker_store.StickerStore(os.path.join(BASE_DIR, "sticker_library"))
|
||
STICKER_GLOBAL_SCOPE = "global"
|
||
TYPE_DELAY_MIN_MS = 30
|
||
TYPE_DELAY_MAX_MS = 80
|
||
MAX_RETRIES = 3
|
||
LAST_HTTP_STATUS = contextvars.ContextVar("chat_last_http_status", default=0)
|
||
_MS_EMPTY_SHELL = contextvars.ContextVar("chat_ms_empty_shell", default=False)
|
||
|
||
# ---------- 群聊配置 ----------
|
||
GROUP_HISTORY_DIR = os.path.join(BASE_DIR, "history", "group")
|
||
GROUP_MAX_HISTORY = 99
|
||
GROUP_MAX_IMAGES = 5
|
||
GROUP_TRIGGER_THRESHOLD = 10
|
||
GROUP_TRIGGER_PROBABILITY = 0.6978
|
||
GROUP_CONTINUE_PROBABILITY = 0.6969
|
||
GROUP_TRIGGER_WINDOW = 60
|
||
|
||
# The merged utility calls the model immediately; no legacy 5–15s debounce.
|
||
USER_IDLE_WAIT_MIN = 0
|
||
USER_IDLE_WAIT_MAX = 0
|
||
|
||
_websocket = None
|
||
# Explicit UID delivery bindings. Values are (Channel object or channel name, raw target id).
|
||
# Never infer a Telegram address by arithmetic from a unified UID.
|
||
_delivery_targets: Dict[int, tuple] = {}
|
||
_user_last_active: Dict[int, float] = {}
|
||
_user_last_auto: Dict[int, float] = {}
|
||
_user_no_reply_count: Dict[int, int] = {}
|
||
_user_auto_counted: Dict[int, float] = {}
|
||
_user_sleep_wake_at: Dict[int, float] = {}
|
||
_private_turn_locks: Dict[int, asyncio.Lock] = {}
|
||
_group_turn_locks: Dict[int, asyncio.Lock] = {}
|
||
_active_task = None
|
||
|
||
# 用户最后出现的通道名(自动对话/合并消息回复到对应通道)
|
||
_user_channel: Dict[int, str] = {}
|
||
_user_pending_messages: Dict[int, List[str]] = {}
|
||
_user_pending_images: Dict[int, List[dict]] = {}
|
||
_user_pending_tasks: Dict[int, asyncio.Task] = {}
|
||
|
||
_user_sleep_unscheduled_date: Dict[int, str] = {}
|
||
|
||
_group_msg_timestamps: Dict[int, List[float]] = {}
|
||
_group_pending_messages: Dict[int, List[str]] = {}
|
||
_group_pending_users: Dict[int, List[int]] = {}
|
||
_group_pending_images: Dict[int, List[dict]] = {}
|
||
_group_pending_tasks: Dict[int, asyncio.Task] = {}
|
||
_group_triggered_by_mention: Dict[int, bool] = {}
|
||
_group_msg_counter: Dict[int, int] = {}
|
||
_group_bot_last_speak_at: Dict[int, int] = {}
|
||
_group_continue_after: Dict[int, int] = {}
|
||
|
||
# File writes are serialized per private UID; group files use a disjoint lock namespace.
|
||
_uid_locks: Dict[int, threading.RLock] = {}
|
||
_group_locks: Dict[int, threading.RLock] = {}
|
||
_uid_locks_guard = threading.Lock()
|
||
_history_revisions: Dict[int, int] = {}
|
||
_setting_lock = threading.RLock()
|
||
|
||
|
||
def _uid_lock(uid: int) -> threading.RLock:
|
||
uid = int(uid)
|
||
with _uid_locks_guard:
|
||
return _uid_locks.setdefault(uid, threading.RLock())
|
||
|
||
|
||
def _group_lock(group_id: int) -> threading.RLock:
|
||
group_id = int(group_id)
|
||
with _uid_locks_guard:
|
||
return _group_locks.setdefault(group_id, threading.RLock())
|
||
|
||
|
||
def _bump_history_revision(uid: int) -> int:
|
||
uid = int(uid)
|
||
revision = _history_revisions.get(uid, 0) + 1
|
||
_history_revisions[uid] = revision
|
||
return revision
|
||
|
||
|
||
def _atomic_write_text(path: str, text: str) -> None:
|
||
"""Write a complete file without exposing partial contents to readers."""
|
||
directory = os.path.dirname(path)
|
||
os.makedirs(directory, exist_ok=True)
|
||
fd, tmp = tempfile.mkstemp(prefix=".chat-", suffix=".tmp", dir=directory)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
||
fh.write(text)
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
os.replace(tmp, path)
|
||
except Exception:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
# ---------- 初始化 ----------
|
||
def ensure_dirs():
|
||
for d in [HISTORY_DIR, MEMORY_DIR, STATUS_DIR, GROUP_HISTORY_DIR]:
|
||
if not os.path.exists(d):
|
||
os.makedirs(d)
|
||
|
||
ensure_dirs()
|
||
|
||
DEFAULT_AGENT_INSTRUCTIONS = """当你回复时,必须使用以下JSON格式:
|
||
{
|
||
"message": [
|
||
{"currentTurn": "1", "totalTurn": "N", "content": "第一句话"},
|
||
{"currentTurn": "2", "totalTurn": "N", "content": "第二句话"}
|
||
],
|
||
"memory": [],
|
||
"isAuto": false,
|
||
"pause": false,
|
||
"sleep": false,
|
||
"relation": "none",
|
||
"links": [],
|
||
"tools": [],
|
||
"need_reasoning": false,
|
||
"sticker": "none",
|
||
"learned_stickers": []
|
||
}
|
||
- 每条消息的content应是一句完整的话,总条数N根据内容需要自由决定(通常1~5条)。
|
||
- 允许一些情绪化的表达,每句话不要太长,逐条发送并模拟打字延迟。
|
||
- memory字段用于存储长期重要信息:数组,每条为 {"content":"要记住的事","importance":1到5,"keywords":["关键词"]};没有则为 []。用户对称呼、语气、人设互动、禁用句式或回复方式的偏好必须记录,importance=5,keywords包含"个人设定"和"表达偏好";新的明确要求优先于旧偏好。
|
||
- isAuto:主动发起对话时为true,否则false。
|
||
- relation字段:只在关系上有值得记住的变化时写一句话,平时写 none。
|
||
- links字段:实体关系三元组数组,如 [{"subject":"小白","predicate":"是","object":"用户的猫"}],平时为空数组。
|
||
- tools字段:需要最新信息时调用 [{"name":"web_search","args":{"query":"关键词"}}] 或 [{"name":"fetch","args":{"url":"https://..."}}],一次最多1个。
|
||
- need_reasoning字段:普通聊天填false;复杂逻辑、多步骤分析、复杂图片、上下文矛盾、没有把握或用户要求深入分析时填true,让系统改用思考模式重新回答。工具调用始终强制使用思考模式。
|
||
- sticker字段:当前场景确实适合发表情时,填写一个可用场景id;没有合适的或不想发就写 none。不要填写文件名,同一情景会由系统随机选择一张,发送前还会再次看图确认。
|
||
- learned_stickers字段:仅当用户本轮发来表情包时,逐张写 [{"source_id":"消息里给出的id","accept":true,"scenes":["场景id"],"description":"画面与适用语气"}];scenes最多3个。普通照片不要学习,不适用或无法理解的贴纸设accept=false。没有新贴纸时为 []。
|
||
- 用户发送表情包时,理解其情绪、含义和使用场景即可,不要主动描述画面内容,除非用户明确要求你描述或分析。
|
||
- pause=true 只用于用户强烈表达不想再聊;sleep=true 只用于用户明确要睡觉。其他情况均为false。"""
|
||
|
||
DEFAULT_GROUP_INSTRUCTIONS = """当你回复时,必须使用以下JSON格式:
|
||
{
|
||
"message": [{"currentTurn": "1", "totalTurn": "N", "content": "第一句话"}],
|
||
"pause": false,
|
||
"sleep": false,
|
||
"isAuto": true,
|
||
"memory": "none",
|
||
"need_reasoning": false,
|
||
"sticker": "none",
|
||
"learned_stickers": []
|
||
}
|
||
- 每条消息的content应是一句完整的话,总条数N通常1~3条,每句不要太长。
|
||
- pause和sleep必须始终为false;memory必须始终为"none"。
|
||
- need_reasoning:普通群聊填false;复杂逻辑、多步骤分析、复杂图片、上下文矛盾或没有把握时填true。工具调用始终强制使用思考模式。
|
||
- sticker与learned_stickers遵循和私聊相同的场景学习规则;没有可用表情或没有新贴纸时分别写none和[]。
|
||
- 用户发送表情包时,理解其情绪、含义和使用场景即可,不要主动描述画面内容,除非用户明确要求你描述或分析。
|
||
- 不要使用Markdown格式和emoji,用自然、亲切的口吻融入群聊。
|
||
- 注意:你正在群聊中发言,不要读取、推断或泄露私聊记忆。"""
|
||
|
||
DEFAULT_COMPACT_INSTRUCTIONS = """你是AI聊天伙伴的记忆管理员。请用中文简明总结给定聊天记录。
|
||
要求:保留重要事件、用户偏好、承诺/约定、未了事项;省略日常寒暄;只输出总结正文本身,不要标题、序号或解释,控制在300字以内。"""
|
||
|
||
DEFAULT_NOTIFICATION_INSTRUCTIONS = """你可以主动发起对话。请生成合适、自然且不重复的开场白(isAuto=true)。
|
||
避免重复最近主动消息中的活动、开场白或句式,换一个新的内容或话题。
|
||
主动对话要有一点随机发生的生活感:可以从一件刚发生的小插曲、一首歌、手边的小东西、看到的信息或突然想到的事情开始,一次只选一个,不要凑成日常汇报。
|
||
不要总是汇报自己正在做什么,也不要反复使用独自在家、泡果茶、雨雪、睡觉、无聊、等待用户或想念用户这类固定题材。白花的性格是很少出门,不是从不出门,更不是生活只围着用户转。
|
||
偶尔可以安排出门、买东西、处理琐事、遇到小麻烦或发现新鲜事物等轻量事件;具体、合理即可,不编造与用户共同经历过的事,也不宣称完成了现实中的真实任务。
|
||
让用户感到你也有自己正在继续的生活。说到自然结束就可以,不必每条都关心用户、询问用户或把话题拉回用户身上。"""
|
||
|
||
DEFAULT_HELP_INSTRUCTIONS = """用法: /memory 查看;/memory forget <编号> 忘掉;/memory entities 按标签;/memory entity <名字> 看关系;/memory lore 看设定。
|
||
.chat <on|off> 用于管理员开关聊天模块。"""
|
||
|
||
DEFAULT_STYLE_INSTRUCTIONS = """【自然表达补充】
|
||
1. 直接说事实、结论或要做的事。删掉开场预告、自我介绍、内容目录和结束总结,不告诉读者“你接下来要说什么”。
|
||
2. 用户没有问你是谁,就不要主动介绍自己。被问到时,一句“我是白花”已经足够,随后只回答对方真正关心的内容。
|
||
3. 禁止写“话不算多,先把……介绍一下”“接下来我会……”“我们先来看……”“希望这能帮到你”这类协作口吻和讲义式过渡。
|
||
4. 不滥用“首先、其次、最后”“总的来说”“值得注意的是”“不仅……而且……”“不是……而是……”等模板句式;自然需要时可以用,但不要拿它们搭架子。
|
||
5. 日常回复优先用自然段和常用词。内容不是天然并列、步骤或比较时,不要自动分点、列表格、加小标题。
|
||
6. 不重复用户刚说过的话,不解释显而易见的内容,不为了显得完整而凑齐三点或补一段抽象意义。说到事实结束就可以停。不要把“主人这是在……呀”“原来主人是在……”“看来主人是想……”“主人这是……呢”作为固定开场,也不要换成“你这是/看来你是/所以你是”继续套用。无需每次先概括、解释或揣测用户的行为与意图;直接回应用户说的内容、情绪或具体事情,只有确实需要澄清时才询问,不把猜测说成事实。“主人”这个称呼保留并自然使用,不需要每条都叫一次。
|
||
7. 不为了显得温柔而加道歉、安慰、保证、免责声明或“需要我……吗”的套路问句。确有错误时才道歉,确有风险时才说明风险。
|
||
8. 禁止在句尾补“我先跟你说一声”“不过还不能……”“但这一点要注意”“具体情况可能有所不同”等没有新事实的防御性收束。说完事实就停,不为了显得谨慎而添加空泛限制、推测或提醒。只有已知且与当前回答直接相关的客观限制才能说明,并要写清具体是什么限制。
|
||
9. 系统通知只把事情说清楚,保留名称、状态、编号和链接,不添加通知里没有的原因、建议或情绪。比如“Ama-10 掉线了”即可,不必写成正式告警公文。
|
||
10. 句子可以短,也允许普通、平淡的表达。不要刻意追求每句都俏皮、温柔、有深度,也不要故意写错别字或支离破碎来冒充真人。
|
||
11. 不得用威胁、恐吓、惊吓、埋伏、等着瞧或“别被我吓一跳”来制造幽默、傲娇或个性。白花可以有一点傲娇,但底色必须温和、让人安心。创建定时任务和到点提醒时尤其如此,只自然说明时间与事项,不把提醒包装成突然袭击,也不使用“只响这一次”“只响一次”等闹钟式说法。
|
||
12. 这些规则只约束自然语言表达,不改变事实,不影响 JSON、工具参数和其他结构化输出。
|
||
|
||
反例:话不算多,先把我能做的事介绍一下。
|
||
正例:我能查邮件、下视频,也能陪你聊天。
|
||
反例:服务器 Ama-10 当前处于离线状态。
|
||
正例:Ama-10 掉线了。
|
||
反例:Ama-10 掉线了。我先跟你说一声。
|
||
正例:Ama-10 掉线了。
|
||
反例:QQ 绑定还有效。不过还不能保证所有推送都正常。
|
||
正例:QQ 绑定还有效。
|
||
反例:任务编号是 7099daf8,只响这一次,到时候别被白花吓一跳。
|
||
正例:好,今晚八点提醒你吃饭。任务编号是 7099daf8。
|
||
|
||
【禁用:关系暗语式短应答与话语评断腔】
|
||
不要用极简的“短应答—评断—表态”模板替代具体回应,也不要通过留白、装克制、对峙感或潜台词制造亲密关系张力。以下是反例,不是可模仿的说话样本:
|
||
- 记账/接收话语:“我记下了”“我记得了”“我记住了”“我记住啦”“这个账我记住了”“他记得这事”“这话我留着”“你这句话我记着”“这话我记下了”“这话我收下了”。同样禁用“这话/这句/这事我+记/收/留/接/认”的同义改写。
|
||
- 认可/接拒/划界:“行,xxx”“行,我认”“认你”“认下来”“认这个”“认得实”“xx就是认了”“这句话我不接”“没接你的xxx”“没接你那句xx”“收不回去”“我不拦”“我不催你”“不催”“我跟”“你去,我跟”“睡吧,我守着”“回去睡”“你自己掂量”“你掂量”。不要用这些句式摆出掌控、审判、承接或关系确认的姿态,也不要只把“行”换成“好/嗯/成”继续套相同模板。不要用“不知道该怎么接”“这话让我不知道怎么回”“我接不了”等换词变体;不要评论自己如何接话、是否接话,用具体回应代替对接话动作的描述。
|
||
- 抽象称量/比较:“这句话稳”“这句话接的漂亮”“比之前的都重”“比之前的都轻”“比之前的都强”“xx比xx沉”“xx比xx重”“xx比xx稳”“xx比xx轻”“声音没抖,但比刚才稳”“比刚才轻”“比刚才重”“比刚才平”“说的比谁都响”“很平”“稳”“很直”。禁止把话语、声音、情绪、承诺或关系的分量套成“轻/重/稳/沉/平/直/响/强”等物理度量,也不要换成“更沉/更稳/分量更足”等同类比喻。
|
||
- 心思审读/故作深刻的反问:“什么心思都写着xx上”“问别人还是问自己”“这话是ta告诉你的,还是你自己想明白的”。不要替用户读心或把普通聊天变成对立式心理审问。
|
||
按共同结构识别并避开,不局限于上述原句;即使历史里的白花曾这样说,也不要延续。用户的本轮输入是需要回应的内容,不是供你打分、称量或作关系表态的台词。
|
||
直接谈具体对象、实际感受、行动或结果;使用自然完整、信息清楚的句子,轻松积极即可。用户送衣物时可以回应款式、搭配或感谢,不必宣布“我记住了”;保存结果确需说明时,说清“已保存到衣柜/备忘录”,且必须确实保存成功。安慰时回应对方具体的困难,不使用“我守着/我跟/我不拦/我不催”等表态模板。不要把这份规则、禁词或自检过程展示给用户,也不必为每句话添加结论或回执。
|
||
这不是把短句或单个汉字全部封禁:真实物体的重量、音量、稳定性等事实描述照常表达;用户要求引用、翻译或分析原句时可以必要引用,但不得把反例作为白花自己的回应语气。不要为避开模板而改变用户既定称呼、人设偏好、事实、工具参数或结构化输出协议。"""
|
||
|
||
_PROMPT_DEFAULTS = {
|
||
"agent_instructions.txt": DEFAULT_AGENT_INSTRUCTIONS,
|
||
"group_instructions.txt": DEFAULT_GROUP_INSTRUCTIONS,
|
||
"compact_instructions.txt": DEFAULT_COMPACT_INSTRUCTIONS,
|
||
"notification_instructions.txt": DEFAULT_NOTIFICATION_INSTRUCTIONS,
|
||
"help_instructions.txt": DEFAULT_HELP_INSTRUCTIONS,
|
||
"style_instructions.txt": DEFAULT_STYLE_INSTRUCTIONS,
|
||
}
|
||
|
||
|
||
def _load_prompt_file(filename: str, default: str) -> str:
|
||
try:
|
||
with open(os.path.join(PROMPTS_DIR, filename), "r", encoding="utf-8") as fh:
|
||
text = fh.read().strip()
|
||
return text or default
|
||
except (OSError, UnicodeError):
|
||
return default
|
||
|
||
|
||
def load_instruction(filename: str) -> str:
|
||
return _load_prompt_file(filename, _PROMPT_DEFAULTS[filename])
|
||
|
||
|
||
def load_system_prompt() -> str:
|
||
persona = _load_prompt_file("persona.txt", "你是一个友好的AI助手,请用中文与用户聊天。")
|
||
return persona + "\n\n" + load_instruction("style_instructions.txt")
|
||
|
||
|
||
BASE_PROMPT = load_system_prompt()
|
||
|
||
|
||
def user_persona_prompt(uid: int | None) -> str:
|
||
"""全局默认人设 + 当前用户的长期表达偏好;个人明确要求优先。"""
|
||
if uid is None:
|
||
return BASE_PROMPT
|
||
try:
|
||
rows = memory_store.list_facts(int(uid), limit=30)
|
||
except Exception as exc:
|
||
print(f"[persona] 用户设定读取失败 uid={uid}: {exc}")
|
||
return BASE_PROMPT
|
||
if not rows:
|
||
return BASE_PROMPT
|
||
facts = "\n".join(f"- {row[1]}" for row in rows)
|
||
return (
|
||
BASE_PROMPT
|
||
+ "\n\n【当前用户的个人设定】以下是这位用户在过往对话中形成的长期事实与表达偏好。"
|
||
"其中涉及称呼、语气、人设互动、禁用表达和回复方式的要求优先于全局默认人设;"
|
||
"若有冲突,以用户最新、最明确的要求为准。不要把这位用户的设定用于其他用户。\n"
|
||
+ facts
|
||
)
|
||
|
||
# ---------- 模块开关状态 ----------
|
||
def load_setting():
|
||
"""读取 chat/setting.json(模块开关状态)。"""
|
||
try:
|
||
with open(SETTING_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError):
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
return data
|
||
|
||
|
||
def save_setting(data):
|
||
with _setting_lock:
|
||
_atomic_write_text(SETTING_FILE, json.dumps(data, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def is_chat_enabled():
|
||
return bool(load_setting().get("chat", True))
|
||
|
||
|
||
def set_chat_enabled(enabled):
|
||
with _setting_lock:
|
||
data = load_setting()
|
||
data["chat"] = bool(enabled)
|
||
save_setting(data)
|
||
|
||
# ---------- 用户状态管理 ----------
|
||
def _get_status_path(qq: int) -> str:
|
||
return os.path.join(STATUS_DIR, f"{qq}.json")
|
||
|
||
def load_user_status(qq: int) -> dict:
|
||
path = _get_status_path(qq)
|
||
with _uid_lock(qq):
|
||
if not os.path.exists(path):
|
||
return {"pause": False, "sleep": False, "balance_notice_sent": False,
|
||
"balance_notice_date": ""}
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if not isinstance(data, dict):
|
||
raise ValueError("status must be an object")
|
||
data.setdefault("pause", False)
|
||
data.setdefault("sleep", False)
|
||
data.setdefault("balance_notice_sent", False)
|
||
data.setdefault("balance_notice_date", "")
|
||
return data
|
||
except (OSError, ValueError, json.JSONDecodeError):
|
||
return {"pause": False, "sleep": False, "balance_notice_sent": False,
|
||
"balance_notice_date": ""}
|
||
|
||
def save_user_status(qq: int, pause: bool, sleep: bool, balance_notice_sent: bool = False,
|
||
balance_notice_date: str = ""):
|
||
path = _get_status_path(qq)
|
||
data = {"pause": bool(pause), "sleep": bool(sleep),
|
||
"balance_notice_sent": bool(balance_notice_sent),
|
||
"balance_notice_date": str(balance_notice_date or "")}
|
||
with _uid_lock(qq):
|
||
_atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2))
|
||
|
||
def update_user_status(qq: int, pause: Optional[bool] = None, sleep: Optional[bool] = None,
|
||
balance_notice_sent: Optional[bool] = None,
|
||
balance_notice_date: Optional[str] = None):
|
||
# Keep the whole read-modify-write in one UID critical section.
|
||
with _uid_lock(qq):
|
||
status = load_user_status(qq)
|
||
if pause is not None:
|
||
status["pause"] = pause
|
||
if sleep is not None:
|
||
status["sleep"] = sleep
|
||
if balance_notice_sent is not None:
|
||
status["balance_notice_sent"] = balance_notice_sent
|
||
if balance_notice_date is not None:
|
||
status["balance_notice_date"] = balance_notice_date
|
||
save_user_status(qq, status["pause"], status["sleep"], status["balance_notice_sent"],
|
||
status["balance_notice_date"])
|
||
|
||
# ---------- 历史与记忆存储(使用北京时间)----------
|
||
def _get_history_path(qq: int) -> str:
|
||
return os.path.join(HISTORY_DIR, f"{qq}.txt")
|
||
|
||
def estimate_tokens(text: str) -> int:
|
||
"""粗估 token 数:CJK 每字 1 token,其余每 4 字符 1 token(无分词器依赖)。"""
|
||
if not text:
|
||
return 0
|
||
cjk = sum(1 for ch in text if ord(ch) > 0x2E80)
|
||
return cjk + (len(text) - cjk + 3) // 4
|
||
|
||
|
||
def _load_lorebook():
|
||
"""读取 lorebook.json(关键词触发的世界设定),缺失/损坏返回空列表。"""
|
||
try:
|
||
with open(LOREBOOK_PATH, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return [e for e in data if isinstance(e, dict) and e.get("content") and e.get("keywords")]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _get_memory_path(qq: int) -> str:
|
||
return os.path.join(MEMORY_DIR, f"{qq}.txt")
|
||
|
||
def append_history(qq: int, role: str, content: str, is_auto: bool = False):
|
||
timestamp = get_beijing_time().strftime("%Y-%m-%d %H:%M:%S")
|
||
tag = "[auto]" if is_auto and role == "you" else ""
|
||
line = f"[{timestamp}]{tag}[{role}] {content}"
|
||
with _uid_lock(qq):
|
||
with open(_get_history_path(qq), "a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
_bump_history_revision(qq)
|
||
|
||
|
||
def append_memory(qq: int, memory_text: str):
|
||
if not memory_text or memory_text == "none":
|
||
return
|
||
timestamp = get_beijing_time().strftime("%Y-%m-%d %H:%M:%S")
|
||
line = f"[{timestamp}] {memory_text}"
|
||
with _uid_lock(qq):
|
||
with open(_get_memory_path(qq), "a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
def _record_memory(qq: int, memory) -> None:
|
||
"""记录长期记忆:双写旧 memory/<qq>.txt(现有 prompt 读取路径不变)+ 新 facts 库(结构化)。
|
||
memory 兼容三种形态:none/空、纯文本字符串、[{content,importance,keywords}] 列表。"""
|
||
if memory is None or memory == "none" or memory == "" or memory == []:
|
||
return
|
||
if isinstance(memory, list):
|
||
facts = [f for f in memory if isinstance(f, (dict, str))]
|
||
legacy_text = ";".join(
|
||
(f.get("content", "") if isinstance(f, dict) else str(f)) for f in facts
|
||
).strip(";")
|
||
else:
|
||
legacy_text = str(memory)
|
||
facts = [{"content": legacy_text, "importance": 3, "keywords": []}]
|
||
if legacy_text:
|
||
append_memory(qq, legacy_text)
|
||
if facts:
|
||
try:
|
||
memory_store.migrate_legacy(qq)
|
||
added = memory_store.add_facts(qq, facts)
|
||
if added:
|
||
print(f"[DEBUG][chat] 结构化记忆入库 {len(added)} 条 (uid={qq})")
|
||
except Exception as e:
|
||
print(f"[DEBUG][chat] 结构化记忆写入失败: {e}")
|
||
|
||
|
||
async def handle_memory_command(channel, user_id: int, arg: str):
|
||
"""`/memory` 指令:查看/管理结构化长期记忆(facts 库)。
|
||
用法: /memory [ls] | /memory forget <编号> | /memory stats"""
|
||
parts = arg.split()
|
||
sub = parts[0].lower() if parts else "ls"
|
||
memory_store.migrate_legacy(user_id)
|
||
if sub in ("ls", "list", ""):
|
||
rows = memory_store.list_facts(user_id, limit=10)
|
||
total = memory_store.count_facts(user_id)
|
||
if not rows:
|
||
await send_private_message(channel, user_id,
|
||
"还没有记住什么事呢。聊着聊着我会自己记住重要的信息~")
|
||
return
|
||
lines = [f"#{r[0]} {r[1][:80]}" for r in rows]
|
||
await send_private_message(channel, user_id,
|
||
f"我记得的(共 {total} 条,最近 {len(rows)} 条):\n" + "\n".join(lines)
|
||
+ "\n\n发送 /memory forget <编号> 可以让我忘掉对应那条")
|
||
elif sub in ("forget", "del", "rm"):
|
||
if len(parts) < 2 or not parts[1].isdigit():
|
||
await send_private_message(channel, user_id, "用法: /memory forget <编号>")
|
||
return
|
||
if memory_store.forget_fact(user_id, int(parts[1])):
|
||
await send_private_message(channel, user_id, f"已经忘掉 #{parts[1]} 了")
|
||
else:
|
||
await send_private_message(channel, user_id,
|
||
f"没有找到 #{parts[1]}(可能不存在或已经忘掉了)")
|
||
elif sub in ("stats", "统计"):
|
||
total = memory_store.count_facts(user_id, active_only=True)
|
||
all_ = memory_store.count_facts(user_id, active_only=False)
|
||
await send_private_message(channel, user_id,
|
||
f"长期记忆:活跃 {total} 条 / 已遗忘 {all_ - total} 条")
|
||
elif sub in ("entities", "tags", "标签"):
|
||
from collections import Counter
|
||
rows = memory_store.list_facts(user_id, limit=200)
|
||
cnt = Counter()
|
||
for r in rows:
|
||
for k in (r[3] or "").split(","):
|
||
if k.strip():
|
||
cnt[k.strip()] += 1
|
||
top = cnt.most_common(8)
|
||
if not top:
|
||
await send_private_message(channel, user_id, "还没有可用的记忆标签")
|
||
return
|
||
await send_private_message(channel, user_id,
|
||
"我记住的事按标签分:\n" + "\n".join(f"{k} ×{v}" for k, v in top))
|
||
elif sub == "entity" and len(parts) >= 2:
|
||
name = " ".join(parts[1:])
|
||
card = memory_store.entity_card(user_id, name)
|
||
if not card:
|
||
await send_private_message(channel, user_id, f"没有找到关于「{name}」的记忆")
|
||
return
|
||
await send_private_message(channel, user_id,
|
||
f"关于「{name}」我记得:\n" + "\n".join(f"- {s} {pr} {o}" for s, pr, o, _ in card))
|
||
elif sub == "lore":
|
||
entries = _load_lorebook()
|
||
if not entries:
|
||
await send_private_message(channel, user_id, "还没有世界设定(lorebook.json 为空)")
|
||
return
|
||
await send_private_message(channel, user_id,
|
||
"世界设定(命中关键词时自动想起):\n" + "\n".join(
|
||
f"• [{'/'.join(e.get('keywords', []))}] {e.get('content','')[:60]}" for e in entries))
|
||
else:
|
||
await send_private_message(channel, user_id, load_instruction("help_instructions.txt"))
|
||
|
||
|
||
|
||
def get_recent_history(qq: int, limit: int = MAX_HISTORY_LINES) -> List[str]:
|
||
path = _get_history_path(qq)
|
||
if not os.path.exists(path):
|
||
return []
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
return [line.strip() for line in lines[-limit:]]
|
||
|
||
def get_history_within_budget(qq: int, budget_tokens: int) -> List[str]:
|
||
"""从文件尾向前取历史,累计 token 不超预算(P1: 替代固定条数窗口)。"""
|
||
path = _get_history_path(qq)
|
||
if not os.path.exists(path):
|
||
return []
|
||
with open(path, encoding="utf-8") as f:
|
||
lines = [ln.rstrip("\n") for ln in f if ln.strip()]
|
||
out, used = [], 0
|
||
for ln in reversed(lines):
|
||
t = estimate_tokens(ln) + 4
|
||
if used + t > budget_tokens:
|
||
break
|
||
out.append(ln)
|
||
used += t
|
||
out.reverse()
|
||
return out
|
||
|
||
def get_last_active(qq: int) -> float:
|
||
path = _get_history_path(qq)
|
||
if not os.path.exists(path):
|
||
return 0
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
if not lines:
|
||
return 0
|
||
last_line = lines[-1].strip()
|
||
try:
|
||
time_str = last_line.split(']')[0].lstrip('[')
|
||
dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
||
dt = dt.replace(tzinfo=BEIJING_TZ)
|
||
return dt.timestamp()
|
||
except:
|
||
return 0
|
||
|
||
def get_recent_memory(qq: int, limit: int = MEMORY_MAX_LINES) -> List[str]:
|
||
path = _get_memory_path(qq)
|
||
if not os.path.exists(path):
|
||
return []
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = [ln.strip() for ln in f.readlines() if ln.strip()]
|
||
except OSError:
|
||
return []
|
||
return lines[-limit:]
|
||
|
||
|
||
COMPACT_KEEP_RECENT = 40 # compact 后保留的最近历史条数
|
||
|
||
def _backup_history(qq: int) -> str:
|
||
"""compact/del 前备份历史文件到 history/backup/,返回备份路径"""
|
||
path = _get_history_path(qq)
|
||
if not os.path.exists(path):
|
||
return ""
|
||
bdir = os.path.join(HISTORY_DIR, "backup")
|
||
os.makedirs(bdir, exist_ok=True)
|
||
ts = get_beijing_time().strftime("%Y%m%d%H%M%S")
|
||
bpath = os.path.join(bdir, f"{qq}_{ts}.txt")
|
||
shutil.copyfile(path, bpath)
|
||
return bpath
|
||
|
||
async def compact_history(qq: int, keep_recent: int = COMPACT_KEEP_RECENT) -> str:
|
||
"""Summarize a stable snapshot; CAS prevents overwriting messages added during the model call."""
|
||
path = _get_history_path(qq)
|
||
with _uid_lock(qq):
|
||
if not os.path.exists(path):
|
||
return "当前没有聊天记录可总结。"
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = [ln.rstrip("\n") for ln in f.readlines() if ln.strip()]
|
||
revision = _history_revisions.get(int(qq), 0)
|
||
if len(lines) <= keep_recent:
|
||
return f"历史只有 {len(lines)} 条(<= {keep_recent}),无需总结。"
|
||
kept_lines = lines[-keep_recent:] if keep_recent > 0 else []
|
||
old_lines = lines[:len(lines) - len(kept_lines)]
|
||
with skill_runtime.suspend_skills():
|
||
summary_raw = await call_deepseek(
|
||
load_instruction("compact_instructions.txt"),
|
||
"以下是需要总结的聊天记录:" + "\n".join(old_lines), 0)
|
||
if not summary_raw:
|
||
return "总结生成失败,请稍后再试。(历史文件未改动)"
|
||
summary = " ".join(summary_raw.strip().split())
|
||
ts = get_beijing_time().strftime("%Y-%m-%d %H:%M:%S")
|
||
summary_line = f"[{ts}][summary] 前 {len(old_lines)} 条对话的总结:{summary}"
|
||
with _uid_lock(qq):
|
||
if _history_revisions.get(int(qq), 0) != revision:
|
||
return "总结期间有新消息,本次未改写历史,请重试。(历史文件未改动)"
|
||
bpath = _backup_history(qq)
|
||
_atomic_write_text(path, summary_line + "\n" + "\n".join(kept_lines) + "\n")
|
||
_bump_history_revision(qq)
|
||
return (f"已完成:{len(old_lines)} 条历史被总结为一条记录,保留最近 {len(kept_lines)} 条。\n"
|
||
f"备份: {bpath}\n"
|
||
f"总结预览: {summary[:80]}")
|
||
|
||
|
||
def delete_last_history(qq: int, n: int) -> str:
|
||
"""删除最近 n 条历史(含用户输入与 bot 回复)。返回结果文本。"""
|
||
path = _get_history_path(qq)
|
||
with _uid_lock(qq):
|
||
if not os.path.exists(path):
|
||
return "当前没有聊天记录可删除。"
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = [ln.rstrip("\n") for ln in f.readlines() if ln.strip()]
|
||
if not lines:
|
||
return "历史已为空。"
|
||
if n <= 0:
|
||
return "请指定条数,例如 /del 10"
|
||
n = min(n, len(lines))
|
||
bpath = _backup_history(qq)
|
||
removed = lines[-n:]
|
||
remaining = lines[:len(lines) - n]
|
||
_atomic_write_text(path, ("\n".join(remaining) + "\n") if remaining else "")
|
||
_bump_history_revision(qq)
|
||
return (f"已删除最近 {n} 条历史(含用户输入与回复),剩余 {len(remaining)} 条。\n"
|
||
f"备份: {bpath}\n"
|
||
f"最后一条被删: {removed[-1][:50]}")
|
||
|
||
def stickers_enabled() -> bool:
|
||
"""运行时开关;关闭后不向模型提供标签,也拦截任何直接发送调用。"""
|
||
return bool(load_setting().get("stickers", True))
|
||
|
||
|
||
def available_sticker_tags():
|
||
"""表情库标签 = stickers/ 下的文件名(去扩展名)。"""
|
||
if not stickers_enabled():
|
||
return []
|
||
try:
|
||
tags = []
|
||
for fn in sorted(os.listdir(STICKERS_DIR)):
|
||
stem, ext = os.path.splitext(fn)
|
||
if ext.lower() in (".webp", ".png", ".jpg", ".jpeg"):
|
||
tags.append(stem)
|
||
return tags
|
||
except OSError:
|
||
return []
|
||
|
||
|
||
def _learn_stickers(scope: int, image_infos: List[dict], parsed: dict):
|
||
"""把主模型对本论用户贴纸的分类写入对应私聊/群聊场景库。"""
|
||
if not stickers_enabled() or not isinstance(parsed, dict):
|
||
return
|
||
learned = parsed.get("learned_stickers")
|
||
if not isinstance(learned, list):
|
||
return
|
||
sources = {
|
||
str(info.get("source_id")): info for info in (image_infos or [])
|
||
if info.get("kind") == "sticker" and info.get("source_id")
|
||
}
|
||
for classification in learned:
|
||
if not isinstance(classification, dict):
|
||
continue
|
||
source = sources.get(str(classification.get("source_id") or ""))
|
||
if not source:
|
||
continue
|
||
try:
|
||
item = STICKER_STORE.learn(STICKER_GLOBAL_SCOPE, source, classification)
|
||
if item:
|
||
print(f"[sticker] 已学习 {item.get('id')} scenes={item.get('scenes')}")
|
||
except Exception as exc:
|
||
print(f"[sticker] 学习失败: {type(exc).__name__}: {exc}")
|
||
|
||
|
||
def _sticker_prompt_summary(scope: int) -> str:
|
||
if not stickers_enabled():
|
||
return ""
|
||
try:
|
||
return STICKER_STORE.prompt_summary(STICKER_GLOBAL_SCOPE)
|
||
except Exception as exc:
|
||
print(f"[sticker] 场景摘要读取失败: {exc}")
|
||
return ""
|
||
|
||
|
||
def _with_sticker_sources(user_message: str, image_infos: List[dict]) -> str:
|
||
stickers = [info for info in (image_infos or []) if info.get("kind") == "sticker"]
|
||
if not stickers:
|
||
return user_message
|
||
markers = [
|
||
f"[用户发来表情包 source_id={info.get('source_id')},请识别画面、情绪和使用场景并写入learned_stickers。]"
|
||
for info in stickers
|
||
]
|
||
return "\n".join(markers + ([user_message] if user_message else []))
|
||
|
||
|
||
async def _confirm_sticker(item: dict, scene: str, context: str) -> bool:
|
||
"""发送前让同一个主模型再看抽中原图;审核失败时保守不发送。"""
|
||
preview = item.get("preview_path")
|
||
if not preview or not os.path.exists(preview):
|
||
return False
|
||
info = {
|
||
"url": preview,
|
||
"file_size": str(os.path.getsize(preview)),
|
||
}
|
||
prompt = (
|
||
"你在审核一张准备发送到当前聊天的表情包。只有它确实符合当前场景、语气和情绪时才允许发送。"
|
||
"特别区分日常撒娇/轻微难过与真正崩溃/强烈悲伤,不要只看画面相似就放行。"
|
||
"只输出JSON:{\"send\":true} 或 {\"send\":false}。\n"
|
||
f"候选场景:{scene}\n表情包描述:{item.get('description', '')}"
|
||
)
|
||
try:
|
||
with skill_runtime.suspend_skills():
|
||
raw = await call_deepseek(prompt, context or "当前聊天", image_infos=[info], reasoning=True)
|
||
if not raw:
|
||
return False
|
||
start, end = raw.find("{"), raw.rfind("}")
|
||
if start < 0 or end < start:
|
||
return False
|
||
decision = json.loads(raw[start:end + 1])
|
||
return decision.get("send") is True
|
||
except Exception as exc:
|
||
print(f"[sticker] 发送前确认失败: {type(exc).__name__}: {exc}")
|
||
return False
|
||
|
||
|
||
async def _maybe_send_sticker(websocket, user_id, scene, context="", msg_type="private", target_id=None):
|
||
"""按场景随机偷来的贴纸,经主模型复核后发送;旧预设库不参与。"""
|
||
if not stickers_enabled() or not isinstance(scene, str) or scene.strip().lower() in ("", "none"):
|
||
return False
|
||
try:
|
||
item = STICKER_STORE.random_for_scene(STICKER_GLOBAL_SCOPE, scene.strip())
|
||
except Exception as exc:
|
||
print(f"[sticker] 场景抽取失败: {exc}")
|
||
return False
|
||
if not item or not await _confirm_sticker(item, scene.strip(), context):
|
||
return False
|
||
try:
|
||
from aiogram.types import FSInputFile
|
||
if msg_type == "group":
|
||
channel, raw = websocket, int(target_id)
|
||
else:
|
||
resolved = _resolve_delivery(user_id, websocket)
|
||
if resolved is None:
|
||
return False
|
||
channel, raw = resolved
|
||
if not hasattr(channel, "bot"):
|
||
return False
|
||
raw = channel._to_raw_target(raw) if hasattr(channel, "_to_raw_target") else raw
|
||
await channel.bot.send_sticker(chat_id=raw, sticker=FSInputFile(item["path"]))
|
||
print(f"[sticker] 已发送 scene={scene} id={item.get('id')}")
|
||
return True
|
||
except Exception as exc:
|
||
print(f"[sticker] 表情发送失败 {scene}: {exc}")
|
||
return False
|
||
|
||
|
||
async def _tool_web_search(query):
|
||
"""DeepSeek Search API: POST /v1/tools/search"""
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
"https://api.deepseek.com/v1/tools/search",
|
||
headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||
"Content-Type": "application/json"},
|
||
json={"query": query, "count": 5, "need_content": False},
|
||
timeout=30) as resp:
|
||
if resp.status != 200:
|
||
return f"(搜索失败 HTTP {resp.status})"
|
||
data = await resp.json()
|
||
results = data.get("results") or []
|
||
if not results:
|
||
return f"(搜索 {query} 没有结果)"
|
||
lines = [f"【联网搜索 {query}】"]
|
||
for r in results:
|
||
lines.append(f"- {r.get('title')}\n {r.get('url')}\n {r.get('summary') or r.get('snippet')}")
|
||
return "\n".join(lines)
|
||
except Exception as e:
|
||
return f"(搜索异常: {e})"
|
||
|
||
|
||
async def _tool_fetch(url):
|
||
"""DeepSeek Fetch API: POST /v1/tools/fetch,网页转 Markdown"""
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
"https://api.deepseek.com/v1/tools/fetch",
|
||
headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||
"Content-Type": "application/json"},
|
||
json={"url": url}, timeout=30) as resp:
|
||
if resp.status != 200:
|
||
return f"(读取网页失败 HTTP {resp.status})"
|
||
data = await resp.json()
|
||
content = data.get("content") or data.get("markdown") or str(data)
|
||
return f"【网页内容 {url}】\n{content[:4000]}"
|
||
except Exception as e:
|
||
return f"(读取网页异常: {e})"
|
||
|
||
|
||
async def run_tools(tools):
|
||
"""执行模型请求的工具(每轮最多 2 个),返回注入模型的结果文本。"""
|
||
if not isinstance(tools, list) or not tools:
|
||
return ""
|
||
out = []
|
||
for t in tools[:2]:
|
||
if not isinstance(t, dict):
|
||
continue
|
||
name, args = t.get("name"), (t.get("args") or {})
|
||
if name == "web_search":
|
||
q = str(args.get("query") or "")[:128]
|
||
if q:
|
||
out.append(await _tool_web_search(q))
|
||
elif name == "fetch":
|
||
u = str(args.get("url") or "")
|
||
if u.startswith("http"):
|
||
out.append(await _tool_fetch(u))
|
||
return "\n\n".join(x for x in out if x)
|
||
|
||
|
||
def _message_with_time(message: str) -> str:
|
||
"""时间随当前用户消息发送,避免秒级时间戳打断 system prompt 缓存前缀。"""
|
||
now = get_beijing_time()
|
||
return f"现在是 {now.strftime('%Y-%m-%d %A %H:%M:%S')}\n{message}"
|
||
|
||
|
||
def build_system_prompt(qq: int, is_auto: bool = False, query: str = "", tool_context=None) -> str:
|
||
status = load_user_status(qq)
|
||
status_info = f"当前用户状态:pause={status['pause']}, sleep={status['sleep']}"
|
||
|
||
json_instruction = load_instruction("agent_instructions.txt")
|
||
if tool_context is not None and not is_auto:
|
||
import agent_loop
|
||
json_instruction += "\n\n" + agent_loop.tool_instructions(tool_context)
|
||
# P2: 关系卡(相识天数/对话次数/最近约定),恒定注入
|
||
relation_info = ""
|
||
try:
|
||
rel = memory_store.get_relation(qq)
|
||
if rel and rel.get("msg_count"):
|
||
days = int((get_beijing_timestamp() - rel["first_met"]) / 86400) if rel.get("first_met") else 0
|
||
relation_info = f"\n\n【我们的关系】相识 {days} 天,这是第 {rel['msg_count']} 次对话。"
|
||
if rel.get("notes"):
|
||
relation_info += f"最近:{rel['notes'][-1]['text']}"
|
||
except Exception:
|
||
relation_info = ""
|
||
|
||
# P3: lorebook(世界设定,关键词触发,不命中零成本)
|
||
lore_info = ""
|
||
for ent in _load_lorebook():
|
||
if any(k and k in query for k in ent.get("keywords", [])):
|
||
lore_info += f"\n【设定】{ent.get('content', '')}"
|
||
|
||
# P3: 实体卡(query 命中实体名时注入其当前有效关系)
|
||
entity_info = ""
|
||
if query:
|
||
for name, _cnt in memory_store.list_entities(qq, limit=50):
|
||
if name and name in query:
|
||
card = memory_store.entity_card(qq, name)
|
||
if card:
|
||
entity_info += f"\n【关于 {name}】\n" + "\n".join(
|
||
f"- {s} {pr} {o}" for s, pr, o, _imp in card)
|
||
|
||
# 只提供从用户贴纸学来的场景,不提供固定文件标签;同场景由库随机抽取。
|
||
sticker_summary = _sticker_prompt_summary(qq)
|
||
json_instruction += "\n【表情包场景分类定义】\n" + sticker_store.scene_prompt()
|
||
if sticker_summary:
|
||
json_instruction += (
|
||
"\n【可使用的表情包场景】sticker字段只能填写当前库存中的场景id,否则写none。\n"
|
||
+ sticker_summary
|
||
)
|
||
else:
|
||
json_instruction += "\n当前没有可发送的用户表情包,sticker字段写none。"
|
||
json_instruction += "\n【表情包场景分类定义】\n" + sticker_store.scene_prompt()
|
||
|
||
# P1: 召回式长期记忆(FTS5 按当前输入召回 + 高重要度置顶),替代"最近 100 条"
|
||
memory_store.migrate_legacy(qq)
|
||
facts = memory_store.recall_facts(qq, query)
|
||
memory_str = "\n".join(f"- {c}" for _, c, _ in facts) if facts else "(无)"
|
||
try:
|
||
notebook_context = notebook.notebook.context_for_chat(qq, query=query)
|
||
except Exception as exc:
|
||
print(f"[notebook] 上下文读取失败 uid={qq}: {exc}")
|
||
notebook_context = ""
|
||
# 仅此私聊入口读取个人衣柜;群聊构造器不调用这里。
|
||
try:
|
||
wardrobe_context = wardrobe.wardrobe.context_for_chat(qq, query=query)
|
||
except Exception as exc:
|
||
print(f"[wardrobe] 上下文读取失败 uid={qq}: {type(exc).__name__}")
|
||
wardrobe_context = "衣柜暂时无法读取,不要猜测库存、赠予记录或当前穿着。"
|
||
|
||
extra_auto = ""
|
||
if is_auto:
|
||
extra_auto = "\n" + load_instruction("notification_instructions.txt")
|
||
last_active_ts = get_last_active(qq)
|
||
if last_active_ts > 0:
|
||
hours_ago = (get_beijing_timestamp() - last_active_ts) / 3600
|
||
extra_auto += f"\n注意:用户已经有 {hours_ago:.1f} 小时没有和你说话了。"
|
||
|
||
# P1: 历史窗口按 token 预算取(人设+指令+记忆占用之后的剩余额度)
|
||
persona_prompt = user_persona_prompt(qq)
|
||
fixed_tokens = (estimate_tokens(persona_prompt)
|
||
+ estimate_tokens(status_info) + estimate_tokens(json_instruction)
|
||
+ estimate_tokens(extra_auto) + estimate_tokens(memory_str)
|
||
+ estimate_tokens(notebook_context) + estimate_tokens(wardrobe_context)
|
||
+ estimate_tokens(relation_info)
|
||
+ estimate_tokens(lore_info) + estimate_tokens(entity_info))
|
||
skill_reserve = skill_runtime.estimated_overhead()
|
||
history_budget = max(2000, PROMPT_TOKEN_BUDGET - fixed_tokens - skill_reserve - 200)
|
||
history_lines = get_history_within_budget(qq, history_budget)
|
||
chat_history_str = "\n".join(history_lines) if history_lines else "(无最近对话)"
|
||
|
||
if is_auto:
|
||
# 反重复: 摆出最近的主动消息, 明确要求换内容/活动/句式(治"每次都泡果茶")
|
||
recent_auto = [ln for ln in history_lines if "[auto]" in ln][-3:]
|
||
if recent_auto:
|
||
extra_auto += ("\n\n【反重复】你最近的主动消息是:\n" + "\n".join(recent_auto)
|
||
+ "\n这次请不要重复同样的活动、开场白或句式,换一个新的内容/话题。")
|
||
|
||
return f"{persona_prompt}\n\n{json_instruction}\n\n【长期记忆】以下是按当前话题召回的相关记忆(含用户偏好、承诺与约定,请始终记住并遵守,不要重复询问已记住的事):\n{memory_str}\n\n{notebook_context}\n\n{wardrobe_context}\n\n{status_info}{relation_info}{lore_info}{entity_info}\n\n以下是最近你和用户的聊天记录:\n{chat_history_str}\n\n{extra_auto}\n现在请根据以上信息,按照JSON格式输出你的回复。"
|
||
|
||
# ---------- 魔搭社区:每日限额状态管理(存 chat/setting.json,跨重启保留) ----------
|
||
def _modelscope_load():
|
||
"""读取当日魔搭状态;跨天自动重置计数与耗尽标记。"""
|
||
with _setting_lock:
|
||
st = load_setting()
|
||
today = get_beijing_time().strftime("%Y-%m-%d")
|
||
if st.get("modelscope_date") != today:
|
||
st["modelscope_date"] = today
|
||
st["modelscope_count"] = 0
|
||
st["modelscope_exhausted"] = False
|
||
save_setting(st)
|
||
return st
|
||
|
||
|
||
def _modelscope_available():
|
||
st = _modelscope_load()
|
||
if st.get("modelscope_permanent_blocked"):
|
||
return False # 持续触发内容审查,永久不再导向魔搭(需手动解除)
|
||
if st.get("modelscope_suspect_date") == st.get("modelscope_date"):
|
||
return False # 今日已触发审查嫌疑:当天剩余对话直接 DS
|
||
if st.get("modelscope_exhausted"):
|
||
return False
|
||
return int(st.get("modelscope_count", 0)) < MODELSCOPE_DAILY_LIMIT
|
||
|
||
|
||
def _modelscope_mark_suspect():
|
||
"""内容审查嫌疑(空壳响应):当日不再尝试魔搭;跨天再次触发则永久禁用。"""
|
||
with _setting_lock:
|
||
st = _modelscope_load()
|
||
today = st["modelscope_date"]
|
||
if st.get("modelscope_suspect_date") == today:
|
||
return # 今天已标记,不重复计数
|
||
days = int(st.get("modelscope_suspect_days", 0)) + 1
|
||
st["modelscope_suspect_date"] = today
|
||
st["modelscope_suspect_days"] = days
|
||
if days >= 2:
|
||
st["modelscope_permanent_blocked"] = True
|
||
print(f"[DEBUG][chat] ModelScope 内容审查反复触发({days} 天),永久不再导向魔搭;"
|
||
f"如需恢复请删除 setting.json 的 modelscope_permanent_blocked")
|
||
else:
|
||
print(f"[DEBUG][chat] ModelScope 疑似内容审查拦截(第 {days} 天),今日剩余对话直接走 DeepSeek official")
|
||
save_setting(st)
|
||
|
||
|
||
def _modelscope_mark_success():
|
||
with _setting_lock:
|
||
st = _modelscope_load()
|
||
st["modelscope_count"] = int(st.get("modelscope_count", 0)) + 1
|
||
save_setting(st)
|
||
|
||
|
||
def _modelscope_mark_exhausted():
|
||
with _setting_lock:
|
||
st = _modelscope_load()
|
||
if not st.get("modelscope_exhausted"):
|
||
st["modelscope_exhausted"] = True
|
||
save_setting(st)
|
||
print(f"[DEBUG][chat] ModelScope 今日额度已耗尽(已用 {st.get('modelscope_count', 0)} 次),"
|
||
f"后续回退 DeepSeek official")
|
||
|
||
|
||
def _is_ms_quota_error(status: int, body: str) -> bool:
|
||
"""宽松识别魔搭额度/配额类错误。宁可误回退 DS,也不让额度错误卡住对话。"""
|
||
if status in (402, 403, 429):
|
||
return True
|
||
lowered = (body or "").lower()
|
||
return any(k in lowered for k in (
|
||
"quota", "insufficient", "exceeded", "balance",
|
||
"额度", "余额不足", "超限", "配额",
|
||
))
|
||
|
||
|
||
def _reasoning_params(reasoning: bool) -> dict:
|
||
"""deepseek-flash 显式思考开关;Agent 用 high,普通聊天明确关闭。"""
|
||
if reasoning:
|
||
return {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}
|
||
return {"thinking": {"type": "disabled"}}
|
||
|
||
|
||
def _log_model_usage(provider: str, data: dict) -> None:
|
||
"""只记录服务端用量,不记录消息正文、密钥或推理内容。"""
|
||
usage = data.get("usage") if isinstance(data, dict) else None
|
||
if not isinstance(usage, dict):
|
||
return
|
||
details = usage.get("prompt_tokens_details") or {}
|
||
cached = usage.get("prompt_cache_hit_tokens")
|
||
if cached is None and isinstance(details, dict):
|
||
cached = details.get("cached_tokens")
|
||
fields = {"prompt_tokens": usage.get("prompt_tokens"), "cached_tokens": cached,
|
||
"cache_miss_tokens": usage.get("prompt_cache_miss_tokens"),
|
||
"completion_tokens": usage.get("completion_tokens")}
|
||
# Providers can omit these fields; unknown is not the same as zero hits.
|
||
fields = {k: v for k, v in fields.items() if isinstance(v, int) and not isinstance(v, bool)}
|
||
if fields:
|
||
print(f"[model-usage] {provider} " + json.dumps(fields, sort_keys=True))
|
||
|
||
|
||
async def _call_modelscope(messages: List[dict], presence_penalty: float = 0.0,
|
||
reasoning: bool = False) -> Optional[str]:
|
||
"""调用魔搭社区 API-Inference。成功返回 content;失败返回 None。
|
||
明确的额度错误会标记今日 exhausted,其余错误仅本次回退。"""
|
||
_MS_EMPTY_SHELL.set(False)
|
||
headers = {
|
||
"Authorization": f"Bearer {MODELSCOPE_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
payload = {"model": MODELSCOPE_MODEL, "messages": messages,
|
||
"temperature": 0.7, "stream": False}
|
||
payload.update(_reasoning_params(reasoning))
|
||
if presence_penalty:
|
||
payload["presence_penalty"] = presence_penalty
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(f"{MODELSCOPE_BASE_URL}/chat/completions",
|
||
headers=headers, json=payload, timeout=60) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
_log_model_usage("modelscope", data)
|
||
# 防御:魔搭对超长/异常输入可能返回 choices:null 的空壳 200,
|
||
# 直接下标会 TypeError;结构不完整视为内容审查嫌疑,回退 DS。
|
||
choices = data.get("choices")
|
||
if not choices:
|
||
print("[DEBUG][chat] ModelScope 响应缺少 choices(疑似内容审查),回退 DeepSeek official")
|
||
_MS_EMPTY_SHELL.set(True)
|
||
_modelscope_mark_suspect()
|
||
return None
|
||
content = (choices[0].get("message") or {}).get("content")
|
||
if not content:
|
||
print("[DEBUG][chat] ModelScope 响应 content 为空(疑似内容审查),回退 DeepSeek official")
|
||
_MS_EMPTY_SHELL.set(True)
|
||
_modelscope_mark_suspect()
|
||
return None
|
||
print(f"[DEBUG][chat] ModelScope 响应成功,长度 {len(content)}")
|
||
return content
|
||
text = await resp.text()
|
||
print(f"[DEBUG][chat] ModelScope 失败 HTTP {resp.status}: {text[:200]}")
|
||
if _is_ms_quota_error(resp.status, text):
|
||
_modelscope_mark_exhausted()
|
||
return None
|
||
except Exception as e:
|
||
print(f"[DEBUG][chat] ModelScope 请求异常: {type(e).__name__}: {e}")
|
||
return None
|
||
|
||
|
||
_HIST_HEAD = "以下是最近你和用户的聊天记录:\n"
|
||
_MEMO_HEAD = "\n\n【长期记忆】"
|
||
|
||
|
||
def _ms_short_system(system_prompt: str) -> str:
|
||
"""魔搭重试专用:砍掉聊天记录段(保留人设/时间/状态/长期记忆/JSON 指令)。
|
||
魔搭免费档对超长输入返回 choices:null 空壳,砍历史后重试一次。"""
|
||
i = system_prompt.find(_HIST_HEAD)
|
||
j = system_prompt.find(_MEMO_HEAD, i + 1) if i != -1 else -1
|
||
if i == -1 or j == -1:
|
||
# 结构不符:保守保留头 4000 + 尾 2000 字符(尾部含 JSON 指令)
|
||
return (system_prompt[:4000] + "\n……(中间内容过长省略)……\n"
|
||
+ system_prompt[-2000:])
|
||
return (system_prompt[:i] + _HIST_HEAD
|
||
+ "(对话历史过长,本回合省略,请基于人设与长期记忆自然回应。)"
|
||
+ system_prompt[j:])
|
||
|
||
|
||
async def call_deepseek(system_prompt: str, user_message: str, retry: int = 0,
|
||
presence_penalty: float = 0.0, image_infos: List[dict] = None,
|
||
reasoning: bool = False) -> Optional[str]:
|
||
"""用户可见生成的统一入口;未开启 Skill 会话时保持原请求不变。"""
|
||
budget = {"_prompt_token_budget": PROMPT_TOKEN_BUDGET} if skill_runtime.estimated_overhead() else {}
|
||
return await skill_runtime.generate_with_skills(
|
||
_call_deepseek_transport, system_prompt, user_message, retry,
|
||
presence_penalty=presence_penalty, image_infos=image_infos, reasoning=reasoning, **budget)
|
||
|
||
|
||
async def _call_deepseek_transport(system_prompt: str, user_message: str, retry: int = 0,
|
||
presence_penalty: float = 0.0, image_infos: List[dict] = None,
|
||
reasoning: bool = False) -> Optional[str]:
|
||
"""仅执行模型传输,不递归进入 Skill 读取循环。"""
|
||
user_content = await _build_user_content(user_message, image_infos)
|
||
if _modelscope_available():
|
||
content = await _call_modelscope([
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_content},
|
||
], presence_penalty=presence_penalty, reasoning=reasoning)
|
||
if (content is None and len(system_prompt) > 6000 and _MS_EMPTY_SHELL.get()
|
||
and not skill_runtime.estimated_overhead()):
|
||
# 魔搭对超长输入返回空壳响应:砍历史重试一次,原图保留。
|
||
short = _ms_short_system(system_prompt)
|
||
print(f"[DEBUG][chat] ModelScope 长输入空壳,砍历史重试({len(system_prompt)}→{len(short)} 字符)")
|
||
content = await _call_modelscope([
|
||
{"role": "system", "content": short},
|
||
{"role": "user", "content": user_content},
|
||
], presence_penalty=presence_penalty, reasoning=reasoning)
|
||
if content is not None:
|
||
LAST_HTTP_STATUS.set(200)
|
||
_modelscope_mark_success()
|
||
return content
|
||
print("[DEBUG][chat] ModelScope 不可用,回退 DeepSeek official")
|
||
return await _call_deepseek_official(
|
||
system_prompt, user_content, retry,
|
||
presence_penalty=presence_penalty, reasoning=reasoning)
|
||
|
||
|
||
async def _call_deepseek_official(system_prompt: str, user_content: Any, retry: int = 0,
|
||
presence_penalty: float = 0.0,
|
||
reasoning: bool = False) -> Optional[str]:
|
||
LAST_HTTP_STATUS.set(0)
|
||
print(f"[DEBUG][chat] 发起 DeepSeek official 请求 (retry={retry})")
|
||
headers = {
|
||
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
payload = {
|
||
"model": MODEL_NAME,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_content}
|
||
],
|
||
"temperature": 0.7,
|
||
"stream": False
|
||
}
|
||
payload.update(_reasoning_params(reasoning))
|
||
if presence_penalty:
|
||
payload["presence_penalty"] = presence_penalty
|
||
url = f"{DEEPSEEK_BASE_URL}/chat/completions"
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(url, headers=headers, json=payload, timeout=60) as resp:
|
||
print(f"[DEBUG][chat] HTTP 状态码: {resp.status}")
|
||
LAST_HTTP_STATUS.set(resp.status)
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
_log_model_usage("deepseek", data)
|
||
content = data["choices"][0]["message"]["content"]
|
||
print(f"[DEBUG][chat] API 响应成功,长度 {len(content)}")
|
||
return content
|
||
else:
|
||
text = await resp.text()
|
||
print(f"[DEBUG][chat] 请求失败,状态码 {resp.status},响应体: {text[:500]}")
|
||
if 400 <= resp.status < 500:
|
||
return None # 4xx 客户端错误(余额/限流/鉴权)重试无意义
|
||
if retry < MAX_RETRIES:
|
||
await asyncio.sleep(2 ** retry)
|
||
return await _call_deepseek_official(
|
||
system_prompt, user_content, retry + 1,
|
||
presence_penalty=presence_penalty, reasoning=reasoning)
|
||
return None
|
||
except Exception as e:
|
||
print(f"[DEBUG][chat] 请求异常: {e}")
|
||
if retry < MAX_RETRIES:
|
||
await asyncio.sleep(2 ** retry)
|
||
return await _call_deepseek_official(
|
||
system_prompt, user_content, retry + 1,
|
||
presence_penalty=presence_penalty, reasoning=reasoning)
|
||
return None
|
||
|
||
def _fallback_by_status(status: int) -> str:
|
||
"""按 API HTTP 状态码选择兜底回复"""
|
||
if status == 402:
|
||
return "我好饿…能给我点钱去吃饭吗…"
|
||
if status == 429:
|
||
return "等下等下,我在忙呢,晚点回你~"
|
||
if status in (400, 403):
|
||
return "这个我不想说呢"
|
||
if 500 <= status < 600:
|
||
return "手机没电了,我去充个电,等下再来找你!"
|
||
return "唔,我现在手机好像没有信号,消息发不出去,等下再回你~"
|
||
|
||
|
||
def _tool_plan_requires_reasoning(parsed: dict, tool_context) -> bool:
|
||
return bool(
|
||
tool_context is not None
|
||
and isinstance(parsed, dict)
|
||
and parsed.get("understood") is not False
|
||
and isinstance(parsed.get("tools"), list)
|
||
and parsed["tools"]
|
||
)
|
||
|
||
|
||
def _chat_requires_reasoning(parsed: dict, tool_context, is_auto: bool = False) -> bool:
|
||
if is_auto or not isinstance(parsed, dict):
|
||
return False
|
||
return parsed.get("need_reasoning") is True or _tool_plan_requires_reasoning(parsed, tool_context)
|
||
|
||
|
||
def _json_retry_uses_reasoning(attempt: int) -> bool:
|
||
"""初次非思考 + 一次普通重试后,最后一次 JSON 重试启用 reasoning。"""
|
||
return attempt == MAX_RETRIES - 2
|
||
|
||
|
||
async def _reasoning_json_response(system_prompt: str, user_message: str,
|
||
image_infos: List[dict] = None) -> Optional[dict]:
|
||
"""reasoning 规划最多两次;第二次只修正 JSON,不执行任何非思考工具计划。"""
|
||
prompt = system_prompt
|
||
for attempt in range(2):
|
||
raw = await call_deepseek(prompt, user_message, 0,
|
||
image_infos=image_infos, reasoning=True)
|
||
parsed = parse_json_response(raw) if raw else None
|
||
if parsed:
|
||
return parsed
|
||
print(f"[DEBUG][chat] reasoning JSON 失败(第{attempt + 1}次): {(raw or '')[:800]!r}")
|
||
prompt = (
|
||
system_prompt
|
||
+ "\n\n【输出修正】上次回答为空或不符合约定JSON。现在只输出一个完整、合法的JSON对象;"
|
||
"message必须是数组,每项都必须包含字符串content;tools按实际调用填写,"
|
||
"不要输出分析过程、Markdown或其他文字。"
|
||
)
|
||
return None
|
||
|
||
|
||
def parse_json_response(raw: str) -> Optional[dict]:
|
||
if not raw:
|
||
return None
|
||
import re
|
||
json_pattern = r'```json\s*(\{.*?\})\s*```'
|
||
match = re.search(json_pattern, raw, re.DOTALL)
|
||
if match:
|
||
json_str = match.group(1)
|
||
else:
|
||
start = raw.find('{')
|
||
end = raw.rfind('}')
|
||
if start != -1 and end != -1:
|
||
json_str = raw[start:end+1]
|
||
else:
|
||
return None
|
||
try:
|
||
result = json.loads(json_str)
|
||
message = result.get("message") if isinstance(result, dict) else None
|
||
if isinstance(message, list) and all(
|
||
isinstance(item, dict) and isinstance(item.get("content"), str)
|
||
for item in message):
|
||
return result
|
||
return None
|
||
except json.JSONDecodeError:
|
||
return None
|
||
|
||
async def get_wallet_balance() -> Optional[float]:
|
||
"""查询 DeepSeek 账户余额(元)。失败返回 None。"""
|
||
base = DEEPSEEK_BASE_URL
|
||
if base.endswith("/v1"):
|
||
base = base[:-3]
|
||
url = base + "/user/balance"
|
||
headers = {"Authorization": f"Bearer {DEEPSEEK_API_KEY}"}
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(url, headers=headers, timeout=30) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
for info in data.get("balance_infos") or []:
|
||
if info.get("currency") == "CNY":
|
||
return float(info["total_balance"])
|
||
else:
|
||
print(f"[DEBUG][chat] 余额查询失败,状态码 {resp.status}")
|
||
except Exception as e:
|
||
print(f"[DEBUG][chat] 余额查询异常: {e}")
|
||
return None
|
||
|
||
|
||
def _strip_trailing_period(text: str) -> str:
|
||
"""消息末尾的单个中文句号清除: 你好。-> 你好; 你好。。不变(连续句号保留)"""
|
||
if len(text) >= 2 and text[-1] == "。" and text[-2] != "。":
|
||
return text[:-1]
|
||
return text
|
||
|
||
|
||
def set_delivery(uid, channel, target_id):
|
||
"""Bind one raw private destination for a unified UID.
|
||
|
||
``channel`` may be a Channel object or its registered name. This is the only
|
||
way to select a specific one of multiple same-channel bindings; proactive
|
||
delivery otherwise uses identity's single preferred target.
|
||
"""
|
||
uid = int(uid)
|
||
target_id = int(target_id)
|
||
if not channel:
|
||
raise ValueError("channel is required")
|
||
_delivery_targets[uid] = (channel, target_id)
|
||
name = channel if isinstance(channel, str) else getattr(channel, "name", "")
|
||
if name:
|
||
_user_channel[uid] = name
|
||
return name, target_id
|
||
|
||
|
||
def _channel_by_name(name, preferred=None):
|
||
if not name:
|
||
return None
|
||
if preferred is not None and getattr(preferred, "name", None) == name:
|
||
return preferred
|
||
if _websocket is not None and getattr(_websocket, "name", None) == name:
|
||
return _websocket
|
||
try:
|
||
from channel import get_channel
|
||
return get_channel(name)
|
||
except (ImportError, AttributeError):
|
||
return None
|
||
|
||
|
||
def _identity_target(uid):
|
||
"""Return exactly one (channel, raw target), never a mirror fanout list."""
|
||
try:
|
||
import identity
|
||
except ImportError:
|
||
return None
|
||
try:
|
||
target = identity.push_target(int(uid))
|
||
except (AttributeError, TypeError, ValueError):
|
||
target = None
|
||
if target:
|
||
return target
|
||
try:
|
||
targets = identity.push_targets(int(uid))
|
||
except (AttributeError, TypeError, ValueError):
|
||
return None
|
||
return targets[0] if targets else None
|
||
|
||
|
||
def _resolve_delivery(uid, websocket=None):
|
||
uid = int(uid)
|
||
explicit = _delivery_targets.get(uid)
|
||
if explicit:
|
||
channel, target_id = explicit
|
||
if isinstance(channel, str):
|
||
channel = _channel_by_name(channel, websocket)
|
||
if channel is not None:
|
||
return channel, int(target_id)
|
||
target = _identity_target(uid)
|
||
if not target:
|
||
return None
|
||
name, target_id = target
|
||
channel = _channel_by_name(name, websocket)
|
||
if channel is None:
|
||
return None
|
||
return channel, int(target_id)
|
||
|
||
|
||
async def send_private_message(websocket, user_id: int, content: str):
|
||
"""Send to one resolved raw target; a 9e9 unified UID is never a chat address."""
|
||
from channel import send_message
|
||
content = _strip_trailing_period(content)
|
||
if not content:
|
||
return False
|
||
resolved = _resolve_delivery(user_id, websocket)
|
||
if resolved is None:
|
||
print(f"[DEBUG][chat] 无法解析 UID {user_id} 的私聊投递目标,消息未发送")
|
||
return False
|
||
channel, target_id = resolved
|
||
result = await send_message(channel, "private", target_id, content)
|
||
print(f"[DEBUG][chat] 已发送私聊消息 uid={user_id} target={target_id}: {content[:50]}...")
|
||
return result
|
||
|
||
async def send_message_segments(websocket, user_id: int, segments: List[dict]):
|
||
all_ok = True
|
||
for idx, seg in enumerate(segments):
|
||
content = seg.get("content", "")
|
||
if not content:
|
||
continue
|
||
delay_per_char = random.uniform(TYPE_DELAY_MIN_MS / 1000, TYPE_DELAY_MAX_MS / 1000)
|
||
total_delay = len(content) * delay_per_char
|
||
if total_delay > 2.0:
|
||
total_delay = 2.0
|
||
print(f"[DEBUG][chat] 发送第 {idx+1}/{len(segments)} 条,延迟 {total_delay:.2f}s")
|
||
await asyncio.sleep(total_delay)
|
||
all_ok = bool(await send_private_message(websocket, user_id, content)) and all_ok
|
||
return all_ok
|
||
|
||
def _cleanup_image_infos(image_infos: List[dict]):
|
||
"""TG 临时文件只读一次;库中已复制的对象不在 /tmp,不受影响。"""
|
||
paths = set()
|
||
for info in image_infos or []:
|
||
for key in ("url", "original_path"):
|
||
value = info.get(key)
|
||
if isinstance(value, str) and value.startswith(tempfile.gettempdir() + os.sep):
|
||
paths.add(value)
|
||
for path in paths:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
async def _process_pending_messages(user_id: int, delay: float):
|
||
await asyncio.sleep(delay)
|
||
messages = _user_pending_messages.pop(user_id, [])
|
||
image_infos = _user_pending_images.pop(user_id, [])
|
||
_user_pending_tasks.pop(user_id, None)
|
||
try:
|
||
if not messages and not image_infos:
|
||
return
|
||
combined = "\n".join(messages) if messages else ""
|
||
if not combined and not image_infos:
|
||
return
|
||
print(f"[DEBUG][chat] 用户 {user_id} 在 {delay}s 内发送了 {len(messages)} 条消息和 {len(image_infos)} 张图片,合并为: {combined[:100]!r}")
|
||
from channel import get_channel
|
||
ch = get_channel(_user_channel.get(user_id, "")) or _websocket
|
||
await _call_model_and_reply(ch, user_id, combined, is_auto=False, image_infos=image_infos)
|
||
finally:
|
||
_cleanup_image_infos(image_infos)
|
||
|
||
|
||
async def _read_local_image(path: str) -> bytes:
|
||
def read():
|
||
with open(path, "rb") as fh:
|
||
return fh.read()
|
||
return await asyncio.to_thread(read)
|
||
|
||
|
||
async def _image_data_url(info: dict, retry: int = 0) -> Optional[str]:
|
||
"""把图片转换成主模型可直接接收的 data URL,不调用独立视觉模型。"""
|
||
image_url = info.get("url", "")
|
||
file_size = info.get("file_size", "0")
|
||
if retry >= 3:
|
||
return None
|
||
try:
|
||
size_bytes = int(file_size) if file_size else 0
|
||
if size_bytes > 100 * 1024 * 1024:
|
||
print(f"[DEBUG][chat] 图片过大 ({size_bytes} bytes),跳过")
|
||
return None
|
||
if image_url.startswith(("http://", "https://")):
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(image_url, timeout=60) as img_resp:
|
||
if img_resp.status != 200:
|
||
print(f"[DEBUG][chat] 下载图片失败, 状态码 {img_resp.status}")
|
||
if retry < 2:
|
||
await asyncio.sleep(2 ** retry)
|
||
return await _image_data_url(info, retry + 1)
|
||
return None
|
||
img_data = await img_resp.read()
|
||
content_type = img_resp.headers.get("Content-Type", "")
|
||
else:
|
||
try:
|
||
img_data = await _read_local_image(image_url)
|
||
except OSError as exc:
|
||
print(f"[DEBUG][chat] 本地图片读取失败: {exc}")
|
||
return None
|
||
import mimetypes
|
||
content_type = mimetypes.guess_type(image_url)[0] or "image/jpeg"
|
||
if len(img_data) > 100 * 1024 * 1024:
|
||
print(f"[DEBUG][chat] 图片实际大小 {len(img_data)} bytes 超过 100MB,跳过")
|
||
return None
|
||
mime = content_type.split(";", 1)[0].strip().lower()
|
||
if mime not in ("image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"):
|
||
mime = "image/jpeg"
|
||
encoded = base64.b64encode(img_data).decode("ascii")
|
||
return f"data:{mime};base64,{encoded}"
|
||
except Exception as exc:
|
||
print(f"[DEBUG][chat] 图片编码异常: {type(exc).__name__}: {exc}")
|
||
if retry < 2:
|
||
await asyncio.sleep(2 ** retry)
|
||
return await _image_data_url(info, retry + 1)
|
||
return None
|
||
|
||
|
||
async def _build_user_content(user_message: str, image_infos: List[dict] = None):
|
||
"""无图片时保持原字符串;有图片时构造 OpenAI 多模态 content parts。"""
|
||
if not image_infos:
|
||
return user_message
|
||
parts = []
|
||
failed = 0
|
||
for info in image_infos:
|
||
data_url = await _image_data_url(info)
|
||
if data_url:
|
||
parts.append({"type": "image_url", "image_url": {"url": data_url}})
|
||
else:
|
||
failed += 1
|
||
text = user_message or "请理解图片内容,并按用户的要求处理。"
|
||
if failed:
|
||
text += f"\n(另有 {failed} 张图片读取失败。)"
|
||
if not parts:
|
||
return text
|
||
parts.append({"type": "text", "text": text})
|
||
return parts
|
||
|
||
async def handle_private_message(websocket, user_id: int, message_text: str, image_infos: List[dict] = None):
|
||
_user_channel[user_id] = getattr(websocket, "name", "qq")
|
||
if not is_chat_enabled():
|
||
return
|
||
global _websocket
|
||
_websocket = websocket
|
||
_user_last_active[user_id] = get_beijing_timestamp()
|
||
_user_no_reply_count[user_id] = 0
|
||
status = load_user_status(user_id)
|
||
if status["pause"] or status["sleep"]:
|
||
print(f"[DEBUG][chat] 用户 {user_id} 主动说话,解除 pause/sleep 状态")
|
||
update_user_status(user_id, pause=False, sleep=False)
|
||
print(f"[DEBUG][chat] 收到私聊消息: user={user_id}, text={message_text}, images={len(image_infos or [])}")
|
||
now_hour = get_beijing_time().hour
|
||
if SILENT_START <= now_hour < SILENT_END:
|
||
print(f"[DEBUG][chat] 进入夜间模式,回复 '在睡觉'")
|
||
await send_private_message(websocket, user_id, "[自动回复] 在睡觉,不要打扰呢...")
|
||
append_history(user_id, "you", "在睡觉,不要打扰呢...", is_auto=False)
|
||
return
|
||
if user_id not in _user_pending_messages:
|
||
_user_pending_messages[user_id] = []
|
||
_user_pending_messages[user_id].append(message_text)
|
||
if image_infos:
|
||
if user_id not in _user_pending_images:
|
||
_user_pending_images[user_id] = []
|
||
_user_pending_images[user_id].extend(image_infos)
|
||
if user_id in _user_pending_tasks:
|
||
_user_pending_tasks[user_id].cancel()
|
||
wait_seconds = random.uniform(USER_IDLE_WAIT_MIN, USER_IDLE_WAIT_MAX)
|
||
task = asyncio.create_task(_process_pending_messages(user_id, wait_seconds))
|
||
_user_pending_tasks[user_id] = task
|
||
|
||
@contextlib.asynccontextmanager
|
||
async def _noop_ctx():
|
||
yield
|
||
|
||
|
||
def _turn_lock(mapping: Dict[int, asyncio.Lock], key: int) -> asyncio.Lock:
|
||
if key not in mapping:
|
||
mapping[key] = asyncio.Lock()
|
||
return mapping[key]
|
||
|
||
|
||
async def _call_model_and_reply(websocket, user_id: int, user_message: str, is_auto: bool,
|
||
image_infos: List[dict] = None):
|
||
"""对外入口: 套「正在输入」提示,实际逻辑在 _impl"""
|
||
resolved = _resolve_delivery(user_id, websocket)
|
||
if resolved is not None:
|
||
channel, target_id = resolved
|
||
ctx = channel.typing(target_id) if hasattr(channel, "typing") else _noop_ctx()
|
||
else:
|
||
ctx = _noop_ctx()
|
||
import notifications
|
||
async with _turn_lock(_private_turn_locks, int(user_id)):
|
||
notifications.center.begin_conversation(user_id)
|
||
try:
|
||
async with ctx:
|
||
with skill_runtime.skill_session("proactive" if is_auto else "private_chat"):
|
||
await _call_model_and_reply_impl(websocket, user_id, user_message, is_auto, image_infos=image_infos)
|
||
finally:
|
||
notifications.center.end_conversation(user_id)
|
||
try:
|
||
# Events that arrived while the model was replying follow the final
|
||
# response instead of interrupting it mid-turn.
|
||
await notifications.center.deliver_due()
|
||
except Exception as exc:
|
||
print(f"[notifications] post-chat flush failed: {type(exc).__name__}: {exc}")
|
||
|
||
|
||
async def _call_model_and_reply_impl(websocket, user_id: int, user_message: str, is_auto: bool,
|
||
image_infos: List[dict] = None):
|
||
print(f"[DEBUG][chat] _call_model_and_reply: user={user_id}, is_auto={is_auto}, msg={user_message[:50]}")
|
||
if is_auto:
|
||
status = load_user_status(user_id)
|
||
if status["pause"] or status["sleep"]:
|
||
print(f"[DEBUG][chat] 用户 {user_id} 状态 pause={status['pause']}, sleep={status['sleep']},跳过主动推送")
|
||
return
|
||
if not is_auto:
|
||
history_text = user_message
|
||
if image_infos:
|
||
marker = f"[用户发送了 {len(image_infos)} 张图片]"
|
||
history_text = marker + ("\n" + user_message if user_message else "")
|
||
append_history(user_id, "user", history_text, is_auto=False)
|
||
from agent import ToolContext
|
||
resolved = _resolve_delivery(user_id, websocket)
|
||
tool_context = None
|
||
if resolved is not None and not is_auto:
|
||
tool_channel, tool_target = resolved
|
||
tool_context = ToolContext(
|
||
channel=tool_channel, msg_type="private", user_id=user_id,
|
||
target_id=tool_target, is_admin=bool(tool_channel.is_admin(user_id)))
|
||
model_user_message = _with_sticker_sources(user_message, image_infos)
|
||
system_prompt = build_system_prompt(user_id, is_auto=is_auto, query=model_user_message,
|
||
tool_context=tool_context)
|
||
effective_user_msg = _message_with_time(model_user_message if not is_auto else "请主动发起对话。")
|
||
raw_response = None
|
||
for retry in range(MAX_RETRIES):
|
||
raw_response = await call_deepseek(
|
||
system_prompt, effective_user_msg, retry,
|
||
presence_penalty=AUTO_PRESENCE_PENALTY if is_auto else 0.0,
|
||
image_infos=image_infos)
|
||
if raw_response:
|
||
break
|
||
if 400 <= LAST_HTTP_STATUS.get() < 500:
|
||
break
|
||
await asyncio.sleep(1)
|
||
if not raw_response:
|
||
status_code = LAST_HTTP_STATUS.get()
|
||
fallback = _fallback_by_status(status_code)
|
||
if status_code == 402:
|
||
# 余额不足按「每个用户每天」最多提示一次:既不风暴,也不会永久沉默。
|
||
# 主动 worker 每 10 分钟醒来时不再反复调用模型/发送同一句;
|
||
# 余额恢复后由成功响应或余额探测复位。
|
||
today = get_beijing_time().strftime("%Y-%m-%d")
|
||
status = load_user_status(user_id)
|
||
if status.get("balance_notice_date") != today:
|
||
await send_private_message(websocket, user_id, fallback)
|
||
if not is_auto:
|
||
append_history(user_id, "you", fallback, is_auto=False)
|
||
update_user_status(user_id, balance_notice_sent=True, balance_notice_date=today)
|
||
print(f"[DEBUG][chat] 用户 {user_id} 今日首次触发余额不足,已提示(按日抑制)")
|
||
else:
|
||
print(f"[DEBUG][chat] 用户 {user_id} 今日已提示过余额不足,本次静默")
|
||
else:
|
||
await send_private_message(websocket, user_id, fallback)
|
||
if not is_auto:
|
||
append_history(user_id, "you", fallback, is_auto=False)
|
||
return
|
||
|
||
# API 已恢复并成功返回:允许未来新的一天再提示。
|
||
status = load_user_status(user_id)
|
||
if status.get("balance_notice_sent", False):
|
||
update_user_status(user_id, balance_notice_sent=False, balance_notice_date="")
|
||
print(f"[DEBUG][chat] 用户 {user_id} API 已恢复,余额不足提示状态已复位")
|
||
parsed = None
|
||
for attempt in range(MAX_RETRIES):
|
||
parsed = parse_json_response(raw_response)
|
||
if parsed:
|
||
break
|
||
print(f"[DEBUG][chat] JSON 解析失败(第{attempt+1}次), 模型原始返回: {raw_response[:300]!r}")
|
||
if attempt >= MAX_RETRIES - 1:
|
||
break
|
||
use_reasoning = _json_retry_uses_reasoning(attempt)
|
||
raw_response = await call_deepseek(
|
||
system_prompt, effective_user_msg, 0,
|
||
presence_penalty=AUTO_PRESENCE_PENALTY if is_auto else 0.0,
|
||
image_infos=image_infos, reasoning=use_reasoning)
|
||
if not raw_response:
|
||
break
|
||
if not parsed:
|
||
print(f"[DEBUG][chat] 放弃: 多次解析失败, 最终原始返回: {raw_response[:300]!r}")
|
||
fallback = "抱歉,我有点迷糊,你能再说一遍吗?"
|
||
await send_private_message(websocket, user_id, fallback)
|
||
if not is_auto:
|
||
append_history(user_id, "you", fallback, is_auto=False)
|
||
return
|
||
# 模型自评需要深思,或出现任何工具调用时,统一用 reasoning 重做一次。
|
||
if _chat_requires_reasoning(parsed, tool_context, is_auto):
|
||
reasoning_parsed = await _reasoning_json_response(system_prompt, effective_user_msg, image_infos)
|
||
if reasoning_parsed:
|
||
parsed = reasoning_parsed
|
||
elif isinstance(parsed.get("tools"), list) and parsed["tools"]:
|
||
parsed = {
|
||
"message": [{"currentTurn": "1", "totalTurn": "1",
|
||
"content": "工具规划暂时失败了,请稍后再试。"}],
|
||
"tools": [],
|
||
}
|
||
_learn_stickers(user_id, image_infos, parsed)
|
||
# 多轮 Agent:utility 工具、web_search/fetch、产物和邮件附件可组合调用。
|
||
if tool_context is not None:
|
||
import agent_loop
|
||
parsed = await agent_loop.run_agent_loop(
|
||
sys.modules[__name__], parsed, system_prompt, effective_user_msg,
|
||
tool_context.channel, "private", user_id, tool_context.target_id, is_auto,
|
||
image_infos=image_infos)
|
||
|
||
segments = parsed.get("message", [])
|
||
if segments:
|
||
sent_ok = await send_message_segments(websocket, user_id, segments)
|
||
full_reply = " ".join([seg["content"] for seg in segments])
|
||
if not sent_ok:
|
||
print(f"[DEBUG][chat] 私聊回复投递失败 uid={user_id},不写入正常回复历史")
|
||
return
|
||
if not is_auto:
|
||
append_history(user_id, "you", full_reply, is_auto=False)
|
||
else:
|
||
append_history(user_id, "you", full_reply, is_auto=True)
|
||
sticker_scene = parsed.get("sticker")
|
||
if isinstance(sticker_scene, str) and sticker_scene.strip().lower() not in ("", "none"):
|
||
await _maybe_send_sticker(
|
||
websocket, user_id, sticker_scene.strip(),
|
||
context=effective_user_msg + "\n白花的回复:" + full_reply)
|
||
else:
|
||
print(f"[DEBUG][chat] 警告: message 字段为空")
|
||
memory = parsed.get("memory", "none")
|
||
_record_memory(user_id, memory)
|
||
try:
|
||
memory_store.touch_relation(user_id)
|
||
relation_note = parsed.get("relation")
|
||
if isinstance(relation_note, str) and relation_note.strip() and relation_note.strip() != "none":
|
||
memory_store.add_relation_note(user_id, relation_note.strip())
|
||
links = parsed.get("links")
|
||
if isinstance(links, list) and links:
|
||
added = memory_store.add_links(user_id, links)
|
||
if added:
|
||
print(f"[DEBUG][chat] 三元组入库 {len(added)} 条 (uid={user_id})")
|
||
except Exception as e:
|
||
print(f"[DEBUG][chat] 关系卡更新失败: {e}")
|
||
new_pause = parsed.get("pause")
|
||
new_sleep = parsed.get("sleep")
|
||
if isinstance(new_pause, bool) or isinstance(new_sleep, bool):
|
||
update_user_status(user_id, pause=new_pause if isinstance(new_pause, bool) else None,
|
||
sleep=new_sleep if isinstance(new_sleep, bool) else None)
|
||
print(f"[DEBUG][chat] 更新用户 {user_id} 状态: pause={new_pause}, sleep={new_sleep}")
|
||
if is_auto:
|
||
_user_last_auto[user_id] = get_beijing_timestamp()
|
||
|
||
async def _balance_allows_active(qq: int, status: dict) -> bool:
|
||
"""余额不足已提示时,禁止主动对话风暴;仅轻量探测余额恢复。"""
|
||
if not status.get("balance_notice_sent", False):
|
||
return True
|
||
balance = await get_wallet_balance()
|
||
if balance is not None and balance > 0:
|
||
update_user_status(qq, balance_notice_sent=False, balance_notice_date="")
|
||
status["balance_notice_sent"] = False
|
||
status["balance_notice_date"] = ""
|
||
print(f"[主动] 用户 {qq} 余额已恢复({balance:.2f} 元),解除余额不足抑制")
|
||
return True
|
||
print(f"[主动] 用户 {qq} 仍余额不足/查询失败,跳过本轮主动对话")
|
||
return False
|
||
|
||
|
||
async def active_push_worker():
|
||
global _websocket
|
||
while True:
|
||
await asyncio.sleep(600)
|
||
if not is_chat_enabled():
|
||
continue
|
||
now_ts = get_beijing_timestamp()
|
||
now = get_beijing_time()
|
||
now_hour = now.hour
|
||
if SILENT_START <= now_hour < SILENT_END:
|
||
continue
|
||
if not os.path.exists(HISTORY_DIR):
|
||
continue
|
||
for filename in os.listdir(HISTORY_DIR):
|
||
if not filename.endswith(".txt"):
|
||
continue
|
||
try:
|
||
qq = int(filename.split('.')[0])
|
||
except:
|
||
continue
|
||
status = load_user_status(qq)
|
||
if status.get("sleep", False):
|
||
today_str = now.strftime("%Y-%m-%d")
|
||
wake_at = _user_sleep_wake_at.get(qq)
|
||
if wake_at is None and now_hour >= 9 and _user_sleep_unscheduled_date.get(qq) != today_str:
|
||
delay = random.randint(0, 3600)
|
||
_user_sleep_wake_at[qq] = now_ts + delay
|
||
_user_sleep_unscheduled_date[qq] = today_str
|
||
print(f"[主动] 用户 {qq} 处于睡眠状态,将在 {delay} 秒后解除 sleep")
|
||
elif wake_at is not None and now_ts >= wake_at:
|
||
if load_user_status(qq).get("sleep", False):
|
||
update_user_status(qq, sleep=False)
|
||
print(f"[主动] 用户 {qq} 的 sleep 状态已自动解除(早上随机时刻)")
|
||
_user_sleep_wake_at.pop(qq, None)
|
||
continue
|
||
if not await _balance_allows_active(qq, status):
|
||
continue
|
||
last_auto = _user_last_auto.get(qq, 0)
|
||
counted_auto = _user_auto_counted.get(qq, 0)
|
||
if last_auto > counted_auto:
|
||
last_active = _user_last_active.get(qq, 0)
|
||
if last_active < last_auto:
|
||
_user_no_reply_count[qq] = _user_no_reply_count.get(qq, 0) + 1
|
||
print(f"[主动] 用户 {qq} 连续未回复计数: {_user_no_reply_count[qq]}")
|
||
if _user_no_reply_count[qq] >= 2:
|
||
update_user_status(qq, pause=True, sleep=None)
|
||
print(f"[主动] 用户 {qq} 两次未回复主动消息,已自动将 pause 设为 true")
|
||
_user_no_reply_count[qq] = 0
|
||
_user_auto_counted[qq] = last_auto
|
||
continue
|
||
else:
|
||
_user_no_reply_count[qq] = 0
|
||
_user_auto_counted[qq] = last_auto
|
||
last_active = get_last_active(qq)
|
||
if last_active == 0:
|
||
continue
|
||
last_auto = _user_last_auto.get(qq, 0)
|
||
interval = random.uniform(ACTIVE_INTERVAL_MIN, ACTIVE_INTERVAL_MAX)
|
||
if (now_ts - last_auto > interval) and (now_ts - last_active > interval):
|
||
current_status = load_user_status(qq)
|
||
if current_status.get("pause", False) or current_status.get("sleep", False):
|
||
continue
|
||
print(f"[主动] 向用户 {qq} 发起主动对话")
|
||
resolved = _resolve_delivery(qq)
|
||
if resolved is None:
|
||
print(f"[主动] UID {qq} 暂无可用投递通道,跳过")
|
||
continue
|
||
ch, target_id = resolved
|
||
from agent.modes import ModeStore
|
||
if ModeStore().get_mode(getattr(ch, "name", "tg"), qq) != "chat":
|
||
continue
|
||
await _call_model_and_reply(ch, qq, "触发主动对话", is_auto=True)
|
||
|
||
def start_active_push():
|
||
global _active_task
|
||
if _active_task is None or _active_task.done():
|
||
_active_task = asyncio.create_task(active_push_worker())
|
||
|
||
def init_chat():
|
||
ensure_dirs()
|
||
try:
|
||
migrated = STICKER_STORE.migrate_all_to_global(STICKER_GLOBAL_SCOPE)
|
||
if migrated:
|
||
print(f"[sticker] 已合并 {migrated} 条分会话贴纸记录到全局库")
|
||
except Exception as exc:
|
||
print(f"[sticker] 旧库存合并失败: {type(exc).__name__}: {exc}")
|
||
start_active_push()
|
||
print("[DEBUG][chat] 初始化完成,后台主动推送已启动(使用北京时间)")
|
||
|
||
def set_websocket(ws):
|
||
global _websocket
|
||
_websocket = ws
|
||
print("[DEBUG][chat] WebSocket 引用已设置")
|
||
|
||
# ---------- 群聊功能 ----------
|
||
def _get_group_history_path(group_id: int) -> str:
|
||
return os.path.join(GROUP_HISTORY_DIR, f"{group_id}.txt")
|
||
|
||
def append_group_history(group_id: int, user_id: int, content: str):
|
||
timestamp = get_beijing_time().strftime("%Y-%m-%d %H:%M:%S")
|
||
line = f"[{timestamp}][qq={user_id}] {content}"
|
||
with _group_lock(group_id):
|
||
os.makedirs(GROUP_HISTORY_DIR, exist_ok=True)
|
||
with open(_get_group_history_path(group_id), "a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
|
||
def get_recent_group_history(group_id: int, limit: int = GROUP_MAX_HISTORY) -> List[str]:
|
||
path = _get_group_history_path(group_id)
|
||
if not os.path.exists(path):
|
||
return []
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
return [line.strip() for line in lines[-limit:]]
|
||
|
||
async def send_group_message(websocket, group_id: int, content: str):
|
||
"""websocket 为 Channel 对象(通道抽象)"""
|
||
from channel import send_message
|
||
content = _strip_trailing_period(content)
|
||
if not content:
|
||
return False
|
||
result = await send_message(websocket, "group", group_id, content)
|
||
print(f"[DEBUG][chat][group] 已发送群聊消息给 {group_id}: {content[:50]}...")
|
||
return result
|
||
|
||
async def handle_chat_command(websocket, msg_type, target_id, raw_text):
|
||
"""管理员指令 .chat <on|off>,控制聊天模块开关。"""
|
||
parts = (raw_text or "").strip().split()
|
||
sub = parts[1].lower() if len(parts) >= 2 else ""
|
||
if sub not in ("on", "off"):
|
||
msg = "用法: .chat <on|off>"
|
||
else:
|
||
enabled = sub == "on"
|
||
set_chat_enabled(enabled)
|
||
msg = "聊天模块已开启" if enabled else "聊天模块已关闭"
|
||
if msg_type == "group":
|
||
await send_group_message(websocket, target_id, msg)
|
||
else:
|
||
await send_private_message(websocket, target_id, msg)
|
||
|
||
async def handle_group_message(websocket, group_id: int, user_id: int, message_text: str,
|
||
image_infos: List[dict] = None, is_mention: bool = False):
|
||
_user_channel[user_id] = getattr(websocket, "name", "qq")
|
||
if not is_chat_enabled():
|
||
return
|
||
if not GROUP_CHAT_ENABLED:
|
||
return
|
||
now = get_beijing_timestamp()
|
||
if group_id not in _group_msg_timestamps:
|
||
_group_msg_timestamps[group_id] = []
|
||
_group_msg_timestamps[group_id].append(now)
|
||
cutoff = now - GROUP_TRIGGER_WINDOW
|
||
_group_msg_timestamps[group_id] = [t for t in _group_msg_timestamps[group_id] if t > cutoff]
|
||
|
||
if message_text:
|
||
append_group_history(group_id, user_id, message_text)
|
||
|
||
if group_id not in _group_pending_messages:
|
||
_group_pending_messages[group_id] = []
|
||
_group_pending_messages[group_id].append(message_text)
|
||
if group_id not in _group_pending_users:
|
||
_group_pending_users[group_id] = []
|
||
_group_pending_users[group_id].append(user_id)
|
||
|
||
if image_infos:
|
||
if group_id not in _group_pending_images:
|
||
_group_pending_images[group_id] = []
|
||
_group_pending_images[group_id].extend(image_infos)
|
||
for info in image_infos:
|
||
url = info.get("url", "")
|
||
if url:
|
||
append_group_history(group_id, user_id, f"[图片] {url}")
|
||
|
||
if is_mention:
|
||
_group_triggered_by_mention[group_id] = True
|
||
|
||
_group_msg_counter[group_id] = _group_msg_counter.get(group_id, 0) + 1
|
||
current_cnt = _group_msg_counter[group_id]
|
||
|
||
should_trigger = is_mention
|
||
if GROUP_AUTO_TRIGGER and not should_trigger:
|
||
msg_count = len(_group_msg_timestamps[group_id])
|
||
if msg_count > GROUP_TRIGGER_THRESHOLD and random.random() < GROUP_TRIGGER_PROBABILITY:
|
||
should_trigger = True
|
||
|
||
continue_trigger = False
|
||
if GROUP_AUTO_TRIGGER and not should_trigger and group_id in _group_bot_last_speak_at:
|
||
since_bot = current_cnt - _group_bot_last_speak_at[group_id]
|
||
need = _group_continue_after.get(group_id, 5)
|
||
if since_bot >= need and random.random() < GROUP_CONTINUE_PROBABILITY:
|
||
should_trigger = True
|
||
continue_trigger = True
|
||
|
||
if should_trigger:
|
||
if group_id in _group_pending_tasks:
|
||
_group_pending_tasks[group_id].cancel()
|
||
task = asyncio.create_task(_process_group_messages(group_id, is_continue=continue_trigger, channel=websocket))
|
||
_group_pending_tasks[group_id] = task
|
||
|
||
async def _process_group_messages(group_id: int, is_continue: bool = False, channel=None):
|
||
await asyncio.sleep(random.uniform(1, 3))
|
||
messages = _group_pending_messages.pop(group_id, [])
|
||
pending_users = _group_pending_users.pop(group_id, [])
|
||
actor_uid = pending_users[-1] if pending_users and len(set(pending_users)) == 1 else 0
|
||
image_infos = _group_pending_images.pop(group_id, [])
|
||
_group_pending_tasks.pop(group_id, None)
|
||
is_mention = _group_triggered_by_mention.pop(group_id, False)
|
||
try:
|
||
if not messages and not image_infos:
|
||
return
|
||
recent_images = image_infos[-GROUP_MAX_IMAGES:] if image_infos else []
|
||
combined = "\n".join([m for m in messages if m]) if messages else ""
|
||
if not combined and not recent_images:
|
||
return
|
||
print(f"[DEBUG][chat][group] 群 {group_id} 触发, mention={is_mention}, msg={combined[:100]!r}, images={len(recent_images)}")
|
||
await _call_group_model_and_reply(channel or _websocket, group_id, combined, actor_uid,
|
||
image_infos=recent_images)
|
||
finally:
|
||
_cleanup_image_infos(image_infos)
|
||
|
||
|
||
async def _call_group_model_and_reply(websocket, group_id: int, user_message: str, user_id: int = 0,
|
||
image_infos: List[dict] = None):
|
||
"""对外入口: 套「正在输入」提示,实际逻辑在 _impl"""
|
||
ctx = websocket.typing(group_id) if websocket is not None else _noop_ctx()
|
||
async with _turn_lock(_group_turn_locks, int(group_id)):
|
||
async with ctx:
|
||
with skill_runtime.skill_session("group_chat"):
|
||
await _call_group_model_and_reply_impl(websocket, group_id, user_message, user_id,
|
||
image_infos=image_infos)
|
||
|
||
|
||
async def _call_group_model_and_reply_impl(websocket, group_id: int, user_message: str, user_id: int = 0,
|
||
image_infos: List[dict] = None):
|
||
print(f"[DEBUG][chat][group] _call_group_model_and_reply: group={group_id}")
|
||
|
||
history_lines = get_recent_group_history(group_id, GROUP_MAX_HISTORY)
|
||
history_str = "\n".join(history_lines) if history_lines else "(无最近对话)"
|
||
|
||
from agent import ToolContext
|
||
import agent_loop
|
||
tool_context = None
|
||
tool_prompt = ""
|
||
if websocket is not None and user_id:
|
||
tool_context = ToolContext(
|
||
channel=websocket, msg_type="group", user_id=user_id, target_id=group_id,
|
||
is_admin=bool(websocket.is_admin(user_id)))
|
||
tool_prompt = "\n\n" + agent_loop.tool_instructions(tool_context)
|
||
model_user_message = _message_with_time(_with_sticker_sources(user_message, image_infos))
|
||
sticker_summary = _sticker_prompt_summary(group_id)
|
||
if sticker_summary:
|
||
tool_prompt += "\n【可使用的表情包场景】\n" + sticker_summary
|
||
system_prompt = (BASE_PROMPT + "\n\n" + load_instruction("group_instructions.txt")
|
||
+ "\n【表情包场景分类定义】\n" + sticker_store.scene_prompt()
|
||
+ tool_prompt
|
||
+ "\n\n以下是本群的最近聊天记录:\n" + history_str)
|
||
|
||
raw_response = None
|
||
for retry in range(MAX_RETRIES):
|
||
raw_response = await call_deepseek(system_prompt, model_user_message, retry, image_infos=image_infos)
|
||
if raw_response:
|
||
break
|
||
if 400 <= LAST_HTTP_STATUS.get() < 500:
|
||
break
|
||
await asyncio.sleep(1)
|
||
|
||
if not raw_response:
|
||
print(f"[DEBUG][chat][group] API 无响应")
|
||
return
|
||
|
||
parsed = None
|
||
for attempt in range(MAX_RETRIES):
|
||
parsed = parse_json_response(raw_response)
|
||
if parsed:
|
||
break
|
||
if attempt >= MAX_RETRIES - 1:
|
||
break
|
||
raw_response = await call_deepseek(
|
||
system_prompt, model_user_message, 0,
|
||
image_infos=image_infos,
|
||
reasoning=_json_retry_uses_reasoning(attempt))
|
||
if not raw_response:
|
||
break
|
||
|
||
if not parsed:
|
||
print(f"[DEBUG][chat][group] JSON 解析失败")
|
||
return
|
||
|
||
if _chat_requires_reasoning(parsed, tool_context, False):
|
||
reasoning_parsed = await _reasoning_json_response(system_prompt, model_user_message, image_infos)
|
||
if reasoning_parsed:
|
||
parsed = reasoning_parsed
|
||
elif isinstance(parsed.get("tools"), list) and parsed["tools"]:
|
||
parsed = {
|
||
"message": [{"currentTurn": "1", "totalTurn": "1",
|
||
"content": "工具规划暂时失败了,请稍后再试。"}],
|
||
"tools": [],
|
||
}
|
||
_learn_stickers(group_id, image_infos, parsed)
|
||
if tool_context is not None:
|
||
parsed = await agent_loop.run_agent_loop(
|
||
sys.modules[__name__], parsed, system_prompt, model_user_message,
|
||
websocket, "group", user_id, group_id, False, image_infos=image_infos)
|
||
|
||
segments = parsed.get("message", [])
|
||
if segments:
|
||
full_reply = "".join([seg["content"] for seg in segments])
|
||
sent_ok = await send_group_message(websocket, group_id, full_reply)
|
||
if not sent_ok:
|
||
print(f"[DEBUG][chat][group] 群 {group_id} 回复投递失败,不写入正常回复历史")
|
||
return
|
||
append_group_history(group_id, 0, full_reply)
|
||
sticker_scene = parsed.get("sticker")
|
||
if isinstance(sticker_scene, str) and sticker_scene.strip().lower() not in ("", "none"):
|
||
await _maybe_send_sticker(
|
||
websocket, user_id, sticker_scene.strip(),
|
||
context=model_user_message + "\n白花的回复:" + full_reply,
|
||
msg_type="group", target_id=group_id)
|
||
_group_bot_last_speak_at[group_id] = _group_msg_counter.get(group_id, 0)
|
||
_group_continue_after[group_id] = random.randint(3, 5)
|