dsh-qqbot-expands 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +310 -2
- package/client.js +306 -0
- package/cordis.patch.yml +73 -0
- package/index.js +2348 -0
- package/package.json +48 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-qqbot-fixes contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,311 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-qqbot-expands
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness(DSH)插件:**在 QQ 官方机器人插件 `@tencent-connect/dsh-qqbot` 基础上的功能拓展**。
|
|
4
|
+
|
|
5
|
+
提供群聊频控、私聊主动问候、表情包、语音、绘图,以及一个统一设置页。
|
|
6
|
+
|
|
7
|
+
> 本插件不是官方插件,与腾讯、DeepSeek 均无隶属关系;它是运行在官方 QQ 机器人插件之上的扩展层,只使用 QQ 开放平台公开接口与 DSH 官方插件机制。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 合规声明(请先读)
|
|
12
|
+
|
|
13
|
+
本插件是**官方 QQ 机器人插件的功能补全**,不替换、不修改官方插件代码,所有消息收发仍由官方插件完成。设计上遵守 QQ 开放平台规范:
|
|
14
|
+
|
|
15
|
+
- **不伪造消息**:不合成 `msg_id` / `event_id`,不伪造平台事件,只处理平台真实下发的事件;
|
|
16
|
+
- **不绕过配额**:主动消息、被动回复窗口、频率限制都按平台规则走,本插件只做“少回”的收敛,不做“多回”的突破;
|
|
17
|
+
- **不读取无权限内容**:平台不下发的消息不抓取,群聊全量消息需要群主在群里开启对应权限后才生效;
|
|
18
|
+
- **不收集、不上传数据**:所有状态只落在本机 `~/.dsh-qqbot/`,不向任何第三方回传;
|
|
19
|
+
- **文件读取需管理员授权**:非管理员发送的图片/文件默认拦截,必须管理员明确同意后才读取;
|
|
20
|
+
- 使用前请确认你的机器人已按 QQ 开放平台要求完成注册、审核,并在群主/管理员授权范围内使用。
|
|
21
|
+
|
|
22
|
+
如果平台规则与本文冲突,以 QQ 开放平台规则为准。
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 前置依赖(重要)
|
|
27
|
+
|
|
28
|
+
| 依赖 | 是否必须 | 说明 |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| DSH(DeepSeek Harness) | **必须** | 插件运行在 DSH profile 中,建议 0.2.1 及以上 |
|
|
31
|
+
| `@tencent-connect/dsh-qqbot` | **必须** | **官方 QQ 机器人插件,必须已安装并完成绑定**;本插件不包含任何 QQ 协议实现,没有它无法工作 |
|
|
32
|
+
| `dsh-meme` | **表情包功能必须** | 提供 `send_meme` 与表情图库;不装则 `qqbot_send_meme` 找不到图库,表情包相关功能不可用 |
|
|
33
|
+
| 外接 TTS / ASR 服务 | 语音功能可选 | GPT-SoVITS 或 OpenAI 兼容端点;没有则语音工具报“未配置” |
|
|
34
|
+
| 绘图 API | 绘图功能可选 | 兼容 `images` / `chat` 两种协议的第三方服务 |
|
|
35
|
+
|
|
36
|
+
**没有官方 QQ 机器人插件,本插件不会加载任何功能。** 本插件注册的工具都依赖官方插件提供的发送通道与会话管理。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 安装
|
|
41
|
+
|
|
42
|
+
### 方式 A:从 npm 安装(已发布版本,推荐)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
dsh plugin --profile web add dsh-qqbot-expands
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 方式 B:从 GitHub 安装(跟 main 最新)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
dsh plugin --profile web add "github:TONG5395/dsh-qqbot-expands"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`dsh plugin` 会把参数转给 profile 里的 pnpm。装完确认两件事:
|
|
55
|
+
|
|
56
|
+
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 里有 `dsh-qqbot-expands`;
|
|
57
|
+
2. 同文件 `dsh.profile.bundles` 数组里有 `"dsh-qqbot-expands"`(没有就手动加上)。
|
|
58
|
+
|
|
59
|
+
也可以直接走 pnpm:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd ~/.dsh/profiles/web
|
|
63
|
+
pnpm add github:TONG5395/dsh-qqbot-expands
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 方式 C:本地目录
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd ~/.dsh/profiles/web
|
|
70
|
+
pnpm add link:/path/to/dsh-qqbot-expands
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 方式 D:压缩包
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cd ~/.dsh/profiles/web
|
|
77
|
+
pnpm add file:/path/to/dsh-qqbot-expands-0.4.1.tar.gz
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 启用与配置
|
|
81
|
+
|
|
82
|
+
1. 在 profile 的 `package.json` 里把 `"dsh-qqbot-expands"` 加进 `dsh.profile.bundles`;
|
|
83
|
+
2. 在 profile 的 `cordis.patch.yml` 里加配置块(见下方“配置参考”);
|
|
84
|
+
3. 重启 DSH 引擎;
|
|
85
|
+
4. 打开设置 →「QQ 机器人 · 问候」,六块设置都在这里。
|
|
86
|
+
|
|
87
|
+
> 提示:`dsh plugin add` 只负责安装包,`dsh.profile.bundles` 与 `cordis.patch.yml` 里的配置仍需确认。装完可用 `dsh --profile web --dump-config` 检查条目是否出现。
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 功能教程
|
|
92
|
+
|
|
93
|
+
### 1. 群聊频控(主动接话收敛)
|
|
94
|
+
|
|
95
|
+
**解决什么问题**:官方插件在 `requireMention: false` 时,对每条群消息都回复,容易刷屏、烧 token。本插件在消息进入 agent 之前拦一道。
|
|
96
|
+
|
|
97
|
+
**判定流程**(只针对群聊、且没有 @ 机器人的消息):
|
|
98
|
+
|
|
99
|
+
1. **无效消息过滤**:空内容、纯标点/表情、单个语气词(嗯/哦/好)、纯重复串(哈哈哈、66666)直接丢弃;
|
|
100
|
+
2. **防抖合并**:消息先进队列,默认 30 秒内继续来的消息并入同一批,安静满 30 秒才结算;
|
|
101
|
+
3. **概率**:结算时按 `chance` 掷骰,没中就**不丢弃**,进“积压”队列;
|
|
102
|
+
4. **积压**:下次掷中时,把积压的消息和当前这批一起送进 agent,机器人按完整上下文回复;
|
|
103
|
+
5. **限速**(可选):`minIntervalMs` / `maxPerHour` / `maxPerDay`,默认全为 0(不限制)。
|
|
104
|
+
|
|
105
|
+
**永远放行**:@ 机器人的消息、私聊消息。
|
|
106
|
+
|
|
107
|
+
**文件与图片**:非管理员发送的带附件消息会被拦下,并在群里提示“需要主人同意”;管理员回复 `同意` / `允许` / `读吧` 后,原消息会被重放读取(有效期 10 分钟,同群提示有 5 分钟冷却)。管理员本人发的文件直接放行。
|
|
108
|
+
|
|
109
|
+
**合并转发**:命中合并转发/ark 卡片时只取文字,不读取里面的图片和文件。
|
|
110
|
+
|
|
111
|
+
**调参建议**:冷清群 `chance` 0.3~0.5;热闹群 0.1~0.2;`debounceMs` 30~60 秒;`backlogMax` 20~30 条。
|
|
112
|
+
|
|
113
|
+
### 2. 私聊主动问候
|
|
114
|
+
|
|
115
|
+
**触发条件**(全部满足):
|
|
116
|
+
|
|
117
|
+
- 只对**私聊**生效,群聊绝不主动搭话;
|
|
118
|
+
- 对方**先说过话**(有历史对话),没聊过的新会话不打扰;
|
|
119
|
+
- 当前时间在时段窗内(默认 22:00–08:00);
|
|
120
|
+
- 对方空闲时间超过 `idleBaseMs ± jitterRatio`(默认 2 小时 ±40%);
|
|
121
|
+
- 今晚问候次数未达 `maxPerNight`(默认 3 条)。
|
|
122
|
+
|
|
123
|
+
**状态**:`~/.dsh-qqbot/greeting-state.json`;旧版 `greeter-state.json` 会自动迁移。
|
|
124
|
+
|
|
125
|
+
**想立刻验证**:设置页把“静默时间窗”改成包含当前时间(例如 8–23),空闲基准改 1 分钟,检查间隔改 1 分钟,然后等 2~3 分钟。
|
|
126
|
+
|
|
127
|
+
### 3. 表情包(依赖 dsh-meme)
|
|
128
|
+
|
|
129
|
+
**前置**:必须安装 `dsh-meme` 并准备好图库(默认 `~/.dsh/meme-packs/`)。
|
|
130
|
+
|
|
131
|
+
**工具**:`qqbot_send_meme`
|
|
132
|
+
|
|
133
|
+
- `tag`:`daily` / `happy` / `angry` / `sad` / `confused` / `shy`;
|
|
134
|
+
- `query`:按文件名关键词再筛(如 `猫`、`比心`、`无语`);
|
|
135
|
+
- 每次调用按 `meme.chance` 概率决定发不发;被概率拦下时返回 `sent:false`,这一轮不要重试。
|
|
136
|
+
|
|
137
|
+
**图库目录自动检索**:配置 `packDir` → `$DSH_HOME/dsh-expression.json` 的 `memeRoot` / `enabledPacks[0]` → `$DSH_HOME/meme-packs/*` 里第一个含 `memes` 的包 → `~/.dsh/meme-packs/meme-pair-dict`。
|
|
138
|
+
|
|
139
|
+
**必须在官方 QQ 插件里放行图库目录**:`im-qqbot` 配置的 `sendFile.extraRoots` 要包含图库根目录(例如 `/root/.dsh/meme-packs`),否则发送会被路径白名单拒绝。
|
|
140
|
+
|
|
141
|
+
**建议提示词**(加到官方插件的 `directPrompt` / `groupPrompt`):
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
表情包规则(qqbot_send_meme,QQ 专属)
|
|
145
|
+
【默认】每轮回复配一张表情包;工具返回 sent:false 就这一轮只回文字,不要重试、不要自己补发。
|
|
146
|
+
流程:1) 调 qqbot_send_meme,按情绪传 tag 或按内容传 query;
|
|
147
|
+
2) 工具会自动选图并发到当前 QQ 会话,不需要再调 qqbot_send_file;
|
|
148
|
+
3) sent:true 继续写正文;sent:false 说明频控拦了或没图,正文照常写。
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4. 语音(TTS / ASR)
|
|
152
|
+
|
|
153
|
+
**工具**:`qq_voice_say`(合成并发语音条)、`qq_voice_stt`(转写音频)、`qq_voice_status`(自检)。
|
|
154
|
+
|
|
155
|
+
- TTS 支持两种协议:`gptsovits`(POST `{base}/tts`,`{text, ref, speed}`)与 `openai`(POST `{base}/audio/speech`);
|
|
156
|
+
- ASR 走 OpenAI 兼容 `POST {base}/audio/transcriptions`;
|
|
157
|
+
- `autoSend: true` 时合成完自动调 `qqbot_send_file` 发到当前会话;
|
|
158
|
+
- QQ 官方插件不会自动下载语音附件,转写需要先把音频拿到本地文件。
|
|
159
|
+
|
|
160
|
+
**配置**:见下方 `voice` 段。地址可留空,用环境变量 `DSH_VOICE_TTS_BASE` / `DSH_VOICE_TTS_KEY` 等兜底。
|
|
161
|
+
|
|
162
|
+
### 5. 绘图
|
|
163
|
+
|
|
164
|
+
**工具**:`qqbot_draw_image`
|
|
165
|
+
|
|
166
|
+
- 兼容 `images`(`POST {base}/images/generations`)与 `chat`(`POST {base}/chat/completions`)两种上游;
|
|
167
|
+
- `protocol: auto` 时先按配置试,失败自动换另一条;
|
|
168
|
+
- baseUrl 没写 `/v1` 时会自动补(兼容 new-api 一类服务);
|
|
169
|
+
- 生成结果落到 `~/.dsh-qqbot/media/`,模型再调 `qqbot_send_file` 发出;
|
|
170
|
+
- 画角色本人时把 `character` 设为 `true`,会套用拟人化角色外观设定。
|
|
171
|
+
|
|
172
|
+
### 6. 统一设置页
|
|
173
|
+
|
|
174
|
+
重启后打开 设置 →「QQ 机器人 · 问候」,一页六块:
|
|
175
|
+
|
|
176
|
+
- **管理员**:管理员 openid(一行一个),并生成“认人提示词”,一键复制贴进官方插件 prompt;
|
|
177
|
+
- **群聊频控**:开关、接话概率、防抖秒数、无效过滤、文件审批;
|
|
178
|
+
- **表情包**:开关、每条消息概率、当前图库目录;
|
|
179
|
+
- **语音**:TTS/ASR 地址、密钥、协议、音色、格式、自动发送;
|
|
180
|
+
- **绘图**:开关、地址、密钥、模型、尺寸、协议;
|
|
181
|
+
- **私聊问候**:开关、时段窗、空闲基准、浮动、每晚额度、检查间隔、时区、问候语。
|
|
182
|
+
|
|
183
|
+
保存写入 `~/.dsh-qqbot/settings.json`,立即生效;改完设置页需要刷新一次 Web 界面(首次出现该页需要重启引擎)。
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 配置参考
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
- id: qqbot-expands
|
|
191
|
+
name: 'dsh-qqbot-expands'
|
|
192
|
+
config:
|
|
193
|
+
gate:
|
|
194
|
+
enabled: true
|
|
195
|
+
chance: 0.25 # 防抖到点后的接话概率
|
|
196
|
+
debounceMs: 30000 # 静默多久合并一批
|
|
197
|
+
backlogMax: 30 # 没随机到的消息最多攒多少条
|
|
198
|
+
backlogInvalid: false # 无效噪音是否也进积压
|
|
199
|
+
minIntervalMs: 0 # 0 = 不限制
|
|
200
|
+
maxPerHour: 0
|
|
201
|
+
maxPerDay: 0
|
|
202
|
+
invalid:
|
|
203
|
+
enabled: true
|
|
204
|
+
minLength: 2
|
|
205
|
+
dropPureRepeat: true
|
|
206
|
+
dropPurePunctuation: true
|
|
207
|
+
dropFiller: true
|
|
208
|
+
files:
|
|
209
|
+
requireAdminApproval: true
|
|
210
|
+
adminOpenids: [] # 建议在设置页填
|
|
211
|
+
approvalKeywords: [同意, 允许, 可以读, 读吧, 批准, 看吧]
|
|
212
|
+
approvalTtlMs: 600000
|
|
213
|
+
noticeCooldownMs: 300000
|
|
214
|
+
skipMergedAttachments: true
|
|
215
|
+
botIds: [] # 机器人 openid 等额外 id(@ 判定兜底)
|
|
216
|
+
meme:
|
|
217
|
+
enabled: true
|
|
218
|
+
packDir: '' # 空 = 自动检索 dsh-meme 图库
|
|
219
|
+
chance: 0.6 # 每条消息附带表情包的概率
|
|
220
|
+
maxRecent: 3
|
|
221
|
+
voice:
|
|
222
|
+
enabled: true
|
|
223
|
+
ttsBaseUrl: ''
|
|
224
|
+
ttsApiKey: ''
|
|
225
|
+
ttsProtocol: gptsovits # gptsovits / openai
|
|
226
|
+
ttsModel: ''
|
|
227
|
+
ttsVoice: default
|
|
228
|
+
ttsFormat: wav
|
|
229
|
+
asrBaseUrl: ''
|
|
230
|
+
asrApiKey: ''
|
|
231
|
+
asrModel: whisper-large-v3-turbo
|
|
232
|
+
autoSend: true
|
|
233
|
+
timeoutMs: 120000
|
|
234
|
+
drawing:
|
|
235
|
+
enabled: true
|
|
236
|
+
baseUrl: ''
|
|
237
|
+
apiKey: ''
|
|
238
|
+
model: ''
|
|
239
|
+
size: 1024x1024
|
|
240
|
+
protocol: auto # images / chat / auto
|
|
241
|
+
timeoutMs: 300000
|
|
242
|
+
greeting:
|
|
243
|
+
enabled: true
|
|
244
|
+
idleBaseMs: 7200000
|
|
245
|
+
jitterRatio: 0.4
|
|
246
|
+
quietStartHour: 22
|
|
247
|
+
quietEndHour: 8
|
|
248
|
+
maxPerNight: 3
|
|
249
|
+
checkIntervalMs: 300000
|
|
250
|
+
tzOffsetHours: 8
|
|
251
|
+
greetings: []
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### 官方 QQ 插件的配套设置
|
|
255
|
+
|
|
256
|
+
```yaml
|
|
257
|
+
- id: im-qqbot
|
|
258
|
+
config:
|
|
259
|
+
requireMention: false # 频控才有意义;改回 true 请同时关闭 gate.enabled
|
|
260
|
+
sendFile:
|
|
261
|
+
restrictPaths: true
|
|
262
|
+
extraRoots:
|
|
263
|
+
- /root/.dsh/meme-packs # 表情包目录必须放行
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## 常见问题
|
|
269
|
+
|
|
270
|
+
**机器人完全不回复?**
|
|
271
|
+
先看官方插件是否正常(绑定、网关、`requireMention`),再看群主是否开启了全量消息权限;最后看设置页概率与防抖。
|
|
272
|
+
|
|
273
|
+
**@ 了也不回?**
|
|
274
|
+
多数是 @ 识别问题:设置页把机器人 openid 填进 `botIds`(从日志或官方插件会话里能看到)。
|
|
275
|
+
|
|
276
|
+
**表情包发不出去?**
|
|
277
|
+
检查 `sendFile.extraRoots` 是否包含图库根目录;设置页能看到当前解析到的图库路径。
|
|
278
|
+
|
|
279
|
+
**文件/图片不读?**
|
|
280
|
+
需要管理员 openid,且非管理员文件必须管理员回复“同意”。
|
|
281
|
+
|
|
282
|
+
**语音没声音?**
|
|
283
|
+
TTS 地址/协议/密钥是否配好;服务端是否支持对应格式。
|
|
284
|
+
|
|
285
|
+
**设置页看不到?**
|
|
286
|
+
重启引擎一次,再刷新页面。
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 开发与测试
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
node test-gate.mjs # 群聊频控
|
|
294
|
+
node test-greeting.mjs # 私聊问候 + 设置页路由
|
|
295
|
+
node test-meme.mjs # 表情包挑选与概率
|
|
296
|
+
node test-draw.mjs # 绘图协议
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
四个测试都不联网,使用临时状态文件。
|
|
300
|
+
|
|
301
|
+
> 这四个脚本只存在于源码仓库,不在 npm 包的 `files` 白名单里,所以从 npm 装到的版本没有它们;要跑测试请克隆仓库后在根目录执行。
|
|
302
|
+
> `test-meme.mjs` 需要本机有可用的表情图库(默认 `~/.dsh/meme-packs/meme-pair-dict/memes/<情绪>/`),挑不到图会失败。
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## 免责声明
|
|
307
|
+
|
|
308
|
+
- 本项目是社区插件,非官方项目,与腾讯 / DeepSeek 无隶属关系。
|
|
309
|
+
- 使用本插件即表示你已阅读并遵守 QQ 开放平台的相关规则;因违反平台规则导致的后果由使用者自行承担。
|
|
310
|
+
- 请勿用于骚扰、刷屏、垃圾营销等场景。本项目只做“少回、可控”的收敛。
|
|
311
|
+
- 软件按 MIT 协议“原样”提供,不附带任何担保。
|
package/client.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// client.js — @local/dsh-qqbot-expands 的浏览器半边
|
|
2
|
+
//
|
|
3
|
+
// 设置 →「QQ 机器人 · 问候」一页管全部 QQ 适配:
|
|
4
|
+
// 管理员 / 群聊频控 / 表情包 / 语音 / 绘图 / 私聊问候。
|
|
5
|
+
// 数据来自宿主路由 /qqbot-expands/settings(GET 读、POST 写),保存后立即生效。
|
|
6
|
+
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
id: 'dsh-qqbot-expands',
|
|
9
|
+
factory(require) {
|
|
10
|
+
const React = require('react')
|
|
11
|
+
const h = React.createElement
|
|
12
|
+
const module = { exports: {} }
|
|
13
|
+
const exports = module.exports
|
|
14
|
+
const API = '/qqbot-expands/settings'
|
|
15
|
+
|
|
16
|
+
const S = {
|
|
17
|
+
wrap: { display: 'flex', flexDirection: 'column', gap: 16, padding: '4px 0 24px' },
|
|
18
|
+
block: { display: 'flex', flexDirection: 'column', gap: 10, paddingBottom: 14, borderBottom: '0.5px solid var(--dsw-alias-border-l3)' },
|
|
19
|
+
title: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
|
|
20
|
+
row: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
21
|
+
label: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)', minWidth: 110 },
|
|
22
|
+
input: {
|
|
23
|
+
width: 120, height: 30, padding: '0 8px', borderRadius: 8,
|
|
24
|
+
border: '0.5px solid var(--dsw-alias-border-l3)', background: 'transparent',
|
|
25
|
+
color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: 13,
|
|
26
|
+
},
|
|
27
|
+
wide: {
|
|
28
|
+
width: 260, height: 30, padding: '0 8px', borderRadius: 8,
|
|
29
|
+
border: '0.5px solid var(--dsw-alias-border-l3)', background: 'transparent',
|
|
30
|
+
color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: 13,
|
|
31
|
+
},
|
|
32
|
+
area: {
|
|
33
|
+
width: '100%', minHeight: 72, padding: 8, borderRadius: 8, resize: 'vertical',
|
|
34
|
+
border: '0.5px solid var(--dsw-alias-border-l3)', background: 'transparent',
|
|
35
|
+
color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: 13, boxSizing: 'border-box',
|
|
36
|
+
},
|
|
37
|
+
btn: {
|
|
38
|
+
height: 30, padding: '0 14px', borderRadius: 999, cursor: 'pointer', font: 'inherit', fontSize: 13,
|
|
39
|
+
border: '0.5px solid var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-brand-primary)',
|
|
40
|
+
color: '#fff',
|
|
41
|
+
},
|
|
42
|
+
ghost: {
|
|
43
|
+
height: 28, padding: '0 12px', borderRadius: 999, cursor: 'pointer', font: 'inherit', fontSize: 12,
|
|
44
|
+
border: '0.5px solid var(--dsw-alias-border-l3)', background: 'transparent',
|
|
45
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
46
|
+
},
|
|
47
|
+
hint: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' },
|
|
48
|
+
ok: { fontSize: 12, color: 'var(--dsw-alias-state-success-primary, #2f9e44)' },
|
|
49
|
+
err: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #e03131)' },
|
|
50
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toDraft(d) {
|
|
54
|
+
const g = d.greeting || {}
|
|
55
|
+
const gate = d.gate || {}
|
|
56
|
+
const m = d.meme || {}
|
|
57
|
+
const v = d.voice || {}
|
|
58
|
+
const dr = d.drawing || {}
|
|
59
|
+
return {
|
|
60
|
+
adminText: (d.adminOpenids || []).join('\n'),
|
|
61
|
+
gate: {
|
|
62
|
+
enabled: gate.enabled !== false,
|
|
63
|
+
chancePct: Math.round((gate.chance ?? 0.25) * 100),
|
|
64
|
+
debounceSec: Math.round((gate.debounceMs ?? 30000) / 1000),
|
|
65
|
+
invalidEnabled: gate.invalid?.enabled !== false,
|
|
66
|
+
fileApproval: gate.files?.requireAdminApproval !== false,
|
|
67
|
+
},
|
|
68
|
+
meme: { enabled: m.enabled !== false, chancePct: Math.round((m.chance ?? 0.6) * 100) },
|
|
69
|
+
voice: {
|
|
70
|
+
enabled: v.enabled !== false,
|
|
71
|
+
ttsBaseUrl: v.ttsBaseUrl || '',
|
|
72
|
+
ttsApiKey: v.ttsApiKey || '',
|
|
73
|
+
ttsProtocol: v.ttsProtocol || 'gptsovits',
|
|
74
|
+
ttsModel: v.ttsModel || '',
|
|
75
|
+
ttsVoice: v.ttsVoice || 'default',
|
|
76
|
+
ttsFormat: v.ttsFormat || 'wav',
|
|
77
|
+
asrBaseUrl: v.asrBaseUrl || '',
|
|
78
|
+
asrApiKey: v.asrApiKey || '',
|
|
79
|
+
asrModel: v.asrModel || 'whisper-large-v3-turbo',
|
|
80
|
+
autoSend: v.autoSend !== false,
|
|
81
|
+
},
|
|
82
|
+
drawing: {
|
|
83
|
+
enabled: dr.enabled !== false,
|
|
84
|
+
baseUrl: dr.baseUrl || '',
|
|
85
|
+
apiKey: dr.apiKey || '',
|
|
86
|
+
model: dr.model || '',
|
|
87
|
+
size: dr.size || '1024x1024',
|
|
88
|
+
protocol: dr.protocol || 'auto',
|
|
89
|
+
},
|
|
90
|
+
greeting: {
|
|
91
|
+
enabled: g.enabled !== false,
|
|
92
|
+
idleMinutes: Math.round((g.idleBaseMs || 7200000) / 60000),
|
|
93
|
+
jitterPct: Math.round((g.jitterRatio || 0) * 100),
|
|
94
|
+
quietStartHour: g.quietStartHour ?? 22,
|
|
95
|
+
quietEndHour: g.quietEndHour ?? 8,
|
|
96
|
+
maxPerNight: g.maxPerNight ?? 3,
|
|
97
|
+
checkMinutes: Math.round((g.checkIntervalMs || 300000) / 60000),
|
|
98
|
+
tzOffsetHours: g.tzOffsetHours ?? 8,
|
|
99
|
+
greetingsText: Array.isArray(g.greetings) ? g.greetings.join('\n') : '',
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function Section() {
|
|
105
|
+
const [draft, setDraft] = React.useState(null)
|
|
106
|
+
const [snapshot, setSnapshot] = React.useState(null)
|
|
107
|
+
const [msg, setMsg] = React.useState({ kind: '', text: '' })
|
|
108
|
+
const [busy, setBusy] = React.useState(false)
|
|
109
|
+
const [copied, setCopied] = React.useState(false)
|
|
110
|
+
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
let alive = true
|
|
113
|
+
fetch(API)
|
|
114
|
+
.then((r) => r.json())
|
|
115
|
+
.then((d) => { if (!alive) return; setSnapshot(d); setDraft(toDraft(d)) })
|
|
116
|
+
.catch((err) => { if (alive) setMsg({ kind: 'err', text: '读取失败:' + err.message }) })
|
|
117
|
+
return () => { alive = false }
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
if (!draft) return h('div', { style: S.wrap }, h('div', { style: S.hint }, '正在读取设置…'))
|
|
121
|
+
|
|
122
|
+
const upd = (section, key) => (event) => {
|
|
123
|
+
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
|
124
|
+
setDraft({ ...draft, [section]: { ...draft[section], [key]: value } })
|
|
125
|
+
}
|
|
126
|
+
const setTop = (key) => (event) => setDraft({ ...draft, [key]: event.target.value })
|
|
127
|
+
|
|
128
|
+
const save = async () => {
|
|
129
|
+
setBusy(true)
|
|
130
|
+
setMsg({ kind: '', text: '' })
|
|
131
|
+
const g = draft.greeting, gt = draft.gate, m = draft.meme, v = draft.voice, dr = draft.drawing
|
|
132
|
+
const payload = {
|
|
133
|
+
adminOpenids: String(draft.adminText || '').split('\n').map((s) => s.trim()).filter(Boolean),
|
|
134
|
+
gate: {
|
|
135
|
+
enabled: Boolean(gt.enabled),
|
|
136
|
+
chance: Math.min(100, Math.max(0, Number(gt.chancePct) || 0)) / 100,
|
|
137
|
+
debounceMs: Math.max(5, Number(gt.debounceSec) || 0) * 1000,
|
|
138
|
+
invalid: { enabled: Boolean(gt.invalidEnabled) },
|
|
139
|
+
files: { requireAdminApproval: Boolean(gt.fileApproval) },
|
|
140
|
+
},
|
|
141
|
+
meme: { enabled: Boolean(m.enabled), chance: Math.min(100, Math.max(0, Number(m.chancePct) || 0)) / 100 },
|
|
142
|
+
voice: {
|
|
143
|
+
enabled: Boolean(v.enabled),
|
|
144
|
+
ttsBaseUrl: v.ttsBaseUrl, ttsApiKey: v.ttsApiKey, ttsProtocol: v.ttsProtocol,
|
|
145
|
+
ttsModel: v.ttsModel, ttsVoice: v.ttsVoice, ttsFormat: v.ttsFormat,
|
|
146
|
+
asrBaseUrl: v.asrBaseUrl, asrApiKey: v.asrApiKey, asrModel: v.asrModel,
|
|
147
|
+
autoSend: Boolean(v.autoSend),
|
|
148
|
+
},
|
|
149
|
+
drawing: {
|
|
150
|
+
enabled: Boolean(dr.enabled), baseUrl: dr.baseUrl, apiKey: dr.apiKey,
|
|
151
|
+
model: dr.model, size: dr.size, protocol: dr.protocol,
|
|
152
|
+
},
|
|
153
|
+
greeting: {
|
|
154
|
+
enabled: Boolean(g.enabled),
|
|
155
|
+
idleBaseMs: Math.max(1, Number(g.idleMinutes) || 0) * 60000,
|
|
156
|
+
jitterRatio: Math.min(100, Math.max(0, Number(g.jitterPct) || 0)) / 100,
|
|
157
|
+
quietStartHour: Number(g.quietStartHour),
|
|
158
|
+
quietEndHour: Number(g.quietEndHour),
|
|
159
|
+
maxPerNight: Number(g.maxPerNight),
|
|
160
|
+
checkIntervalMs: Math.max(1, Number(g.checkMinutes) || 0) * 60000,
|
|
161
|
+
tzOffsetHours: Number(g.tzOffsetHours),
|
|
162
|
+
greetings: String(g.greetingsText || '').split('\n').map((s) => s.trim()).filter(Boolean),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
167
|
+
const data = await res.json().catch(() => ({}))
|
|
168
|
+
if (!res.ok || data.ok === false) throw new Error(data.error || ('HTTP ' + res.status))
|
|
169
|
+
setSnapshot(data)
|
|
170
|
+
setDraft(toDraft(data))
|
|
171
|
+
setMsg({ kind: 'ok', text: '已保存,立即生效' })
|
|
172
|
+
} catch (err) {
|
|
173
|
+
setMsg({ kind: 'err', text: '保存失败:' + err.message })
|
|
174
|
+
} finally {
|
|
175
|
+
setBusy(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const copyPrompt = async () => {
|
|
180
|
+
try {
|
|
181
|
+
await navigator.clipboard.writeText(snapshot?.adminPrompt || '')
|
|
182
|
+
setCopied(true)
|
|
183
|
+
setTimeout(() => setCopied(false), 1500)
|
|
184
|
+
} catch (err) {
|
|
185
|
+
setMsg({ kind: 'err', text: '复制失败:' + err.message })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const input = (section, key, opts = {}) => h('input', {
|
|
190
|
+
style: opts.wide ? S.wide : S.input,
|
|
191
|
+
type: opts.type || 'text',
|
|
192
|
+
min: opts.min, max: opts.max, placeholder: opts.placeholder,
|
|
193
|
+
value: draft[section][key],
|
|
194
|
+
onChange: upd(section, key),
|
|
195
|
+
})
|
|
196
|
+
const check = (section, key) => h('input', { type: 'checkbox', checked: Boolean(draft[section][key]), onChange: upd(section, key) })
|
|
197
|
+
const line = (label, ...children) => h('div', { style: S.row }, h('span', { style: S.label }, label), ...children)
|
|
198
|
+
|
|
199
|
+
const g = draft.greeting, gt = draft.gate, m = draft.meme, v = draft.voice, dr = draft.drawing
|
|
200
|
+
const peers = snapshot?.greetingState?.peers || []
|
|
201
|
+
|
|
202
|
+
return h('div', { style: S.wrap },
|
|
203
|
+
|
|
204
|
+
// 管理员
|
|
205
|
+
h('div', { style: S.block },
|
|
206
|
+
h('div', { style: S.title }, '管理员'),
|
|
207
|
+
h('div', { style: S.hint }, '一行一个 openid。文件读取审批、认人提示词都会用到。'),
|
|
208
|
+
h('textarea', { style: { ...S.area, ...S.mono }, placeholder: '在这里填管理员的 openid,一行一个', value: draft.adminText, onChange: setTop('adminText') }),
|
|
209
|
+
line('辨认提示词', h('button', { style: S.ghost, type: 'button', onClick: copyPrompt }, copied ? '已复制' : '复制'), h('span', { style: S.hint }, '贴进 QQ 机器人的 directPrompt / groupPrompt')),
|
|
210
|
+
h('textarea', { style: { ...S.area, ...S.mono, minHeight: 66 }, readOnly: true, value: snapshot?.adminPrompt || '' }),
|
|
211
|
+
),
|
|
212
|
+
|
|
213
|
+
// 群聊频控
|
|
214
|
+
h('div', { style: S.block },
|
|
215
|
+
h('div', { style: S.title }, '群聊频控'),
|
|
216
|
+
line('开启频控', check('gate', 'enabled'), h('span', { style: S.hint }, '未 @ 的群消息先过闸再决定接不接')),
|
|
217
|
+
line('接话概率', input('gate', 'chancePct', { type: 'number', min: 0, max: 100 }), h('span', { style: S.hint }, '%(防抖到点后掷骰)')),
|
|
218
|
+
line('防抖', input('gate', 'debounceSec', { type: 'number', min: 5 }), h('span', { style: S.hint }, '秒内继续来的消息合并成一批')),
|
|
219
|
+
line('无效消息过滤', check('gate', 'invalidEnabled'), h('span', { style: S.hint }, '语气词、纯标点、重复串直接丢')),
|
|
220
|
+
line('文件需管理员同意', check('gate', 'fileApproval'), h('span', { style: S.hint }, '非管理员发的图片/文件先拦下')),
|
|
221
|
+
),
|
|
222
|
+
|
|
223
|
+
// 表情包
|
|
224
|
+
h('div', { style: S.block },
|
|
225
|
+
h('div', { style: S.title }, '表情包'),
|
|
226
|
+
line('开启表情包', check('meme', 'enabled')),
|
|
227
|
+
line('每条消息概率', input('meme', 'chancePct', { type: 'number', min: 0, max: 100 }), h('span', { style: S.hint }, '%(每条回复独立掷骰)')),
|
|
228
|
+
h('div', { style: S.hint }, '图库目录:' + (snapshot?.meme?.resolvedPackDir || '自动检索中')),
|
|
229
|
+
),
|
|
230
|
+
|
|
231
|
+
// 语音
|
|
232
|
+
h('div', { style: S.block },
|
|
233
|
+
h('div', { style: S.title }, '语音'),
|
|
234
|
+
line('开启语音', check('voice', 'enabled'), h('span', { style: S.hint }, 'TTS 合成语音条 / ASR 转写')),
|
|
235
|
+
line('TTS 地址', input('voice', 'ttsBaseUrl', { wide: true, placeholder: 'https://…' })),
|
|
236
|
+
line('TTS 密钥', input('voice', 'ttsApiKey', { wide: true, placeholder: '可留空' })),
|
|
237
|
+
line('TTS 协议', h('select', { style: S.wide, value: v.ttsProtocol, onChange: upd('voice', 'ttsProtocol') },
|
|
238
|
+
h('option', { value: 'gptsovits' }, 'gptsovits'),
|
|
239
|
+
h('option', { value: 'openai' }, 'openai'),
|
|
240
|
+
)),
|
|
241
|
+
line('音色 / 模型', input('voice', 'ttsVoice', { wide: true }), input('voice', 'ttsModel', { wide: true })),
|
|
242
|
+
line('格式', input('voice', 'ttsFormat', { placeholder: 'wav' })),
|
|
243
|
+
line('ASR 地址', input('voice', 'asrBaseUrl', { wide: true, placeholder: '可留空' })),
|
|
244
|
+
line('ASR 密钥', input('voice', 'asrApiKey', { wide: true })),
|
|
245
|
+
line('ASR 模型', input('voice', 'asrModel', { wide: true })),
|
|
246
|
+
line('合成后自动发送', check('voice', 'autoSend')),
|
|
247
|
+
),
|
|
248
|
+
|
|
249
|
+
// 绘图
|
|
250
|
+
h('div', { style: S.block },
|
|
251
|
+
h('div', { style: S.title }, '绘图'),
|
|
252
|
+
line('开启绘图', check('drawing', 'enabled')),
|
|
253
|
+
line('接口地址', input('drawing', 'baseUrl', { wide: true, placeholder: 'https://…/v1' })),
|
|
254
|
+
line('密钥', input('drawing', 'apiKey', { wide: true })),
|
|
255
|
+
line('模型', input('drawing', 'model', { wide: true })),
|
|
256
|
+
line('尺寸', input('drawing', 'size', { placeholder: '1024x1024' })),
|
|
257
|
+
line('协议', h('select', { style: S.wide, value: dr.protocol, onChange: upd('drawing', 'protocol') },
|
|
258
|
+
h('option', { value: 'auto' }, 'auto'),
|
|
259
|
+
h('option', { value: 'images' }, 'images'),
|
|
260
|
+
h('option', { value: 'chat' }, 'chat'),
|
|
261
|
+
)),
|
|
262
|
+
),
|
|
263
|
+
|
|
264
|
+
// 私聊问候
|
|
265
|
+
h('div', { style: S.block },
|
|
266
|
+
h('div', { style: S.title }, '私聊主动问候'),
|
|
267
|
+
line('开启问候', check('greeting', 'enabled'), h('span', { style: S.hint }, '只问候私聊,群聊绝不主动搭话')),
|
|
268
|
+
line('静默时间窗',
|
|
269
|
+
input('greeting', 'quietStartHour', { type: 'number', min: 0, max: 23 }), h('span', { style: S.hint }, '点 —'),
|
|
270
|
+
input('greeting', 'quietEndHour', { type: 'number', min: 0, max: 23 }), h('span', { style: S.hint }, '点(跨午夜也行)')),
|
|
271
|
+
line('空闲基准',
|
|
272
|
+
input('greeting', 'idleMinutes', { type: 'number', min: 1 }), h('span', { style: S.hint }, '分钟,±'),
|
|
273
|
+
input('greeting', 'jitterPct', { type: 'number', min: 0, max: 100 }), h('span', { style: S.hint }, '% 浮动')),
|
|
274
|
+
line('每晚最多',
|
|
275
|
+
input('greeting', 'maxPerNight', { type: 'number', min: 0, max: 50 }), h('span', { style: S.hint }, '条'),
|
|
276
|
+
h('span', { style: S.label, minWidth: 0 }, '检查间隔'),
|
|
277
|
+
input('greeting', 'checkMinutes', { type: 'number', min: 1 }), h('span', { style: S.hint }, '分钟')),
|
|
278
|
+
line('时区偏移', input('greeting', 'tzOffsetHours', { type: 'number', min: -12, max: 14 }), h('span', { style: S.hint }, '小时(东八区填 8)')),
|
|
279
|
+
line('问候语', h('textarea', { style: S.area, placeholder: '每行一条;留空用内置的几句', value: g.greetingsText, onChange: upd('greeting', 'greetingsText') })),
|
|
280
|
+
h('div', { style: S.hint }, '今晚已登记 ' + peers.length + ' 个私聊' +
|
|
281
|
+
(peers.length > 0 ? ':' + peers.slice(0, 4).map((p) => (p.name || p.id.slice(0, 6)) + '(' + (p.idleMinutes ?? '—') + '分钟)').join('、') : '')),
|
|
282
|
+
),
|
|
283
|
+
|
|
284
|
+
h('div', { style: S.row },
|
|
285
|
+
h('button', { style: S.btn, type: 'button', disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
|
|
286
|
+
msg.kind === 'ok' ? h('span', { style: S.ok }, msg.text) : null,
|
|
287
|
+
msg.kind === 'err' ? h('span', { style: S.err }, msg.text) : null,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function apply(ctx) {
|
|
293
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
294
|
+
name: 'settings.section',
|
|
295
|
+
id: 'qqbot-expands',
|
|
296
|
+
order: 8,
|
|
297
|
+
label: 'QQ 机器人 · 问候',
|
|
298
|
+
}, Section))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const inject = ['slots']
|
|
302
|
+
exports.apply = apply
|
|
303
|
+
exports.inject = inject
|
|
304
|
+
return module.exports
|
|
305
|
+
},
|
|
306
|
+
})
|