@zaofan/dsh-qqbot 1.4.6 → 1.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -535
- package/README_EN.md +6 -0
- package/client/qqbot-settings.js +152 -5
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +106 -1
- package/dist/channel-tools.js.map +1 -1
- package/dist/features/agg-score.d.ts +73 -0
- package/dist/features/agg-score.d.ts.map +1 -0
- package/dist/features/agg-score.js +63 -0
- package/dist/features/agg-score.js.map +1 -0
- package/dist/features/attitude.d.ts +132 -0
- package/dist/features/attitude.d.ts.map +1 -0
- package/dist/features/attitude.js +318 -0
- package/dist/features/attitude.js.map +1 -0
- package/dist/features/bot-reply-memo.d.ts +8 -0
- package/dist/features/bot-reply-memo.d.ts.map +1 -0
- package/dist/features/bot-reply-memo.js +34 -0
- package/dist/features/bot-reply-memo.js.map +1 -0
- package/dist/features/emo-samples.d.ts +27 -0
- package/dist/features/emo-samples.d.ts.map +1 -0
- package/dist/features/emo-samples.js +116 -0
- package/dist/features/emo-samples.js.map +1 -0
- package/dist/features/four-source.d.ts +114 -0
- package/dist/features/four-source.d.ts.map +1 -0
- package/dist/features/four-source.js +260 -0
- package/dist/features/four-source.js.map +1 -0
- package/dist/features/image-url-ledger.d.ts +2 -0
- package/dist/features/image-url-ledger.d.ts.map +1 -1
- package/dist/features/image-url-ledger.js +23 -0
- package/dist/features/image-url-ledger.js.map +1 -1
- package/dist/features/intimacy-ledger.d.ts +31 -0
- package/dist/features/intimacy-ledger.d.ts.map +1 -0
- package/dist/features/intimacy-ledger.js +113 -0
- package/dist/features/intimacy-ledger.js.map +1 -0
- package/dist/features/local-signals.d.ts +130 -0
- package/dist/features/local-signals.d.ts.map +1 -0
- package/dist/features/local-signals.js +445 -0
- package/dist/features/local-signals.js.map +1 -0
- package/dist/features/people-memo.d.ts +52 -0
- package/dist/features/people-memo.d.ts.map +1 -0
- package/dist/features/people-memo.js +239 -0
- package/dist/features/people-memo.js.map +1 -0
- package/dist/features/quote-cache.d.ts +19 -0
- package/dist/features/quote-cache.d.ts.map +1 -0
- package/dist/features/quote-cache.js +67 -0
- package/dist/features/quote-cache.js.map +1 -0
- package/dist/features/self-disclosure.d.ts +28 -0
- package/dist/features/self-disclosure.d.ts.map +1 -0
- package/dist/features/self-disclosure.js +60 -0
- package/dist/features/self-disclosure.js.map +1 -0
- package/dist/features/tendency-samples.d.ts +22 -0
- package/dist/features/tendency-samples.d.ts.map +1 -0
- package/dist/features/tendency-samples.js +194 -0
- package/dist/features/tendency-samples.js.map +1 -0
- package/dist/features/thinking-log.d.ts +22 -0
- package/dist/features/thinking-log.d.ts.map +1 -0
- package/dist/features/thinking-log.js +70 -0
- package/dist/features/thinking-log.js.map +1 -0
- package/dist/features/xlsx.d.ts +28 -0
- package/dist/features/xlsx.d.ts.map +1 -0
- package/dist/features/xlsx.js +159 -0
- package/dist/features/xlsx.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +25 -3
- package/dist/session/session-manager.js.map +1 -1
- package/dist/transport/events.d.ts +12 -0
- package/dist/transport/events.d.ts.map +1 -1
- package/dist/transport/events.js +15 -2
- package/dist/transport/events.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +344 -20
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/outbound-buffer.d.ts.map +1 -1
- package/dist/transport/outbound-buffer.js +8 -0
- package/dist/transport/outbound-buffer.js.map +1 -1
- package/dist/transport/outbound.d.ts +12 -0
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +170 -5
- package/dist/transport/outbound.js.map +1 -1
- package/docs/USER-GUIDE.md +429 -0
- package/package.json +2 -1
- package/settings-host.js +154 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# dsh-qqbot 用户手册
|
|
2
|
+
|
|
3
|
+
> [@zaofan/dsh-qqbot](../README.md) 的**详细文档**:配置项、内置命令、自定义扩展、群管理、本地小模型、好感度系统、架构与本地开发。
|
|
4
|
+
> 只想看"它能干什么 + 怎么装" → 回 **[README](../README.md)**。
|
|
5
|
+
|
|
6
|
+
**章节**:给AI的话 · 架构 · QQ 远程审批 · QQ 群管理 · 智能回复(本地小模型)· **好感度与熟识度** · 配置项 · 内置命令 · 用户扩展 · 富媒体指令 · 核心模块 · 会话路由 · 设计原则 · 本地开发
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
### 给AI的话
|
|
11
|
+
- QQ 会话内可直接调用的标准工具:发图/撤图/查库/打标/查未整理/定时(`send_media`/`recall_message`/`list_stickers`/`sticker_tag`/`sticker_untagged`/`schedule_timer`/`schedule_cancel`…),会话按账号精确路由
|
|
12
|
+
- **群管理工具**(`group_join_requests`/`group_approve_join`/`group_join_auto`/`group_mute_state`/`group_mute_member`/`id_lookup`…):入群审批与禁言,需机器人为该群管理员;**禁言可批量**(`member_openids` 数组,单次最多 20 人;`gid` 可指向别的群);**按昵称查 openid 用 `id_lookup`**(想主动 @ 没 @ 过你的人时用它;⚠️ 从没发过言、没申请过入群的人查不到,官方群成员列表接口未开放);对话内管当前群,web/非群会话用配置的 `manageGroup`。**入群审批默认听主人的,不自动批**——先 `group_join_requests` 查看,把申请人+验证信息汇报给主人,等主人明确说"通过/拒绝"再 `group_approve_join`;**主人明确要求**"按关键词自动批"时才用 `group_join_auto`(命中关键词放行 / 未命中拒绝,`dry_run` 可预览)
|
|
13
|
+
- **图片消息的内置「看图」提示可关**(`imageHint`,默认开):关掉后不再注入「把 URL 传给识图工具」那条提示 —— 模型自己能读图时,在设置面板 ③ 区块取消勾选即可。
|
|
14
|
+
- **纯文本也能发图撤消息**:让 AI 在回复里写 `[MEDIA:image|图片路径或网址]` 就自动变成真图发出去(`voice`/`video`/`file` 同理);写 `[RECALL]` 撤回自己刚发的那条
|
|
15
|
+
- **扩展命令能悄悄唤醒 AI**:命令返回 `{ wake: { content } }` 即可 —— 插件会把这条消息塞进**和真人消息同一个聚合窗口**(多人同时触发自动合并成一回合,不浪费 token),适合「用户没找到想要的东西 → 自动喊 AI 去找」这类场景。
|
|
16
|
+
- **跨会话通信**:`session_list` 列出全部会话(含潜在群);`session_wake(session_id 或 scope+peer_id, text, send_qq?, media?)` 向指定会话/群发消息并唤醒对方 LLM,可同时走 QQBot 通道发给人看,`media` 支持跨群发图
|
|
17
|
+
- **省 token 设计(v1.3.0)**:群历史行默认 `[昵称] 内容`(只有被 @ 的那条带 openid),`session_list` 默认"群名+尾号"(`full=true` 才给完整),`group_join_requests`/`list_stickers` 等列表默认 3~5 条并可翻页 —— 长聊天的上下文开销明显更小
|
|
18
|
+
- **入群申请的两种形态**:
|
|
19
|
+
- 会话流里出现【入群申请】= **消息注记**(静默记录、未唤醒你)——不用立刻行动,等主人下一条消息时自然接应
|
|
20
|
+
- 收到【审批轮询】= **系统唤醒**——主动 `group_join_requests` 查看新申请,把申请人+验证信息汇报给主人,等主人拍板再批/拒
|
|
21
|
+
- 消息里带 `@bot` = 有人 @ 本机器人(`@bot` 就是自己)
|
|
22
|
+
- 会话归属、工作区挂载等宿主问题已按官方机制修好(移植上游 PR #21,幂等、全 fail-soft)
|
|
23
|
+
- **官方接口文档**(写扩展、查事件字段/错误码、确认按钮与卡片字段时先看这里):QQ 机器人 api-v2 文档 <https://bot.q.qq.com/wiki/develop/api-v2/>
|
|
24
|
+
- **你可以自己写 markdown、自己写工具、自己写斜杠命令**(不用等人给你做功能):
|
|
25
|
+
- 卡片正文就是**你直接写的 markdown**(`#` 标题、加粗、``、代码块)——**本插件没有模板引擎,模板就是你**;要"带按钮的卡片"则走 botplay 事件或 dock 卡片编辑器(按钮回调须由 host 注册)。
|
|
26
|
+
- 工具/命令写在**账号数据目录**的 `.qqbot-extensions/{tools,commands}/`,**不在插件包内** → **升级/重装插件(换 node_modules)不会覆盖你的扩展**,扩展原样保留。
|
|
27
|
+
- 工具 `run(args, env)` 的 `env` 里有 `sender` + `replyTarget`(内置 `send_media` 用的同一个发送器),**工具能自己发 markdown 卡/图/语音/文件**:所以「调接口取数据 → 拼卡片 → 发出去」一个工具就能闭环,不必绕回你。用户说"给我写个点歌工具"时,照契约现场写即可。
|
|
28
|
+
- 生效方式:工具发 `/tools-reload`(或调 `tools_reload`)即时生效;命令需重启宿主;**同名工具改内容会被注册表跳过 → 换名或重启**。
|
|
29
|
+
|
|
30
|
+
> 🛡️ 仓库**不含**任何机器人凭据、图库数据、日志与个人路径(发布前已清理)。AppID/AppSecret 请走环境变量或 Web 面板注入,**不要提交进 git**。
|
|
31
|
+
|
|
32
|
+
## 架构
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
QQ 用户 → QQ WebSocket → dsh-im-qqbot → ctx.agents → dsh agent loop → LLM
|
|
36
|
+
↑ │
|
|
37
|
+
└── session/event ──────────┘
|
|
38
|
+
(assistant reply → QQ sendMarkdown)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 开发者:--patch 开发模式
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export QQBOT_APPID="你的AppID" QQBOT_SECRET="你的AppSecret"
|
|
45
|
+
npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## QQ 远程审批(可选)
|
|
49
|
+
|
|
50
|
+
当 Agent 的工具要访问**工作区之外**的位置时,dsh 会触发权限审批。开启后,机器人把审批请求**发到 QQ**(任务发起者的会话),你直接在 QQ 里放行/拒绝:
|
|
51
|
+
|
|
52
|
+
> ⚠️ **DSH 权限申请**
|
|
53
|
+
> 工具:pwsh
|
|
54
|
+
> 原因:需要访问工作区外路径
|
|
55
|
+
>
|
|
56
|
+
> 允许本次操作:`/approve A1B2C3`
|
|
57
|
+
> 拒绝本次操作:`/deny A1B2C3`
|
|
58
|
+
> 仅本次有效,120 秒后自动拒绝。
|
|
59
|
+
|
|
60
|
+
**启用**(二选一;保存即对新审批请求生效,无需重启):
|
|
61
|
+
- **Web 设置面板**:设置 →「QQ 机器人」→ ⑤ QQ 远程审批 → 勾选开启;
|
|
62
|
+
- 或 `cordis.patch.yml` 的实例 config 加两行后重启:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- id: im-qqbot
|
|
66
|
+
config:
|
|
67
|
+
enableApprovals: true # 默认 false
|
|
68
|
+
approvalTimeoutMs: 120000 # 等待时长, 超时自动拒绝
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**安全边界**:验证码一次性;仅"任务发起者本人 + 同一会话"可批(群聊里其他人看到验证码也无效);只授权当前这一次操作;Agent 取消或 dsh 退出自动取消。
|
|
72
|
+
|
|
73
|
+
> 思路来源: wang-22-code/dsh-qqbot-bridge 的 QQ 审批设计(宿主 dsh `approval/request` 标准事件,官方 dsh-acp / Web 审批弹窗同款机制)。
|
|
74
|
+
|
|
75
|
+
## QQ 群管理(可选)
|
|
76
|
+
|
|
77
|
+
机器人**为群管理员**时,可开启群管理能力:实时接收「入群申请」并自动提醒主人、按申请审批入群、查询/设置群成员禁言。所有操作走腾讯官方 GroupOpenMsg 接口,错误信息已做"人话"映射(如 11703=机器人不是该群管理员、40103004=不能禁言群主/管理员、11255=群已注销)。
|
|
78
|
+
|
|
79
|
+
**能力总开关**:
|
|
80
|
+
|
|
81
|
+
- **Web 设置面板**: 设置 →「QQ 机器人」→ ⑥ QQ 群管理 → 勾选开启,并填/选「默认管理群」;
|
|
82
|
+
- 或 `cordis.patch.yml` 的实例 config 加配置后重启:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- id: im-qqbot
|
|
86
|
+
config:
|
|
87
|
+
groupAdmin:
|
|
88
|
+
enabled: true
|
|
89
|
+
owners: [] # 主人 openid 白名单(空=不校验)
|
|
90
|
+
manageGroup: "群openid" # 对话内默认管理群(web/非群会话用; 群会话自动取当前群)
|
|
91
|
+
watchJoinRequests: true # 订阅入群申请事件(改后需重启: 涉及连接期 intents)
|
|
92
|
+
notifyInGroup: true # 收到申请时在群内发提醒
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> ⚠️ `watchJoinRequests` 需要连接期注册 intents(GROUP_MEMBER_EVENT, 1<<24)——**改它必须重启**,不是 live 热改;且需官方对该机器人开放对应能力,否则连接可能被拒(4914/4915)。
|
|
96
|
+
|
|
97
|
+
**入群审批怎么用**: 事件到达 → bot 在群里发一条提醒(含申请人昵称/验证语)→ 你在对话里说"通过/拒绝"(AI 调 `group_approve_join`)→ 官方落库审批。也可以在设置面板「⑥ QQ 群管理 → 入群审批」页看待审批清单手动批。
|
|
98
|
+
|
|
99
|
+
**按关键词自动审批(v1.3.0+)**: 你说「自动审批, 关键词=早饭」这类指令 → AI 调 `group_join_auto` 执行: 官方待审列表里**验证消息命中任一关键词的放行、未命中的拒绝**(`rejectUnmatched:false` 只放行不清场, `dry_run:true` 只预览); 同一人重复申请**去重后按最新一条判**, 拒绝理由中性(默认"答非所问")。⚠️ 仅在主人明确要求时使用。
|
|
100
|
+
|
|
101
|
+
**入群申请双通道(v1.1.4+)**:
|
|
102
|
+
- **消息注记** = QQ 实时事件, 静默 append 到目标会话(web 可见、不唤醒 AI), 格式【入群申请】;
|
|
103
|
+
- **系统提醒** = 轮询兜底, 唤醒 AI 起来处理, 格式【审批轮询】;
|
|
104
|
+
- 申请群=群组管理器(hub)会话时只在 hub 注记; 普通群按下面开关决定是否也注记/唤醒。
|
|
105
|
+
|
|
106
|
+
**群组管理四个独立开关(dock 设置, v1.1.4+)**: `唤醒AI`=唤醒群管会话 AI 处理; `注入群管会话`=hub web 注记(不唤醒); `通知普通群`=申请所在群 web 注记(不唤醒); `唤醒普通群AI`=唤醒申请所在群 AI——事件与轮询两条链路都生效, 互不干扰。
|
|
107
|
+
|
|
108
|
+
**跨群/跨会话工具**(v1.1.0+ / **v1.2.0 扩充**):
|
|
109
|
+
- `group_join_requests(gid=…)` / `group_approve_join(member_openids=…)`: 传 `gid` 可查询/审批**指定群**的入群申请(不限于当前会话群), 支持一次批量审批多人;
|
|
110
|
+
- `session_list` / `session_wake`: 列出全部会话(含群注册表里的"潜在群", 重启后仍可靠)供寻址 / 向指定会话发消息并唤醒对方 LLM(可带 `media` 跨群发图);
|
|
111
|
+
- **`broadcast_send`(v1.2.0)**: **一键群发** —— 同一段内容一次发到多个群/私聊, `targets` 直接写**分组名 / 群名 / 备注或 openid**; 走插件广播队列(串行+失败重试, 与 dock「📤 群发 · 广播」面板**同一份任务**), 默认直接发, 返回逐目标 `message_id`, 2 分钟内可 `action:"recall"` 撤回;
|
|
112
|
+
- **`target_group`(v1.2.0)**: **分组管理** —— list/create/rename/delete/add/remove, 读写的正是 dock「📇 群组管理 → 🗂 分组」那份数据 → **AI 与主人共用同一份分组**: 主人在面板分好组, AI 直接"发给群友"就能群发。
|
|
113
|
+
- **`group_join_auto`(v1.3.0)**: **按关键词自动审批入群申请** —— 命中任一关键词的**直接放行**、未命中的**直接拒绝**(不可逆, 可 `dry_run` 预览); 源数据是**官方待审列表**(非本地流水), 同一人重复申请**去重按最新一条判**; 仅在主人明确要求时使用。
|
|
114
|
+
|
|
115
|
+
**配置项**(Web 面板 ⑥ 可改, 见下表 `groupAdmin.*`)
|
|
116
|
+
|
|
117
|
+
## 🧠 智能回复(本地小模型 · 可选,省 token)(v1.4.5+)
|
|
118
|
+
|
|
119
|
+
让插件先用一个**跑在本机的小模型**给群消息打分(「价值评分」):分数低于门槛、又**没被 @** 的闲聊
|
|
120
|
+
**直接不唤醒 AI** → 那一轮 token 就省下来了;被 @ 的永远放行,**带图的消息一律放行**(群友发图多半是给她看的)。
|
|
121
|
+
|
|
122
|
+
- **模型**:`bge-small-zh-v1.5`(中文专训,ONNX 量化版 **≈ 23MB**,CPU 毫秒级,**零 token、完全离线**,不上传任何内容)
|
|
123
|
+
- **位置**:`{DSH_HOME | ~/.dsh}/models/bge-small-zh/`(用户级,跨工作区共用一份;也可在配置里指别的目录)
|
|
124
|
+
- **缺了也不影响使用**:检测不到模型就**自动静默关闭**评分,插件照常跑(只是不再省 token)
|
|
125
|
+
|
|
126
|
+
### ① 一条命令下载(推荐)
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
node scripts/download-model.mjs # 源码分发(在插件仓库根目录跑)
|
|
130
|
+
node node_modules/@zaofan/dsh-qqbot/scripts/download-model.mjs # npm 装的插件(npm 目录内)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
默认**优先走国内镜像** `hf-mirror.com`(失败自动换官方源),下载完会校验体积并打印后续步骤。可选参数:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
node scripts/download-model.mjs --dir "D:\models\bge-small-zh" # 换目录(填进插件配置 localModel.modelDir)
|
|
137
|
+
node scripts/download-model.mjs --source hf # 强制官方源
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### ② 手动下载(就三个文件)
|
|
141
|
+
|
|
142
|
+
把 `<源>` 换成 `https://hf-mirror.com` 或 `https://huggingface.co`,文件放到 `~/.dsh/models/bge-small-zh/`:
|
|
143
|
+
|
|
144
|
+
| 下载地址 | 存放位置 | 体积参考 |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `<源>/Xenova/bge-small-zh-v1.5/resolve/main/onnx/model_quantized.onnx` | `bge-small-zh/onnx/model_quantized.onnx` | ≈ 23MB |
|
|
147
|
+
| `<源>/Xenova/bge-small-zh-v1.5/resolve/main/tokenizer.json` | `bge-small-zh/tokenizer.json` | ≈ 430KB |
|
|
148
|
+
| `<源>/Xenova/bge-small-zh-v1.5/resolve/main/config.json` | `bge-small-zh/config.json` | < 1KB |
|
|
149
|
+
|
|
150
|
+
Windows PowerShell 例子:
|
|
151
|
+
|
|
152
|
+
```powershell
|
|
153
|
+
$dir = "$env:USERPROFILE\.dsh\models\bge-small-zh"
|
|
154
|
+
$base = "https://hf-mirror.com/Xenova/bge-small-zh-v1.5/resolve/main"
|
|
155
|
+
New-Item -ItemType Directory -Force "$dir\onnx" | Out-Null
|
|
156
|
+
Invoke-WebRequest "$base/onnx/model_quantized.onnx" -OutFile "$dir\onnx\model_quantized.onnx"
|
|
157
|
+
Invoke-WebRequest "$base/tokenizer.json" -OutFile "$dir\tokenizer.json"
|
|
158
|
+
Invoke-WebRequest "$base/config.json" -OutFile "$dir\config.json"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### ③ 交给 AI 做(把下面这段直接粘给你的 AI / dsh 里的她)
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
请帮我在本机装好 dsh 的 qqbot 插件要用的本地小模型(离线、不上传内容):
|
|
165
|
+
1. 下载 BAAI/bge-small-zh-v1.5 的三个文件到 `~/.dsh/models/bge-small-zh/`:
|
|
166
|
+
onnx/model_quantized.onnx(≈23MB)、tokenizer.json、config.json;
|
|
167
|
+
国内优先用 https://hf-mirror.com,失败再试 https://huggingface.co。
|
|
168
|
+
2. 目录结构必须是:<模型目录>/onnx/model_quantized.onnx、<模型目录>/tokenizer.json、<模型目录>/config.json
|
|
169
|
+
3. 下完自己校验:model_quantized.onnx ≥ 20MB、tokenizer.json ≥ 300KB;
|
|
170
|
+
也可以直接跑插件仓库里的一键脚本:`node scripts/download-model.mjs`
|
|
171
|
+
4. 最后告诉我:插件设置页「本地小模型」这一项该填什么、以及三个评分模式 off / log / block 分别什么行为。
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 评分模式怎么选
|
|
175
|
+
|
|
176
|
+
| 模式 | 行为 | 建议 |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `off` | 完全不评分,所有消息照常唤醒 | 不想掺和 |
|
|
179
|
+
| `log` | **只记分,不拦** | **观察期**:先跑几天,在面板「最近评分」看分准不准 |
|
|
180
|
+
| `block` | 低于门槛(默认 `0.5`)**不唤醒** | 正式使用、省 token |
|
|
181
|
+
|
|
182
|
+
- **会话级覆盖**:dock「⚙ 单会话设置」里可以**按群单独设**模式 / 门槛 / 开关(存 `settings.yaml` 的 `localModel.overrides`),**保存即时生效,无需重启**(v1.4.5 起)。
|
|
183
|
+
- **拦截边界**:被 @ 的永远放行;带图一律放行;**只有被拦在唤醒之前**(没花 token)的那次才会**回滚**本群的回复冷却 —— 冷却本来就是用来省 token 的,token 花了就不算白花。
|
|
184
|
+
- **评分记录**:`{dataRoot}/.qqbot/value-scores.jsonl`,一行一条,字段 `score / worth / gate / min / conf / mention / img / lib / agg / top`。面板能直读,也可以让 AI 读它来维护样例库(`{dataRoot}/.qqbot/value-samples.jsonl`:`{"m":"消息文本","y":1|0}`,y=1 表示"她会想接话")。
|
|
185
|
+
|
|
186
|
+
## 💗 好感度与熟识度(v1.4.7+)
|
|
187
|
+
|
|
188
|
+
两个维度**分开算、互不干扰**:
|
|
189
|
+
|
|
190
|
+
| | 🧠 熟识度 | 💗 好感度 |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| 问的是 | 她把你**记得多牢** | 她对你**什么态度** |
|
|
193
|
+
| 怎么来 | 客观计数(来过几天 / 消息数 / 被点名 / 接话)+ 记忆曲线 | 每次互动算一个"事件分",累积成 A 值 |
|
|
194
|
+
| 脾气 | 慢变;久不出现会淡忘,但有**下限**(老熟人不回陌生) | 可升可降,有上下限(范围随熟识度变宽) |
|
|
195
|
+
| 数据文件 | `{dataRoot}/.qqbot/affinity.json` | `{dataRoot}/.qqbot/attitude.json` |
|
|
196
|
+
|
|
197
|
+
### 判定怎么来的
|
|
198
|
+
|
|
199
|
+
- **内心 vs 表面**:她的**思考(reasoning)**判「亲近 / 拒绝 / 任务」;真正发出去的**正文**判「暖 / 冷 / 中性」。
|
|
200
|
+
- **扣不扣看内心,不看表面**:
|
|
201
|
+
- 心里是「亲近 / 任务」→ 表面再冷淡也**不扣**(那只是语气);
|
|
202
|
+
- 心里是「拒绝」→ 再看表面:**仍暖且够长 = 让步**(净正,心里不肯但还是照顾了);冷或过短 = 重罚。
|
|
203
|
+
- **对比放大**:对方夸她而她掉了好感 = 不领情 ×1.5;对方冷淡而她涨了 = 想讨好 ×1.5;两边同向 ×1.2。
|
|
204
|
+
- **把握不够就弃权**:本地小模型的相似度低于门槛时**不给标签、不动分** —— 宁可空着,也不给错分(擦线的判定比"判不出"危险得多)。
|
|
205
|
+
|
|
206
|
+
### 数值口径
|
|
207
|
+
|
|
208
|
+
- 范围 `R(F) = 1 + 2F`,阻尼 `k(F) = 1 / (1 + 3F)` —— 越熟,变化越慢、上下限越宽(F 为熟识度强度)。
|
|
209
|
+
- **好感偏移 = +0.1 × 好感占比**(占比 = A ÷ R),**加在消息分数上**:亲近加分、冷淡减分;⚠️ **门槛始终是你设的那个值**,好感度从不偷偷改它。
|
|
210
|
+
- **聚合消息按加权平均**综合判断:`综合分 = Σ(权 × 有效分) / Σ权`,其中 `权 = 价值分 × (1 + 好感占比) × (被点名 ? 2 : 1)`;归属取**权重最大的那条**(谁贡献最大记谁头上),避免"用别人的语气、扣别人的分"。
|
|
211
|
+
- **事件按回合结算**:一个回合里她可能想好几步,只结算**一次**(用整回合的思考 + 整回合真正发出的正文),不会因为中间步骤没说话就被判"敷衍"。
|
|
212
|
+
|
|
213
|
+
### 面板与导出
|
|
214
|
+
|
|
215
|
+
- 面板「🧠 熟识度 | 💗 好感度」分列显示、图标化:熟识 `👤陌生人 / 👋眼熟 / 🤝熟人`,好感 `💖很亲近 / 💗亲近 / 😐中立 / 🧊冷淡 / ❄️疏远`。
|
|
216
|
+
- **📊 导出 Excel**:一键下载 xlsx —— 四张工作表(**总览** / 熟识度 / 好感度 / 口径说明),总览把两个维度按人并成一行,分享给别人也看得懂。
|
|
217
|
+
- 鼠标悬停能看明细:聚合那一行会摊开**每一票**(价值分 × 权重 × 好感占比)。
|
|
218
|
+
|
|
219
|
+
### 红线
|
|
220
|
+
|
|
221
|
+
负好感**只退礼貌档**:少主动、少自作主张,**绝不冷落、阴阳、攻击**。任何"行为侧"的接线都必须一次一档、可一键回滚,默认只观察不生效。
|
|
222
|
+
|
|
223
|
+
## 配置项
|
|
224
|
+
|
|
225
|
+
| 配置 | 类型 | 默认值 | 说明 |
|
|
226
|
+
|------|------|--------|------|
|
|
227
|
+
| `appId` | string | **必填** | QQ Bot AppID(或通过 `QQBOT_APPID` 环境变量) |
|
|
228
|
+
| `appSecret` | string | **必填** | QQ Bot AppSecret(或通过 `QQBOT_SECRET` 环境变量) |
|
|
229
|
+
| `provider` | string | `deepseek-official` | LLM 提供商名称 |
|
|
230
|
+
| `model` | string | `deepseek-chat` | 模型名称 |
|
|
231
|
+
| `preset` | string | - | Agent preset id |
|
|
232
|
+
| `cwd` | string | `process.cwd()` | Agent 工作目录(**不是**数据目录) |
|
|
233
|
+
| `dataRoot` | string | `{cwd}/dshqqbot` | 插件数据根(表情包 / `.qqbot` / `.qqbot-extensions`)。**默认就在 `{cwd}/dshqqbot`** —— 工作目录保持干净; 想放别处显式配置; 老数据首次启动自动迁入 |
|
|
234
|
+
| `requireMention` | boolean | `true` | 群聊是否需要 @bot 才触发 |
|
|
235
|
+
| `groupPrompt` | string | - | 群聊额外 system prompt |
|
|
236
|
+
| `directPrompt` | string | - | 私聊额外 system prompt |
|
|
237
|
+
| `textChunkLimit` | number | `4500` | 单条消息最大字符数 |
|
|
238
|
+
| `sessionIdleTimeout` | number | `1800000` | 会话闲置超时(ms),默认 30 分钟 |
|
|
239
|
+
| `debug` | boolean | `false` | 调试模式 |
|
|
240
|
+
| `groupAdmin.enabled` | boolean | `false` | 群管理总开关(需机器人为群管理员) |
|
|
241
|
+
| `groupAdmin.owners` | string[] | `[]` | 可操作群管理的主人 openid 白名单(空=不校验) |
|
|
242
|
+
| `groupAdmin.manageGroup` | string | `''` | 对话内默认管理群 openid(web/非群会话时用) |
|
|
243
|
+
| `groupAdmin.watchJoinRequests` | boolean | `false` | 订阅入群申请事件(改后需重启) |
|
|
244
|
+
| `groupAdmin.notifyInGroup` | boolean | `true` | 收到申请时在群内发提醒 |
|
|
245
|
+
| `messageReference` | boolean | `true` | 引用消息总开关(v1.4.1): 开=入站消息带**短消息号** + 引用附原文 + 注入引用指令, AI 可用 `[rf:短号]` 引用对方; 关=完全不注入 |
|
|
246
|
+
|
|
247
|
+
> 🔧 新版 Web 面板把群管理单开成「⑥ QQ 群管理」卡片(入群审批/禁言/成员信息/黑名单), 与上方 `groupAdmin.*` 配置同一份数据。
|
|
248
|
+
|
|
249
|
+
## 内置命令
|
|
250
|
+
|
|
251
|
+
在 QQ 群里直接发(无需 @ 机器人;走 SDK 直通,不占用 AI 回合):
|
|
252
|
+
|
|
253
|
+
| 命令 | 说明 |
|
|
254
|
+
|------|------|
|
|
255
|
+
| `/outmode` | 查看当前出站模式与四档说明 |
|
|
256
|
+
| `/outmode adaptive` | 切到 **适配主动**(默认): 收到真人消息前5条带引用回你, 之后自动转独立消息, 连发不被吞 |
|
|
257
|
+
| `/outmode detail` | 切到 **详细主动**(v1.2.0): 聊天同"适配主动", 但**额外把 AI 的工具调用/结果也推到 QQ**(看进度用, 消息会变多) |
|
|
258
|
+
| `/outmode passive` | 切到 **被动**: 始终回复你那条(连发约4~5条后被QQ吞) |
|
|
259
|
+
| `/outmode silent` | 切到 **完全不出站**: 她照常思考但不向QQ发任何回复(web可对话) |
|
|
260
|
+
| `/outmode nothink` | 切到 **完全不思考**: QQ入站不唤醒AI, 消息只记录(逃生通道, 可随时切回) |
|
|
261
|
+
| `/bot-reset` | 重置当前会话(清除上下文) |
|
|
262
|
+
| `/bot-new` | 开启新会话(保留旧会话历史);若旧档已损坏/无法加载,自动另起新档(可在 QQ 上直接弃掉炸掉的会话) |
|
|
263
|
+
| `/bot-model` / `/model` | 查看或切换模型(如 `/bot-model deepseek-official/deepseek-v4-flash`) |
|
|
264
|
+
| `/bot-status` | 查看当前会话状态 |
|
|
265
|
+
| `/bot-ping` | 连通性测试 |
|
|
266
|
+
| `/bot-version` | 查看版本与当前模型 |
|
|
267
|
+
| `/bot-stop` | 中止当前正在生成的内容 |
|
|
268
|
+
| `/bot-restart` | 自重启 dsh 宿主(约4秒, 期间短暂离线, 自动拉起) |
|
|
269
|
+
| `/botplay` | 出互动事件目录卡(点事件直接触发, 自动翻页); `/botplay 事件名` 直接触发(如 `/botplay 签到`) |
|
|
270
|
+
| `/perm` | 切换权限档: `/perm` 查看; `/perm 只读\|工作区\|全权` 切换(即时生效) |
|
|
271
|
+
| `/new [preset]` | 以指定人格开新会话(旧会话存档可回看); `/presets` 看可用人格 |
|
|
272
|
+
| `/答 <内容>` / `/ans` | **回答提问卡片**(v1.2.0): 按钮点不动或想自己打字时用 —— `/答 A`(选第1个)、`/答 1 3`(多选)、`/答 #2 B`(多个提问时指定第2问)、`/答 你的话`(不是选项 → 当自由回答原样转给 AI) |
|
|
273
|
+
| `/bot-help` | 查看所有指令 |
|
|
274
|
+
| `/tools-reload` | 热刷新 QQ 通道工具(开发用, 新工具无需重启即可用) |
|
|
275
|
+
|
|
276
|
+
> 💡 `/outmode` 是她的"逃生开关":即使处于 nothink(完全不思考)状态,SDK 直通命令也能把她唤醒——在 QQ 里发 `/outmode adaptive` 即可。
|
|
277
|
+
|
|
278
|
+
## 用户扩展(自定义斜杠命令 / QQ 工具)(v0.9.8+)
|
|
279
|
+
|
|
280
|
+
> 给"用户自己 + AI 自己"写扩展用的。写在**账号数据目录的扩展区**(默认=账号工作目录 cwd;
|
|
281
|
+
> 若账号配置了 `dataRoot`, 则在 `{dataRoot}/.qqbot-extensions`), 不碰插件本体——
|
|
282
|
+
> 以后升级插件(换 node_modules)不会覆盖你的扩展。扩展=可执行 JS, 只在你自己的机器上跑。
|
|
283
|
+
> 查看当前目录: dock 账号列表会显示该账号的"数据目录"。
|
|
284
|
+
|
|
285
|
+
### 目录结构(每账号独立)
|
|
286
|
+
```
|
|
287
|
+
<数据目录>/
|
|
288
|
+
├── 表情包/ # 图库(若配置了 dataRoot, 如 cwd/dshqqbot/表情包)
|
|
289
|
+
├── .qqbot/ # 台账/定时/审批(如 cwd/dshqqbot/.qqbot)
|
|
290
|
+
└── .qqbot-extensions/
|
|
291
|
+
├── commands/ # 自定义斜杠命令(重启后生效)
|
|
292
|
+
└── tools/ # 自定义 QQ 通道工具(AI 可调; 写完用 /tools-reload 或让 AI 调 tools_reload 热刷)
|
|
293
|
+
```
|
|
294
|
+
数据目录 = `dataRoot`(已配置, 例 `D:\my-projects\qqbot-data`)或账号 cwd(未配置时, 向后兼容)。
|
|
295
|
+
|
|
296
|
+
### 自定义斜杠命令: .qqbot-extensions/commands/xxx.mjs
|
|
297
|
+
```js
|
|
298
|
+
export default {
|
|
299
|
+
name: ['hello', '你好'], // 命令名(可别名数组); QQ 群发 /hello 或 /你好 触发
|
|
300
|
+
description: '打招呼(示例)',
|
|
301
|
+
usage: '/hello [名字]',
|
|
302
|
+
handler: (ctx) => `👋 你好 ${ctx.command.raw || ''}`.trim(), // 返回文本即回复
|
|
303
|
+
};
|
|
304
|
+
```
|
|
305
|
+
改完**重启宿主**(`/bot-restart`)生效, 或直接问 AI(它知道规则)。
|
|
306
|
+
|
|
307
|
+
### 自定义 QQ 工具: .qqbot-extensions/tools/xxx.mjs
|
|
308
|
+
```js
|
|
309
|
+
export default {
|
|
310
|
+
name: 'roll_dice',
|
|
311
|
+
description: '掷一颗 N 面骰子, 返回点数',
|
|
312
|
+
inputSchema: { // ⚠️ 可选参数不要写 required; 必填才写 required: true
|
|
313
|
+
sides: { type: 'integer', description: '骰子面数, 默认 6' },
|
|
314
|
+
},
|
|
315
|
+
// env: { cwd, manager, sender, replyTarget, exec } —— sender/replyTarget 可发 QQ 消息
|
|
316
|
+
run: async (args, env) => {
|
|
317
|
+
const sides = Math.max(2, Math.min(1000, Math.round(Number(args.sides) || 6)));
|
|
318
|
+
return { ok: true, msg: `🎲 ${1 + Math.floor(Math.random() * sides)}` };
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
```
|
|
322
|
+
写完在 QQ 里发 `/tools-reload`(或直接让 AI 调 `tools_reload` 工具)即可用, 无需重启。
|
|
323
|
+
|
|
324
|
+
### 给 AI 的要点(让 AI 帮用户写扩展时照此办)
|
|
325
|
+
1. 命令/工具文件都放**账号数据目录**的 `.qqbot-extensions/` 下(dataRoot 优先, 无则 cwd), 别放插件包内。
|
|
326
|
+
→ **升级/重装插件(换 node_modules)只动插件本体, 不会覆盖扩展目录**, 用户的扩展永久保留。
|
|
327
|
+
2. 工具入参 schema 用 JSON Schema 风格; **可选参数不带 required 字段**。
|
|
328
|
+
3. `run(args, env)` 的 `env = { cwd, manager, sender, replyTarget, exec }`:
|
|
329
|
+
- `sender` + `replyTarget` 就是内置 `send_media` 用的发送器 → **工具可以自己发 markdown 卡片 / 图片 / 语音 / 文件**, 不用把内容再交回 AI。
|
|
330
|
+
- 工具返回 `{ ok, msg }`(msg 作为工具结果回给 AI); 命令返回纯文本。
|
|
331
|
+
4. 卡片正文由**你(AI)直接写 markdown**(标题/加粗/``/代码块), **本插件没有模板引擎, 不需要也不会用配置型模板**。
|
|
332
|
+
5. 生效方式: 工具发 `/tools-reload` 或调 `tools_reload` —— 新工具即时生效; **同名工具改内容会被工具注册表跳过(`already registered`) → 换名或重启宿主**; 命令一律需重启宿主(`/bot-restart`)。
|
|
333
|
+
6. 能力边界: **扩展工具无法注册"按钮点击回调"** —— 按钮回调只能由 host 侧的 botplay 事件 / dock 卡片编辑器注册。纯扩展方案的交互范式 = "卡片 + 用户回个编号", 由 AI 当状态机再调一次工具。
|
|
334
|
+
7. 示范(点歌): ①工具里 fetch 搜索接口 → ②拼一段 markdown(封面/歌名/歌手/歌词) → ③`sender.sendMarkdown(replyTarget, 卡片)` → ④要试听就 `sender.sendMedia(...)` → ⑤返回 `{ok:true,msg:'已发卡'}`。
|
|
335
|
+
8. **接口字段别猜**: 写扩展遇到不确定的官方字段/事件/错误码, 先查官方 api-v2 文档 <https://bot.q.qq.com/wiki/develop/api-v2/> (按钮 `action.type` 0=跳转/1=回调/2=指令、键盘 5 行上限、错误码等都在里面), 不要凭印象写。
|
|
336
|
+
|
|
337
|
+
## 富媒体指令(AI 回复里写标记,自动变成真消息)
|
|
338
|
+
|
|
339
|
+
让 AI(或你替她)在回复正文里写以下标记,插件会自动拆出来发成真实的 QQ 消息,**标记本身不会显示**:
|
|
340
|
+
|
|
341
|
+
| 标记 | 效果 | 示例 |
|
|
342
|
+
|------|------|------|
|
|
343
|
+
| `[MEDIA:image\|来源]` | 发图片(本地路径或 http(s) 链接) | `[MEDIA:image\|D:\pics\kiss.jpg]` / `[MEDIA:image\|https://…/a.png]` |
|
|
344
|
+
| `[MEDIA:voice\|来源]` | 发语音(仅支持本地路径或 QQ 可拉取的链接) | `[MEDIA:voice\|D:\audio\hi.silk]` |
|
|
345
|
+
| `[MEDIA:video\|来源]` | 发视频 | `[MEDIA:video\|D:\videos\clip.mp4]` |
|
|
346
|
+
| `[MEDIA:file\|来源]` | 发文件 | `[MEDIA:file\|D:\docs\计划.pdf]` |
|
|
347
|
+
| `[RECALL]` | 撤回自己刚发的那条消息 | 单独一行写 `[RECALL]` |
|
|
348
|
+
| `[RECALL:N]` | 撤回自己发的倒数第 N 条 | 如 `[RECALL:2]` 撤倒数第二条 |
|
|
349
|
+
| `[rf:短号]` | **引用**某条消息(以引用气泡形式发出) | `[rf:0913a]` 引用短号 `0913a` 那条 |
|
|
350
|
+
|
|
351
|
+
要点:
|
|
352
|
+
- 图片/文件可用**本机绝对路径**或**网络 URL**;语音本地路径若为 QQ SILK 格式也能转码发送。
|
|
353
|
+
- ≥5MB 的本地大文件(视频/压缩包…)自动转后台分片上传,不阻塞对话。
|
|
354
|
+
- 一次回复可混用多条 `[MEDIA:]`,配合长文本拆条连发使用。
|
|
355
|
+
- 这些是"AI 会自己写"的暗号——正常聊天时她收到"发个开心点的图"这类指令,会自己调工具完成,不需要你手动写标记。
|
|
356
|
+
|
|
357
|
+
### 引用消息(v1.4.1,默认开)
|
|
358
|
+
|
|
359
|
+
每条入站消息都带一个**短消息号**(形如 `#0913a` = 月日 + 流水号):
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
[做早饭 (E9020753…) #0913a] 帮我看看这个
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- AI 想引用某条消息时,在回复正文里写 `[rf:0913a]`,这条回复就会以**引用气泡**发出(对方能看到"她引用了这条")。
|
|
366
|
+
- 别人引用某条消息时,**被引用原文**会一并进上下文(`[Quoted message begins] … [Quoted message ends]`),AI 能读懂"他在回哪句话"。
|
|
367
|
+
- 短号 ↔ 完整 msg_id 的对应关系存在本地台账 `{dataRoot}/.qqbot/msg-index/{群|私聊}/refs.json`(**按会话分文件夹**,每份上限 500 条,超出丢最旧;带时间与发送者昵称,可直接打开查)。
|
|
368
|
+
- 好处:长 msg_id(60+ 字符)不再进上下文,**短号只花 1-2 token**。
|
|
369
|
+
- 关闭:设置面板 ③ 区块去掉「引用消息」勾选 → 不登记台账、不注入短号、不注入引用指令。
|
|
370
|
+
|
|
371
|
+
## 核心模块
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
src/
|
|
375
|
+
├── index.ts # Cordis 插件入口(async apply)
|
|
376
|
+
├── config.ts # 配置 Schema
|
|
377
|
+
├── types.ts # 全局类型定义
|
|
378
|
+
├── setup.ts # 凭据绑定(扫码)
|
|
379
|
+
├── transport/ # 传输层
|
|
380
|
+
│ ├── inbound.ts # QQ 入站消息 → agent.followup()
|
|
381
|
+
│ ├── outbound.ts # session/event → QQ sendMarkdown
|
|
382
|
+
│ ├── outbound-buffer.ts # 流式缓冲
|
|
383
|
+
│ └── chunker.ts # Markdown 文本切分
|
|
384
|
+
├── session/ # 会话管理层
|
|
385
|
+
│ ├── session-manager.ts # QQ peer → Agent 映射
|
|
386
|
+
│ └── idle-evictor.ts # 闲置回收
|
|
387
|
+
├── model/ # 模型路由层
|
|
388
|
+
│ ├── model-resolver.ts # 路由解析
|
|
389
|
+
│ ├── prefs-store.ts # per-peer 偏好持久化
|
|
390
|
+
│ └── settings-reader.ts # settings.yaml 只读
|
|
391
|
+
├── shared/ # 共享工具
|
|
392
|
+
│ ├── utils.ts # 通用函数
|
|
393
|
+
│ ├── scope.ts # scope/peer 提取
|
|
394
|
+
│ └── send-helper.ts # 分块发送
|
|
395
|
+
├── commands/ # 斜杠命令
|
|
396
|
+
└── typings/ # 外部模块声明
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## 会话路由
|
|
400
|
+
|
|
401
|
+
sessionKey: `qqbot:${appId}:${scope}:${peerId}`,由 SHA-256 确定性派生 SessionId,重启后可恢复。
|
|
402
|
+
|
|
403
|
+
解析策略:进程内复用 → 持久化恢复 → 全新创建。
|
|
404
|
+
|
|
405
|
+
## 设计原则
|
|
406
|
+
|
|
407
|
+
- **纯 Cordis 插件** — 遵循 dsh "Plugins, not loop changes" 原则
|
|
408
|
+
- **声明式依赖** — `inject = ['agents']`,不直接耦合其他插件
|
|
409
|
+
- **会话隔离** — 每个 QQ 私聊用户/群聊各一个独立 Agent
|
|
410
|
+
- **Preset 支持** — 可通过 `agent-presets` 服务挂载预设(工具集、prompt 等)
|
|
411
|
+
- **闲置回收** — 超时自动 dispose Agent,防止内存泄漏
|
|
412
|
+
- **Markdown 输出** — 回复以 Markdown 格式发送,支持代码块/表格感知切分
|
|
413
|
+
|
|
414
|
+
## 本地开发
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
# 安装依赖
|
|
418
|
+
pnpm install
|
|
419
|
+
|
|
420
|
+
# 构建
|
|
421
|
+
pnpm build
|
|
422
|
+
|
|
423
|
+
# 开发模式(watch)
|
|
424
|
+
pnpm dev
|
|
425
|
+
|
|
426
|
+
# 用 --patch 方式调试
|
|
427
|
+
export QQBOT_APPID="xxx" QQBOT_SECRET="xxx"
|
|
428
|
+
npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
|
|
429
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaofan/dsh-qqbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"license": "MIT",
|
|
58
58
|
"files": [
|
|
59
59
|
"dist",
|
|
60
|
+
"docs/USER-GUIDE.md",
|
|
60
61
|
"client",
|
|
61
62
|
"cordis.patch.yml",
|
|
62
63
|
"settings-host.js",
|
package/settings-host.js
CHANGED
|
@@ -982,11 +982,26 @@ export function apply(ctx) {
|
|
|
982
982
|
const mod = await import('./dist/api/group-admin.js');
|
|
983
983
|
return { bot, client: mod.createGroupAdmin({ appId: bot.appId, appSecret: bot.appSecret }) };
|
|
984
984
|
}
|
|
985
|
+
/** 已判定「群不存在」的 gid → 判定时刻(死群退避用,24h TTL;内存即可,重启重探一次) */
|
|
986
|
+
const DEAD_GROUP_CACHE = new Map();
|
|
987
|
+
/**
|
|
988
|
+
* 审计日志滚动上限(2026-09-14 补):原来是纯 append,没有上限 ——
|
|
989
|
+
* 面板群列表接口每刷一次就要为**每个已死的群**写一条 group.dead(实测 9 天 4.2 万行 / 7.5MB)。
|
|
990
|
+
* 这里加**大小上限**:超过 2MB 就只保留最近 2000 行,任何一类事件刷屏都挡得住。
|
|
991
|
+
*/
|
|
992
|
+
const AUDIT_MAX_BYTES = 2 * 1024 * 1024;
|
|
993
|
+
const AUDIT_KEEP_LINES = 2000;
|
|
985
994
|
function audit(cwd, entry) {
|
|
986
995
|
try {
|
|
987
996
|
mkdirSync(join(cwd, '.qqbot'), { recursive: true });
|
|
988
997
|
const af = join(cwd, '.qqbot', 'group-audit.jsonl');
|
|
989
998
|
writeFileSync(af, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', { flag: 'a' });
|
|
999
|
+
try {
|
|
1000
|
+
if (statSync(af).size > AUDIT_MAX_BYTES) {
|
|
1001
|
+
const lines = readFileSync(af, 'utf8').split('\n').filter(Boolean);
|
|
1002
|
+
writeFileSync(af, lines.slice(-AUDIT_KEEP_LINES).join('\n') + '\n', 'utf8');
|
|
1003
|
+
}
|
|
1004
|
+
} catch { /* 压缩失败不影响主链 */ }
|
|
990
1005
|
} catch { /* ignore */ }
|
|
991
1006
|
}
|
|
992
1007
|
function readGroupsJson(cwd) {
|
|
@@ -1161,6 +1176,11 @@ export function apply(ctx) {
|
|
|
1161
1176
|
const aliveReg = {};
|
|
1162
1177
|
if (gc) {
|
|
1163
1178
|
for (const g of [...map.values()].sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0))) {
|
|
1179
|
+
// 死群退避(2026-09-14 补):已判「群不存在」的 gid 24h 内不再重复校验 ——
|
|
1180
|
+
// 原来这里每次都全量校验,面板每刷一次就为每个死群写一条 group.dead(实测 9 天 4.2 万行)。
|
|
1181
|
+
// 命中缓存直接跳过(**也不写审计**),过 24h 再探一次(万一群复活/又被拉回去)。
|
|
1182
|
+
const deadAt = DEAD_GROUP_CACHE.get(g.gid);
|
|
1183
|
+
if (deadAt && Date.now() - deadAt < 24 * 3600_000) continue;
|
|
1164
1184
|
try {
|
|
1165
1185
|
const info = await gc.client.getGroupInfo(g.gid);
|
|
1166
1186
|
if (info.ok && info.data) {
|
|
@@ -1170,6 +1190,7 @@ export function apply(ctx) {
|
|
|
1170
1190
|
aliveReg[g.gid] = { name: official || (reg[g.gid] && reg[g.gid].name) || '', lastAt: (reg[g.gid] && reg[g.gid].lastAt) || Date.now() };
|
|
1171
1191
|
} else {
|
|
1172
1192
|
// 11255 等 = 群已注销/不存在 → 不返回给 UI(避免选中后调用报错), 仅保留审计
|
|
1193
|
+
DEAD_GROUP_CACHE.set(g.gid, Date.now());
|
|
1173
1194
|
audit(bot.cwd, { ev: 'group.dead', ns: bot.id, gid: g.gid, code: info.err && info.err.code, human: info.err && info.err.human });
|
|
1174
1195
|
}
|
|
1175
1196
|
} catch (e2) {
|
|
@@ -1293,6 +1314,139 @@ export function apply(ctx) {
|
|
|
1293
1314
|
writeJson(res, 200, { ok: true, done, modelDir: dir });
|
|
1294
1315
|
} catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
|
|
1295
1316
|
});
|
|
1317
|
+
// 导出 Excel(2026-09-14 主人:"好感度熟识度可以一键导出 excel 表格,i need to share")
|
|
1318
|
+
// 零依赖:xlsx 由 dist/features/xlsx.js 现场拼(见那个文件头:不为了导出拖进 exceljs)
|
|
1319
|
+
// 三张表:熟识度 / 好感度 / 口径说明(分享给别人时看得懂)
|
|
1320
|
+
route(ctx, 'GET', '/api/qqbot-settings/export.xlsx', async (req, res) => {
|
|
1321
|
+
try {
|
|
1322
|
+
const u = new URL(req.url ?? '/', 'http://x');
|
|
1323
|
+
const bot = nsBot(NSQ(u));
|
|
1324
|
+
const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot) ? bot.cfg.dataRoot : ((bot && bot.cwd) || '');
|
|
1325
|
+
if (!dataRoot) return writeJson(res, 400, { ok: false, error: '找不到数据根目录' });
|
|
1326
|
+
const [{ buildXlsx }, affMod, attMod] = await Promise.all([
|
|
1327
|
+
import('./dist/features/xlsx.js'),
|
|
1328
|
+
import('./dist/features/local-signals.js'),
|
|
1329
|
+
import('./dist/features/attitude.js'),
|
|
1330
|
+
]);
|
|
1331
|
+
const limit = Math.max(1, Math.min(500, Number(u.searchParams.get('limit')) || 200));
|
|
1332
|
+
const aff = affMod.topAffinity(dataRoot, limit) || [];
|
|
1333
|
+
const att = attMod.topAttitude(dataRoot, limit) || [];
|
|
1334
|
+
const fmt = (ts) => (ts ? new Date(ts).toLocaleString('zh-CN') : '');
|
|
1335
|
+
const shortId = (k) => String(k || '').replace(/^(person|group|c2c):/, '');
|
|
1336
|
+
|
|
1337
|
+
const rows1 = [['#', '昵称', 'openid', '熟识度', '档位', '来过(天)', '消息数', '被点名', '接话', '最近活跃']];
|
|
1338
|
+
aff.forEach((x, i) => rows1.push([i + 1, x.name || '', shortId(x.key), x.score ?? '', x.tier || '', x.reviews ?? 0, x.msgs ?? 0, x.mentions ?? 0, x.replies ?? 0, fmt(x.lastAt)]));
|
|
1339
|
+
|
|
1340
|
+
const rows2 = [['#', '昵称', 'openid', '好感度', '档位', '好感占比', '事件数', '最近一次涨跌原因', '最近活跃']];
|
|
1341
|
+
att.forEach((x, i) => {
|
|
1342
|
+
let tier = '';
|
|
1343
|
+
let ratio = '';
|
|
1344
|
+
try {
|
|
1345
|
+
const g = attMod.attitudeGateFor(dataRoot, x.key);
|
|
1346
|
+
tier = g.tier;
|
|
1347
|
+
ratio = Math.round(g.ratio * 1000) / 1000;
|
|
1348
|
+
} catch { /* 算不出留空 */ }
|
|
1349
|
+
rows2.push([i + 1, x.name || '', shortId(x.key), x.a ?? '', tier, ratio, x.events ?? 0, x.lastWhy || '', fmt(x.lastAt)]);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// 总览:两个维度**按人合并成一行**放第一张(2026-09-14 主人打开文件问"好感度呢?"——
|
|
1353
|
+
// 分表藏在底部标签页里容易漏看;分享场景下,一张总表最直观)
|
|
1354
|
+
const attByKey = new Map(att.map((x) => [x.key, x]));
|
|
1355
|
+
const seenKeys = new Set();
|
|
1356
|
+
const merged = [];
|
|
1357
|
+
for (const x of aff) { merged.push({ key: x.key, name: x.name, aff: x, att: attByKey.get(x.key) }); seenKeys.add(x.key); }
|
|
1358
|
+
for (const x of att) { if (!seenKeys.has(x.key)) merged.push({ key: x.key, name: x.name, aff: undefined, att: x }); }
|
|
1359
|
+
merged.sort((a, b) => (b.att?.a ?? -99) - (a.att?.a ?? -99));
|
|
1360
|
+
const rows0 = [['#', '昵称', 'openid', '熟识度', '熟识档位', '好感度', '好感档位', '好感占比', '来过(天)', '消息数', '被点名', '接话', '事件数', '最近一次涨跌原因', '最近活跃']];
|
|
1361
|
+
merged.forEach((m, i) => {
|
|
1362
|
+
let tier = '';
|
|
1363
|
+
let ratio = '';
|
|
1364
|
+
if (m.att) {
|
|
1365
|
+
try {
|
|
1366
|
+
const g = attMod.attitudeGateFor(dataRoot, m.key);
|
|
1367
|
+
tier = g.tier;
|
|
1368
|
+
ratio = Math.round(g.ratio * 1000) / 1000;
|
|
1369
|
+
} catch { /* 算不出留空 */ }
|
|
1370
|
+
}
|
|
1371
|
+
rows0.push([i + 1, m.name || '', shortId(m.key), m.aff?.score ?? '', m.aff?.tier ?? '', m.att?.a ?? '', tier, ratio,
|
|
1372
|
+
m.aff?.reviews ?? '', m.aff?.msgs ?? '', m.aff?.mentions ?? '', m.aff?.replies ?? '', m.att?.events ?? '', m.att?.lastWhy ?? '',
|
|
1373
|
+
fmt(m.att?.lastAt || m.aff?.lastAt)]);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
const rows3 = [
|
|
1377
|
+
['一键导出 · 口径说明', ''], ['生成时间', new Date().toLocaleString('zh-CN')],
|
|
1378
|
+
['熟识度', '她把你记得多牢 —— 客观计数 + 记忆曲线,慢变(来过几天、说了多少、被点名、接话)'],
|
|
1379
|
+
['好感度', '她对你什么态度 —— 随事件可升可降(内心倾向判「亲近/拒绝/任务」+ 正文情绪判「暖/冷/中性」)'],
|
|
1380
|
+
['“最近一次涨跌原因”', '形如「内心亲近 +0.5」「内心拒绝 −0.5 / 又烦又冷(重罚) −0.5」「两好相凑 ×1.2」'],
|
|
1381
|
+
['判定把握', '本地小模型对不上号时会**弃权**(不给标签、不动分),所以有些格子是空的'],
|
|
1382
|
+
['红线', '负好感只退礼貌档:少主动,绝不冷落、阴阳、攻击'],
|
|
1383
|
+
['来源', 'dsh-qqbot 插件 · 数据文件 .qqbot/affinity.json 与 attitude.json'],
|
|
1384
|
+
];
|
|
1385
|
+
|
|
1386
|
+
const buf = buildXlsx([
|
|
1387
|
+
{ name: '总览', rows: rows0, widths: [5, 18, 34, 9, 10, 9, 10, 10, 9, 9, 9, 8, 9, 44, 20] },
|
|
1388
|
+
{ name: '熟识度', rows: rows1, widths: [5, 18, 34, 10, 10, 10, 9, 9, 8, 20] },
|
|
1389
|
+
{ name: '好感度', rows: rows2, widths: [5, 18, 34, 10, 10, 11, 9, 44, 20] },
|
|
1390
|
+
{ name: '说明', rows: rows3, widths: [20, 90] },
|
|
1391
|
+
]);
|
|
1392
|
+
const when = new Date();
|
|
1393
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
1394
|
+
const cn = `熟识度好感度_${when.getFullYear()}${p2(when.getMonth() + 1)}${p2(when.getDate())}_${p2(when.getHours())}${p2(when.getMinutes())}.xlsx`;
|
|
1395
|
+
res.writeHead(200, {
|
|
1396
|
+
'content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1397
|
+
'content-disposition': `attachment; filename="qqbot-stats.xlsx"; filename*=UTF-8''${encodeURIComponent(cn)}`,
|
|
1398
|
+
'content-length': String(buf.length),
|
|
1399
|
+
'cache-control': 'no-store',
|
|
1400
|
+
});
|
|
1401
|
+
res.end(buf);
|
|
1402
|
+
audit(dataRoot, { ev: 'export.xlsx', ns: bot && bot.id, rows: aff.length + att.length });
|
|
1403
|
+
} catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// 好感度台账(2026-09-13 主人定, 观察期): 只读, 供面板显示"谁跟她最熟"
|
|
1407
|
+
// 数据源: {dataRoot}/.qqbot/affinity.json (每条群消息累计 互动/被点名/接话; 熟度现算, 公式透明)
|
|
1408
|
+
route(ctx, 'GET', '/api/qqbot-settings/affinity', async (req, res) => {
|
|
1409
|
+
try {
|
|
1410
|
+
const u = new URL(req.url ?? '/', 'http://x');
|
|
1411
|
+
const bot = nsBot(NSQ(u));
|
|
1412
|
+
const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot) ? bot.cfg.dataRoot : ((bot && bot.cwd) || '');
|
|
1413
|
+
if (!dataRoot) return writeJson(res, 200, { ok: true, items: [] });
|
|
1414
|
+
const mod = await import('./dist/features/local-signals.js');
|
|
1415
|
+
const limit = Math.max(1, Math.min(50, Math.round(Number(u.searchParams.get('limit'))) || 8));
|
|
1416
|
+
const items = (mod.topAffinity(dataRoot, limit) || []).map((x) => ({
|
|
1417
|
+
key: x.key, name: x.name || '', score: x.score, tier: x.tier || '', reviews: x.reviews || 0, msgs: x.msgs, mentions: x.mentions, replies: x.replies, lastAt: x.lastAt,
|
|
1418
|
+
}));
|
|
1419
|
+
writeJson(res, 200, { ok: true, items });
|
|
1420
|
+
} catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
// 好感度(2026-09-14 主人"直接推进"): A 值台账 —— 与「熟识度」是**两个维度**, 面板分两列
|
|
1424
|
+
// 熟识度 = 她把他记多牢(客观计数+记忆曲线, 慢变) | 好感度 = 她对他什么态度(随事件可升可降)
|
|
1425
|
+
// 数据源: {dataRoot}/.qqbot/attitude.json (内心倾向 + 正文情绪 → Δ → A 值, 见 features/attitude.ts)
|
|
1426
|
+
route(ctx, 'GET', '/api/qqbot-settings/attitude', async (req, res) => {
|
|
1427
|
+
try {
|
|
1428
|
+
const u = new URL(req.url ?? '/', 'http://x');
|
|
1429
|
+
const bot = nsBot(NSQ(u));
|
|
1430
|
+
const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot) ? bot.cfg.dataRoot : ((bot && bot.cwd) || '');
|
|
1431
|
+
if (!dataRoot) return writeJson(res, 200, { ok: true, items: [] });
|
|
1432
|
+
const mod = await import('./dist/features/attitude.js');
|
|
1433
|
+
const limit = Math.max(1, Math.min(50, Math.round(Number(u.searchParams.get('limit'))) || 8));
|
|
1434
|
+
const items = (mod.topAttitude(dataRoot, limit) || []).map((x) => {
|
|
1435
|
+
// 档位名(面板显示用,2026-09-14 主人要求):按"好感度占其范围的比例"分 很亲近/亲近/中立/冷淡/疏远
|
|
1436
|
+
let tier = '';
|
|
1437
|
+
let ratio = 0;
|
|
1438
|
+
try {
|
|
1439
|
+
const g = mod.attitudeGateFor(dataRoot, x.key);
|
|
1440
|
+
if (g) { tier = g.tier; ratio = Math.round(g.ratio * 1000) / 1000; }
|
|
1441
|
+
} catch { /* 算不出就不显示档位 */ }
|
|
1442
|
+
return {
|
|
1443
|
+
key: x.key, name: x.name || '', a: x.a, events: x.events || 0, lastAt: x.lastAt || 0, why: x.lastWhy || '', codes: x.lastCodes || [], tier, ratio,
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
writeJson(res, 200, { ok: true, items });
|
|
1447
|
+
} catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1296
1450
|
// 价值样例库读写(2026-09-13 主人定): {dataRoot}/.qqbot/value-samples.jsonl —— 用户可直接编辑
|
|
1297
1451
|
function valueSamplesPathOf(bot) {
|
|
1298
1452
|
const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot)
|