dsh-qq-tools 0.9.5 → 0.10.2

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.
Files changed (41) hide show
  1. package/README.md +23 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/config.js +96 -0
  4. package/lib/images.js +420 -0
  5. package/lib/index.js +2 -1
  6. package/lib/mount-all.js +59 -0
  7. package/lib/observation.js +24 -0
  8. package/lib/onebot.js +32 -0
  9. package/lib/prompts.js +56 -0
  10. package/lib/qq-state.js +51 -0
  11. package/lib/render.js +49 -0
  12. package/lib/session.js +201 -0
  13. package/lib/shared.js +26 -3377
  14. package/lib/tools/avatar.js +54 -0
  15. package/lib/tools/faces.js +296 -0
  16. package/lib/tools/friends.js +559 -0
  17. package/lib/tools/groups.js +220 -0
  18. package/lib/tools/poke.js +41 -0
  19. package/lib/tools/private.js +190 -0
  20. package/lib/tools/qzone.js +569 -0
  21. package/lib/utils.js +73 -0
  22. package/lib/watchers/mentions.js +167 -0
  23. package/lib/watchers/poke.js +87 -0
  24. package/lib/watchers/private.js +95 -0
  25. package/lib/watchers/ws.js +61 -0
  26. package/package.json +98 -103
  27. package/tools/qq-favorite-faces/index.js +11 -10
  28. package/tools/qq-favorite-faces/locale/en.json +1 -1
  29. package/tools/qq-favorite-faces/locale/zh.json +1 -1
  30. package/tools/qq-friend-management/index.js +28 -28
  31. package/tools/qq-get-group-history/index.js +10 -8
  32. package/tools/qq-get-group-members/index.js +9 -8
  33. package/tools/qq-list-groups/index.js +9 -8
  34. package/tools/qq-private-chat/index.js +16 -20
  35. package/tools/qq-qzone/index.js +22 -22
  36. package/tools/qq-send-face/index.js +11 -8
  37. package/tools/qq-send-face/locale/en.json +1 -1
  38. package/tools/qq-send-face/locale/zh.json +1 -1
  39. package/tools/qq-send-group-message/index.js +13 -10
  40. package/tools/qq-send-poke/index.js +13 -10
  41. package/tools/qq-set-avatar/index.js +9 -8
package/lib/shared.js CHANGED
@@ -1,3377 +1,26 @@
1
- // dsh-qq-tools —— 给大肥鱼会话用的轻量 QQ 群/私聊工具 + 唤醒(共享核心)
2
- //
3
- // 结构(2026-10-23 拆分,2026-10-24 加表情和私聊,2026-10-26 加头像/好友管理/QQ 空间):
4
- // · 每个工具是一个独立的 cordis 组件,由 cordis.patch.yml 里的 11 个工具 insert 行加载
5
- // (包根 carrier 另有一行);插件详情页会列出 11 个工具组,每个都有自己的开关。
6
- // · 本文件只放共享逻辑:配置、OneBot、目标会话挂载、@ 轮询、私聊唤醒、戳一戳、表情、
7
- // 头像、好友管理、QQ 空间、观察窗。
8
- // · 发群消息组件负责 @ 提醒轮询;戳一戳组件负责接收戳一戳事件;
9
- // 收藏表情组件负责 QQ 收藏夹(走 SnowLuma 的自定义表情接口,和 QQ 客户端同步);
10
- // 私聊组件负责好友/私聊记录/发私聊消息,以及私聊消息唤醒;
11
- // 好友管理组件负责好友资料/备注/分组/删好友/名片赞和好友申请;
12
- // QQ 空间组件负责说说列表、详情评论、发表和管理。
13
- //
14
- // 设计目标(按用户要求):
15
- // · 群聊不逐条转发、不自动回;由「主动搭话」或「有人 @」触发,
16
- // 模型自己用工具看 2~3 个群的最近 30 条,再决定要不要发、发哪个群;
17
- // · 私聊消息逐条唤醒目标会话,模型用 qq_get_private_history / qq_send_private_message 看和回;
18
- // · 所有工具只在目标会话的 agent 上注册,其他会话看不到;
19
- // · 唤醒通过 agent.followup 进入同一个会话:
20
- // 群 @ = qq-mention,群后续 = qq-followup,群戳一戳 = qq-poke,私聊 = qq-private。
21
- //
22
- // 配置:~/.dsh/qq-tools-config.json(首次启动自动创建)
23
- // {
24
- // "napCat": "http://127.0.0.1:3000",
25
- // "accessToken": "",
26
- // "targetSession": "", // 目标会话 ID;留空 = 不挂工具
27
- // "groups": [], // 允许看/发的群;空数组 = 工具不限制(提醒也不轮询)
28
- // "historyCount": 30,
29
- // "maxHistoryCount": 50,
30
- // "pollMs": 5000,
31
- // "mentionEnabled": true, // @ 提醒轮询(跟着「发群消息」组件走)
32
- // "pokeEnabled": true, // 接收戳一戳事件(跟着「戳一戳」组件走)
33
- // "privateEnabled": true, // 私聊唤醒(跟着「私聊」组件走)
34
- // "privateUsers": [], // 允许唤醒的私聊 QQ 号;空数组 = 不限制
35
- // "timeoutMs": 6000
36
- // }
37
- //
38
- // 工具本身的开关在 DSH 插件页的组件列表里:关掉某个组件,它就不再注册对应工具。
39
-
40
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync, statSync } from 'node:fs'
41
- import { join } from 'node:path'
42
- import { homedir } from 'node:os'
43
- import { randomUUID } from 'node:crypto'
44
- import { createRequire } from 'node:module'
45
- import { pathToFileURL } from 'node:url'
46
-
47
- const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
48
- const CFG_PATH = join(DSH_HOME, 'qq-tools-config.json')
49
- const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
50
-
51
-
52
- const requireFromHere = createRequire(import.meta.url)
53
- let sharpSource = ''
54
-
55
- function sharpFromDir(dir) {
56
- try {
57
- const s = createRequire(join(String(dir), 'noop.js'))('sharp')
58
- return s || null
59
- } catch (error) {
60
- return null
61
- }
62
- }
63
-
64
- function sharpFromCache() {
65
- try {
66
- const cache = requireFromHere.cache || {}
67
- for (const key of Object.keys(cache)) {
68
- if (!/[\\/]node_modules[\\/]sharp[\\/]/.test(key)) continue
69
- const mod = cache[key]
70
- if (mod && mod.exports) return mod.exports
71
- }
72
- } catch (error) {}
73
- return null
74
- }
75
-
76
- // sharp 是可选的,按顺序找:SHARP_PATH → 插件自己装的 → 宿主 runtime → 常见宿主目录。
77
- // 关键的一条是 process.argv[2]:DSH 桌面版的 dsh-desktop-host 用 argv[2] 接 runtimeDir,
78
- // 插件和宿主在同一个进程里,所以 runtimeDir/node_modules/sharp 就是宿主实际加载的那份。
79
- // 不写死安装路径;找不到时核心图片逻辑照常工作,只是不拆 GIF 帧,
80
- // 尺寸被宿主拒绝时也没法兜底重编码。
81
- function loadSharp() {
82
- const probes = []
83
- const add = (label, fn) => probes.push({ label, fn })
84
-
85
- if (process.env.SHARP_PATH) add('SHARP_PATH', () => requireFromHere(process.env.SHARP_PATH))
86
- add('插件 node_modules', () => requireFromHere('sharp'))
87
- if (process.argv[1]) add('宿主入口', () => createRequire(process.argv[1])('sharp'))
88
- if (process.argv[2]) add('宿主 runtimeDir', () => sharpFromDir(process.argv[2]))
89
-
90
- // 不同启动方式下 runtimeDir 的位置可能变;把 argv 里看起来像目录的路径都试一遍。
91
- for (let i = 1; i < process.argv.length; i++) {
92
- const raw = String(process.argv[i] || '')
93
- if (!raw) continue
94
- try {
95
- if (existsSync(raw) && statSync(raw).isDirectory()) add('argv 目录 ' + raw, () => sharpFromDir(raw))
96
- } catch (error) {}
97
- }
98
-
99
- if (process.cwd()) add('当前目录', () => sharpFromDir(process.cwd()))
100
- if (process.resourcesPath) {
101
- for (const root of [
102
- join(process.resourcesPath, 'app.asar', 'dsh'),
103
- join(process.resourcesPath, 'app.asar.unpacked', 'dsh'),
104
- join(process.resourcesPath, 'app.asar'),
105
- join(process.resourcesPath, 'app.asar.unpacked'),
106
- join(process.resourcesPath, 'runtime'),
107
- ]) {
108
- add('resources ' + root, () => sharpFromDir(root))
109
- }
110
- }
111
- add('已加载模块缓存', sharpFromCache)
112
-
113
- for (const probe of probes) {
114
- try {
115
- const s = probe.fn()
116
- if (s) {
117
- sharpSource = probe.label
118
- return s
119
- }
120
- } catch (error) {}
121
- }
122
- return null
123
- }
124
-
125
- const sharp = loadSharp()
126
- diag('sharp:' + (sharp
127
- ? '已加载(' + sharpSource + ')'
128
- : '没找到;GIF 只取首帧,大图交给 DSH 归一化,图片不会因此丢失'))
129
- let gifWarned = false
130
-
131
- const DEFAULT_GROUPS = []
132
- const DEFAULT_CFG = {
133
- napCat: 'http://127.0.0.1:3000',
134
- accessToken: '',
135
- targetSession: '',
136
- groups: DEFAULT_GROUPS,
137
- historyCount: 30,
138
- maxHistoryCount: 50,
139
- pollMs: 5000,
140
- mentionEnabled: true,
141
- wsUrl: 'ws://127.0.0.1:3001',
142
- pokeEnabled: true,
143
- privateEnabled: true,
144
- privateUsers: [],
145
- observationMs: 60000,
146
- timeoutMs: 6000,
147
- }
148
-
149
- function diag(msg) {
150
- try {
151
- appendFileSync(DIAG_PATH, new Date().toISOString() + ' ' + msg + '\n', 'utf8')
152
- } catch (error) {}
153
- }
154
-
155
-
156
- function clean(s) {
157
- return String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
158
- }
159
-
160
- // 说说正文和评论要保留换行;只清掉控制字符,不折叠空白。
161
- function cleanMultiline(s, max = 4000) {
162
- return String(s == null ? '' : s)
163
- .replace(/\r\n?/g, '\n')
164
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ')
165
- .trim()
166
- .slice(0, max)
167
- }
168
-
169
- function ensureCfg() {
170
- try {
171
- if (existsSync(CFG_PATH)) return
172
- mkdirSync(DSH_HOME, { recursive: true })
173
- writeFileSync(CFG_PATH, JSON.stringify(DEFAULT_CFG, null, 2), 'utf8')
174
- diag('已创建配置:' + CFG_PATH)
175
- } catch (error) {}
176
- }
177
-
178
- function loadCfg() {
179
- try {
180
- const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
181
- const out = { ...DEFAULT_CFG, ...(raw && typeof raw === 'object' ? raw : {}) }
182
- out.groups = Array.isArray(out.groups) ? out.groups.map((x) => Number(x)).filter(Number.isFinite) : DEFAULT_GROUPS
183
- out.napCat = String(out.napCat || DEFAULT_CFG.napCat).replace(/\/+$/, '')
184
- out.historyCount = Math.max(1, Math.min(Number(out.historyCount) || 30, Number(out.maxHistoryCount) || 50))
185
- out.maxHistoryCount = Math.max(out.historyCount, Math.min(Number(out.maxHistoryCount) || 50, 100))
186
- out.pollMs = Math.max(2000, Math.min(Number(out.pollMs) || 5000, 60000))
187
- out.timeoutMs = Math.max(1000, Math.min(Number(out.timeoutMs) || 6000, 15000))
188
- out.mentionEnabled = out.mentionEnabled !== false
189
- out.wsUrl = String(out.wsUrl || DEFAULT_CFG.wsUrl)
190
- out.pokeEnabled = out.pokeEnabled !== false
191
- out.privateEnabled = out.privateEnabled !== false
192
- out.privateUsers = Array.isArray(out.privateUsers) ? out.privateUsers.map((x) => Number(x)).filter(Number.isFinite) : []
193
- out.observationMs = Math.max(30000, Math.min(Number(out.observationMs) || 60000, 600000))
194
- return out
195
- } catch (error) {
196
- return { ...DEFAULT_CFG }
197
- }
198
- }
199
-
200
- // UI 保存挂载会话时只覆盖传进来的字段,其余配置原样保留;先写临时文件再换名,
201
- // 避免写一半留下坏 JSON 让后面所有工具都退回默认配置。
202
- export function saveCfg(patch) {
203
- ensureCfg()
204
- let current = {}
205
- try {
206
- const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
207
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) current = raw
208
- } catch (error) {}
209
- const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
210
- try {
211
- mkdirSync(DSH_HOME, { recursive: true })
212
- const tmp = CFG_PATH + '.tmp'
213
- writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
214
- renameSync(tmp, CFG_PATH)
215
- } catch (error) {
216
- diag('保存配置失败:' + String((error && error.message) || error))
217
- throw error
218
- }
219
- return next
220
- }
221
-
222
- function targetSessionId() {
223
- const cfg = loadCfg()
224
- return String(cfg.targetSession || '')
225
- }
226
-
227
- function sessionIdOf(agent) {
228
- try {
229
- const s = agent && agent.session
230
- return String((s && (s.id || s.sessionId)) || (agent && agent.id) || '')
231
- } catch (error) {
232
- return ''
233
- }
234
- }
235
-
236
- function findAgent(agents, id) {
237
- const raw = String(id || '').trim()
238
- if (!raw) return undefined
239
- const candidates = [raw]
240
- if (raw.startsWith('session-')) candidates.push(raw.slice('session-'.length))
241
- else candidates.push('session-' + raw)
242
- for (const one of candidates) {
243
- try {
244
- const a = agents.get(one)
245
- if (a !== undefined) return a
246
- } catch (error) {}
247
- }
248
- try {
249
- for (const a of agents.list() || []) {
250
- const sid = sessionIdOf(a)
251
- if (sid === raw || 'session-' + sid === raw || sid === raw.replace(/^session-/, '')) return a
252
- }
253
- } catch (error) {}
254
- return undefined
255
- }
256
-
257
-
258
- // ---------- 目标会话 UI 接口(由「挂载会话」组件启动) ----------
259
- // 浏览器半侧只读写 targetSession:GET 返回当前值和活着的会话,POST 写回配置文件;
260
- // 其他组件仍然每 3 秒 loadCfg(),所以保存后不需要重启。
261
- const TARGET_SESSION_ROUTE = '/api/dsh-qq-tools/target-session'
262
-
263
- function replyJson(res, status, body) {
264
- const text = JSON.stringify(body)
265
- try {
266
- res.writeHead(status, {
267
- 'content-type': 'application/json; charset=utf-8',
268
- 'cache-control': 'no-store',
269
- })
270
- res.end(text)
271
- } catch (error) {}
272
- }
273
-
274
- async function readRequestJson(req, max = 64 * 1024) {
275
- const chunks = []
276
- let size = 0
277
- for await (const chunk of req) {
278
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
279
- size += buf.length
280
- if (size > max) throw new Error('body too large')
281
- chunks.push(buf)
282
- }
283
- if (!chunks.length) return {}
284
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
285
- }
286
-
287
- function sameOriginRequest(req) {
288
- const origin = String((req.headers && req.headers.origin) || '')
289
- if (!origin) return true
290
- try {
291
- return new URL(origin).host === String((req.headers && req.headers.host) || '')
292
- } catch (error) {
293
- return false
294
- }
295
- }
296
-
297
- export function mountTargetSessionRoute(ctx) {
298
- const webServer = ctx.get('webServer')
299
- if (!webServer || typeof webServer.register !== 'function') {
300
- diag('挂载会话:宿主没有 webServer,设置页接口不可用')
301
- return
302
- }
303
- ctx.effect(() => {
304
- const dispose = webServer.register({
305
- kind: 'exact',
306
- path: TARGET_SESSION_ROUTE,
307
- handler: async (req, res) => {
308
- if (!sameOriginRequest(req)) {
309
- replyJson(res, 403, { ok: false, error: 'forbidden' })
310
- return
311
- }
312
- const method = String(req.method || 'GET').toUpperCase()
313
- if (method === 'GET') {
314
- const liveSessionIds = []
315
- try {
316
- const agents = ctx.get('agents') || ctx.agents
317
- for (const agent of (agents && agents.list()) || []) {
318
- const sid = sessionIdOf(agent)
319
- if (sid) liveSessionIds.push(sid)
320
- }
321
- } catch (error) {}
322
- replyJson(res, 200, { ok: true, targetSession: targetSessionId(), liveSessionIds })
323
- return
324
- }
325
- if (method !== 'POST') {
326
- replyJson(res, 405, { ok: false, error: 'method not allowed' })
327
- return
328
- }
329
- let body
330
- try {
331
- body = await readRequestJson(req)
332
- } catch (error) {
333
- replyJson(res, 400, { ok: false, error: 'invalid body' })
334
- return
335
- }
336
- const raw = body && typeof body.targetSession === 'string' ? body.targetSession : ''
337
- const targetSession = clean(raw).slice(0, 200)
338
- try {
339
- saveCfg({ targetSession })
340
- } catch (error) {
341
- replyJson(res, 500, { ok: false, error: 'save failed' })
342
- return
343
- }
344
- diag('目标会话已更新:' + (targetSession || '(不挂载)'))
345
- replyJson(res, 200, { ok: true, targetSession })
346
- },
347
- })
348
- return () => {
349
- try { dispose() } catch (error) {}
350
- }
351
- }, 'qq-tools: target session route')
352
- }
353
-
354
- function allowedGroup(groupId, cfg) {
355
- if (!Array.isArray(cfg.groups) || cfg.groups.length === 0) return true
356
- return cfg.groups.includes(Number(groupId))
357
- }
358
-
359
- function allowedPrivate(userId, cfg) {
360
- if (!Array.isArray(cfg.privateUsers) || cfg.privateUsers.length === 0) return true
361
- return cfg.privateUsers.includes(Number(userId))
362
- }
363
-
364
- async function onebot(action, body, cfg) {
365
- const base = String(cfg.napCat || '').replace(/\/+$/, '')
366
- if (!base) throw new Error('没有配置 OneBot 地址')
367
- const ctrl = new AbortController()
368
- const timer = setTimeout(() => ctrl.abort(new Error('OneBot 请求超时')), cfg.timeoutMs || 6000)
369
- try {
370
- const headers = { 'content-type': 'application/json' }
371
- if (cfg.accessToken) headers.authorization = 'Bearer ' + cfg.accessToken
372
- const r = await fetch(base + '/' + action, {
373
- method: 'POST',
374
- headers,
375
- body: JSON.stringify(body || {}),
376
- signal: ctrl.signal,
377
- })
378
- const text = await r.text()
379
- let data
380
- try {
381
- data = JSON.parse(text)
382
- } catch (error) {
383
- throw new Error('OneBot 返回不是 JSON:' + text.slice(0, 200))
384
- }
385
- if (!r.ok) throw new Error('OneBot HTTP ' + r.status + ':' + text.slice(0, 200))
386
- if (data && data.status && data.status !== 'ok') {
387
- throw new Error('OneBot 错误:' + String(data.message || data.wording || ('retcode ' + data.retcode)))
388
- }
389
- return data && data.data !== undefined ? data.data : data
390
- } finally {
391
- clearTimeout(timer)
392
- }
393
- }
394
-
395
- function segText(segs) {
396
- const list = Array.isArray(segs) ? segs : []
397
- let media = 0
398
- return clean(list
399
- .map((s) => {
400
- if (!s) return ''
401
- if (s.type === 'text') return String((s.data && s.data.text) || '')
402
- if (s.type === 'at') {
403
- const qq = s.data && s.data.qq
404
- return '@' + (qq === 'all' ? '全体' : String((s.data && s.data.name) || qq || ''))
405
- }
406
- if (s.type === 'image') return '[图片#' + (media++) + ']'
407
- if (s.type === 'mface') return '[表情包#' + (media++) + ']'
408
- if (s.type === 'face') {
409
- const id = s.data && s.data.id
410
- return id != null && id !== '' ? '[表情:' + id + ']' : '[表情]'
411
- }
412
- return ''
413
- })
414
- .join(''))
415
- }
416
-
417
- // 这里只做「优先展示动图」的排序提示,不决定 mediaType。
418
- // QQ 的 [动画表情] 摘要经常贴在静态 PNG/JPEG 上,所以别再信 summary 里的「动画」;
419
- // 真正是不是 GIF 由后面按字节嗅探,只有 mface 和 .gif 扩展名算动图候选。
420
- function animatedSeg(seg) {
421
- if (!seg || !seg.data) return false
422
- if (seg.type === 'mface') return true
423
- const d = seg.data
424
- return /\.gif(?:[?#]|$)/i.test(String(d.file || '')) || /\.gif(?:[?#]|$)/i.test(String(d.url || ''))
425
- }
426
-
427
- function fmtTime(sec) {
428
- const t = Number(sec)
429
- if (!Number.isFinite(t) || t <= 0) return ''
430
- const d = new Date(t * 1000)
431
- const p = (n) => String(n).padStart(2, '0')
432
- return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
433
- }
434
-
435
- function senderName(m) {
436
- const s = (m && m.sender) || {}
437
- return clean(s.card || s.nickname || s.user_id || (m && m.user_id) || '?')
438
- }
439
-
440
- function senderId(m) {
441
- const s = (m && m.sender) || {}
442
- return clean(s.user_id || (m && m.user_id) || '')
443
- }
444
-
445
- // 唤醒提示里的消息行:和戳一戳一致,固定带上昵称和 QQ 号。
446
- // 有 message_id 时附在末尾,模型可以把它传给 reply_to 做引用。
447
- export function formatIncomingLine(name, userId, text, messageId) {
448
- const who = clean(name) || 'QQ'
449
- const qq = String(userId || '?')
450
- return `${who}(${qq}):${text || '(空)'}` + (messageId != null ? `(消息 #${messageId})` : '')
451
- }
452
-
453
- // 回复风格:群聊要像随手打字,短、分条、不刷屏地 @ 和引用。
454
- export const CHAT_STYLE = '回复像群友随手打字:想连发几条就**多次调用同一个工具**(一次调用只发一条),每条一句短话(十几二十个字),最多三次调用;'
455
- + '不要句号收尾,不要写长段落,不要 Markdown,不要把昵称或 QQ 号写进正文,不要每句都 @ 或引用——'
456
- + '只有确实要指代某条消息时才传 reply_to,只有必须提醒某个人时才传 at_user_ids。'
457
-
458
- const CHAT_BUBBLE_CHARS = 80
459
-
460
- // 一条消息的清理:压掉复制来的对齐空格和多行,去掉正文开头重复的 @昵称。
461
- function cleanBubble(value) {
462
- return String(value || '')
463
- .replace(/\r/g, '')
464
- .replace(/[\u3000\t ]+/g, ' ')
465
- .replace(/\s*\n\s*/g, ' ')
466
- .trim()
467
- .replace(/^@[^\s@]{1,40}\s+/, '')
468
- .slice(0, CHAT_BUBBLE_CHARS)
469
- }
470
-
471
- // 一次工具调用只发一条:想分几条说就让模型多次调用工具。
472
- export function chatText(args) {
473
- return cleanBubble(args && args.message)
474
- }
475
-
476
-
477
- function renderTool(_args, v) {
478
- if (!v || !v.ok) return [{ type: 'text', text: 'QQ 工具失败:' + String((v && v.error) || '未知错误') }]
479
- if (Array.isArray(v.groups)) {
480
- if (!v.groups.length) return [{ type: 'text', text: '现在没有允许查看的群。' }]
481
- return [{ type: 'text', text: '可以去的群:\n' + v.groups.map((g) => `- ${g.name || g.id}(群号 ${g.id},${g.member_count} 人)`).join('\n') }]
482
- }
483
- if (Array.isArray(v.friends)) {
484
- if (!v.friends.length) return [{ type: 'text', text: v.query ? '没有匹配的好友。' : '好友列表是空的。' }]
485
- const lines = v.friends.map((f) => `- ${f.remark || f.nickname || f.user_id}(${f.nickname || ''},QQ ${f.user_id})`)
486
- return [{ type: 'text', text: `你的好友(${v.total} 个):\n` + lines.join('\n') }]
487
- }
488
- if (Array.isArray(v.messages)) {
489
- const where = v.group_id != null ? `群 ${v.group_id}` : `QQ ${v.user_id}`
490
- if (!v.messages.length) return [{ type: 'text', text: `${where} 最近没有消息。` }]
491
- const lines = v.messages.map((m) => `${m.t || ''} [#${m.message_id || '?'}] ${m.sender}(${m.user_id}):${m.text || '(空)'}`)
492
- const blocks = [{ type: 'text', text: `${where} 最近 ${v.count} 条:\n` + lines.join('\n') }]
493
- if (Array.isArray(v.images) && v.images.length) {
494
- blocks.push({ type: 'text', text: '下面是这些图片(按消息顺序):' })
495
- for (const img of v.images) {
496
- if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
497
- if (img && img.block) blocks.push(img.block)
498
- }
499
- }
500
- return blocks
501
- }
502
- if (Array.isArray(v.members)) {
503
- const lines = v.members.map((m) => `- ${m.card || m.nickname || m.user_id}(QQ ${m.user_id},${m.role || 'member'})`)
504
- return [{ type: 'text', text: `群 ${v.group_id} 成员:\n` + (lines.join('\n') || '(空)') }]
505
- }
506
- if (v.poke) {
507
- return [{ type: 'text', text: `戳回去了:${v.group_id ? '群 ' + v.group_id : '私聊'} QQ ${v.user_id}。` }]
508
- }
509
- if (v.group_id != null && v.message_id != null) {
510
- return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'})。` }]
511
- }
512
- if (v.user_id != null && v.message_id != null) {
513
- return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'})。` }]
514
- }
515
- return [{ type: 'text', text: JSON.stringify(v).slice(0, 2000) }]
516
- }
517
-
518
-
519
- function listOf(data, key) {
520
- if (Array.isArray(data)) return data
521
- if (data && Array.isArray(data[key])) return data[key]
522
- return []
523
- }
524
-
525
- export function makeListTool() {
526
- return {
527
- name: 'qq_list_groups',
528
- description:
529
- '看看你所在的 QQ 群。返回允许你查看/发言的群号、群名、人数。' +
530
- '群聊里想说话时先用这个挑群;群号可以直接传给 qq_get_group_history / qq_send_group_message。',
531
- parameters: { type: 'object', properties: {}, additionalProperties: false },
532
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
533
- async execute() {
534
- const cfg = loadCfg()
535
- try {
536
- const data = await onebot('get_group_list', {}, cfg)
537
- const groups = (Array.isArray(data) ? data : [])
538
- .map((g) => ({
539
- id: Number(g.group_id),
540
- name: clean(g.group_name || g.group_id || ''),
541
- member_count: Number(g.member_count || 0),
542
- }))
543
- .filter((g) => Number.isFinite(g.id) && allowedGroup(g.id, cfg))
544
- return { ok: true, groups }
545
- } catch (error) {
546
- return { ok: false, error: String((error && error.message) || error) }
547
- }
548
- },
549
- render(_args, v) {
550
- if (!v || !v.ok) return [{ type: 'text', text: '看群列表失败:' + String((v && v.error) || '未知错误') }]
551
- if (!v.groups.length) return [{ type: 'text', text: '现在没有允许查看的群。' }]
552
- return [{
553
- type: 'text',
554
- text: '可以去的群:\n' + v.groups.map((g) => `- ${g.name || g.id}(群号 ${g.id},${g.member_count} 人)`).join('\n'),
555
- }]
556
- },
557
- }
558
- }
559
-
560
-
561
- // ---------- 图片类型与尺寸 ----------
562
- // DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
563
- // animated 提示都可能撒谎,所以只信字节。大图不依赖 sharp:DSH 默认单张源图
564
- // 上限 20MiB、归一化目标 4MiB,直接交给它处理;sharp 只用于 GIF 拆帧和
565
- // admit 被尺寸拒绝后的兜底重编码。
566
- const ADMIT_IMAGE_LIMIT = 6
567
- const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
568
- const IMAGE_MEDIA_TYPES = {
569
- png: 'image/png',
570
- jpeg: 'image/jpeg',
571
- webp: 'image/webp',
572
- gif: 'image/gif',
573
- }
574
-
575
- function sniffImageType(bytes) {
576
- if (!bytes || bytes.length < 12) return ''
577
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
578
- bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'
579
- if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
580
- if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
581
- (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif'
582
- if (bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
583
- return ''
584
- }
585
-
586
- async function sharpImageType(bytes) {
587
- if (!sharp) return ''
588
- try {
589
- const meta = await sharp(bytes, { animated: true }).metadata()
590
- return IMAGE_MEDIA_TYPES[String((meta && meta.format) || '').toLowerCase()] || ''
591
- } catch (error) {
592
- return ''
593
- }
594
- }
595
-
596
- // DSH 只收 png/jpeg/webp/gif;sharp 能解的其他格式(TIFF 等)先转成它认识的格式。
597
- async function convertImage(bytes) {
598
- if (!sharp) return null
599
- try {
600
- const meta = await sharp(bytes).metadata()
601
- const hasAlpha = !!meta.hasAlpha
602
- const out = hasAlpha
603
- ? await sharp(bytes).webp({ quality: 82 }).toBuffer()
604
- : await sharp(bytes).jpeg({ quality: 82 }).toBuffer()
605
- return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
606
- } catch (error) {
607
- return null
608
- }
609
- }
610
-
611
- // 大图不丢:超过 DSH 归一化目标就先缩到 2048 长边并重编码,通常能压到 1MiB 以内。
612
- async function shrinkImage(bytes) {
613
- if (!sharp || !bytes || !bytes.length) return null
614
- try {
615
- const meta = await sharp(bytes, { animated: false }).metadata()
616
- const width = Number(meta && meta.width) || 0
617
- const height = Number(meta && meta.height) || 0
618
- if (!width || !height) return null
619
- const hasAlpha = !!meta.hasAlpha
620
- const scale = Math.min(1, 2048 / Math.max(width, height))
621
- const targetWidth = Math.max(1, Math.round(width * scale))
622
- const targetHeight = Math.max(1, Math.round(height * scale))
623
- for (const quality of [82, 70, 58]) {
624
- const pipeline = sharp(bytes, { animated: false })
625
- .rotate()
626
- .resize({ width: targetWidth, height: targetHeight, fit: 'inside', withoutEnlargement: true })
627
- const out = hasAlpha
628
- ? await pipeline.webp({ quality }).toBuffer()
629
- : await pipeline.jpeg({ quality }).toBuffer()
630
- if (out.length <= IMAGE_SOFT_LIMIT) {
631
- return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
632
- }
633
- }
634
- } catch (error) {}
635
- return null
636
- }
637
-
638
-
639
- async function gifFrames(bytes, max = 3) {
640
- if (!sharp || !bytes || !bytes.length) return null
641
- try {
642
- const meta = await sharp(bytes, { animated: true }).metadata()
643
- const pages = Number(meta.pages || 1)
644
- if (!(pages > 1)) return null
645
- const indices = pages <= max
646
- ? Array.from({ length: pages }, (_, i) => i)
647
- : [0, Math.floor((pages - 1) / 2), pages - 1]
648
- const out = []
649
- for (const i of indices) {
650
- try {
651
- const buf = await sharp(bytes, { page: i, pages: 1 })
652
- .resize({ width: 1024, withoutEnlargement: true })
653
- .png()
654
- .toBuffer()
655
- if (buf && buf.length) out.push({ bytes: buf, page: i + 1, pages })
656
- } catch (error) {}
657
- }
658
- return out.length ? out : null
659
- } catch (error) {
660
- return null
661
- }
662
- }
663
-
664
- async function imageBytes(data, cfg) {
665
- const d = data || {}
666
- const url = String(d.url || '').trim()
667
- if (/^https?:/i.test(url)) {
668
- try {
669
- const ctrl = new AbortController()
670
- const timer = setTimeout(() => ctrl.abort(), 8000)
671
- try {
672
- const r = await fetch(url, { signal: ctrl.signal })
673
- if (r.ok) return Buffer.from(await r.arrayBuffer())
674
- } finally {
675
- clearTimeout(timer)
676
- }
677
- } catch (error) {}
678
- }
679
- const file = String(d.file || '').trim()
680
- if (file.startsWith('base64://')) {
681
- try { return Buffer.from(file.slice('base64://'.length), 'base64') } catch (error) {}
682
- }
683
- if (/^https?:/i.test(file)) {
684
- try {
685
- const r = await fetch(file)
686
- if (r.ok) return Buffer.from(await r.arrayBuffer())
687
- } catch (error) {}
688
- }
689
- if (file && (file.includes('/') || file.includes('\\'))) {
690
- try { return readFileSync(file) } catch (error) {}
691
- }
692
- if (file) {
693
- try {
694
- const info = await onebot('get_image', { file }, cfg)
695
- const u = String((info && (info.url || info.file)) || '').trim()
696
- if (/^https?:/i.test(u)) {
697
- const r = await fetch(u)
698
- if (r.ok) return Buffer.from(await r.arrayBuffer())
699
- }
700
- if (u && (u.includes('/') || u.includes('\\'))) {
701
- try { return readFileSync(u) } catch (error) {}
702
- }
703
- } catch (error) {}
704
- }
705
- return null
706
- }
707
-
708
- function admitErrorText(error) {
709
- if (!error) return '未知错误'
710
- return (error.code ? String(error.code) + ':' : '') + String(error.message || error)
711
- }
712
-
713
- // 单张被拒时可以重编码再试;批量/存储层错误重试没有意义。
714
- const RETRYABLE_IMAGE_ERRORS = new Set([
715
- 'IMAGE_TYPE_MISMATCH',
716
- 'INVALID_IMAGE',
717
- 'UNSUPPORTED_IMAGE_TYPE',
718
- 'IMAGE_TOO_LARGE',
719
- 'IMAGE_TOO_MANY_PIXELS',
720
- 'IMAGE_DIMENSION_TOO_LARGE',
721
- ])
722
-
723
- async function admitOneImage(attachments, block) {
724
- try {
725
- const out = await attachments.admitPromptContent([block])
726
- const hit = (Array.isArray(out) ? out : []).find((x) => x && x.type === 'image')
727
- return hit ? { block: hit } : { error: new Error('附件层没有返回图片') }
728
- } catch (error) {
729
- return { error }
730
- }
731
- }
732
-
733
- async function retryImageBlock(block) {
734
- let bytes
735
- try {
736
- bytes = Buffer.from(String((block && block.data) || ''), 'base64')
737
- } catch (error) {
738
- return null
739
- }
740
- if (!bytes || !bytes.length) return null
741
- const type = sniffImageType(bytes)
742
- const fixed = type ? await shrinkImage(bytes) : await convertImage(bytes)
743
- if (!fixed) return null
744
- return { ...block, data: fixed.bytes.toString('base64'), mediaType: fixed.mediaType }
745
- }
746
-
747
- // 一张坏图不能拖垮整批:先整批送,失败后退回逐张,逐张失败的再重编码试一次。
748
- async function admitPreparedImages(attachments, prepared) {
749
- const blocks = prepared.map((x) => x.block)
750
- try {
751
- const out = await attachments.admitPromptContent(blocks)
752
- const images = (Array.isArray(out) ? out : []).filter((x) => x && x.type === 'image')
753
- if (images.length === blocks.length) {
754
- return images.map((block, i) => ({ caption: prepared[i].caption, block }))
755
- }
756
- diag('图片 admit 返回数量不符(' + images.length + '/' + blocks.length + '),改为逐张处理')
757
- } catch (error) {
758
- diag('图片批量 admit 失败,改为逐张重试:' + admitErrorText(error))
759
- }
760
- const admitted = []
761
- let firstError = ''
762
- for (const item of prepared) {
763
- let result = await admitOneImage(attachments, item.block)
764
- if (result.error && RETRYABLE_IMAGE_ERRORS.has(String(result.error.code || ''))) {
765
- const retried = await retryImageBlock(item.block)
766
- if (retried) result = await admitOneImage(attachments, retried)
767
- }
768
- if (result.block) admitted.push({ caption: item.caption, block: result.block })
769
- else if (!firstError) firstError = admitErrorText(result.error)
770
- }
771
- if (firstError && admitted.length < prepared.length) {
772
- diag('有图片没能进来(已跳过,其余保留):' + firstError)
773
- }
774
- return admitted
775
- }
776
-
777
- export function admitImagesFor(ctx) {
778
- return async function admitImages(list) {
779
- const attachments = ctx.attachments || (typeof ctx.get === 'function' ? ctx.get('attachments') : null)
780
- if (!attachments || typeof attachments.admitPromptContent !== 'function') return []
781
- const prepared = []
782
- for (const x of Array.isArray(list) ? list : []) {
783
- if (prepared.length >= ADMIT_IMAGE_LIMIT) break
784
- let bytes = await imageBytes(x.data, x.cfg || loadCfg())
785
- if (!bytes || !bytes.length) continue
786
- const name = String(x.name || 'qq-image.jpg')
787
- // animated 只是提示,type 一律按字节来;猜错会被 DSH 按 IMAGE_TYPE_MISMATCH 整批拒掉。
788
- let mediaType = sniffImageType(bytes) || (await sharpImageType(bytes))
789
- if (!mediaType) {
790
- const converted = await convertImage(bytes)
791
- if (!converted) {
792
- diag('图片跳过:认不出格式(' + name + ')')
793
- continue
794
- }
795
- bytes = converted.bytes
796
- mediaType = converted.mediaType
797
- }
798
- if (mediaType === 'image/gif') {
799
- const frames = await gifFrames(bytes, 3)
800
- if (frames) {
801
- for (const f of frames) {
802
- prepared.push({
803
- caption: String(x.caption || '') + `(GIF 第 ${f.page}/${f.pages} 帧)`,
804
- block: {
805
- type: 'image',
806
- data: f.bytes.toString('base64'),
807
- mediaType: 'image/png',
808
- name: name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`,
809
- },
810
- })
811
- if (prepared.length >= ADMIT_IMAGE_LIMIT) break
812
- }
813
- continue
814
- }
815
- if (!sharp && !gifWarned) {
816
- gifWarned = true
817
- diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
818
- }
819
- }
820
- // 大图不在这里预缩放:DSH 自己会把源图归一化到 4MiB,20MiB 以内都能收。
821
- // 只有 admit 因尺寸被拒时才在 retryImageBlock 里用 sharp 兜底重编码。
822
- prepared.push({
823
- caption: String(x.caption || ''),
824
- block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
825
- })
826
- }
827
- if (!prepared.length) return []
828
- return admitPreparedImages(attachments, prepared)
829
- }
830
- }
831
-
832
- export function makeHistoryTool(admitImages) {
833
- return {
834
- name: 'qq_get_group_history',
835
- description:
836
- '看某个群最近的聊天记录(只读)。默认最近 30 条,最多 50 条。' +
837
- '主动搭话或有人 @ 你时,可以用它看 2~3 个群,再决定要不要说话、在哪个群说。' +
838
- '群号从 qq_list_groups 或 @ 提醒里拿。消息前面的 #编号 是 message_id,' +
839
- '可以用 qq_send_face 回复,或用 qq_favorite_face 收藏消息里的图片。',
840
- parameters: {
841
- type: 'object',
842
- properties: {
843
- group_id: { type: 'number', description: '群号' },
844
- count: { type: 'number', description: '看最近多少条,默认 30,最多 50' },
845
- include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
846
- },
847
- required: ['group_id'],
848
- additionalProperties: false,
849
- },
850
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
851
- async execute(args) {
852
- const cfg = loadCfg()
853
- const groupId = Number(args && args.group_id)
854
- if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
855
- if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
856
- const count = Math.max(1, Math.min(Number(args && args.count) || cfg.historyCount, cfg.maxHistoryCount))
857
- try {
858
- const data = await onebot('get_group_msg_history', { group_id: groupId, count }, cfg)
859
- const raw = listOf(data, 'messages')
860
- const messages = raw
861
- .map((m) => ({
862
- time: Number(m.time || 0),
863
- t: fmtTime(m.time),
864
- sender: senderName(m),
865
- user_id: senderId(m),
866
- text: segText(m.message),
867
- message_id: m.message_id,
868
- }))
869
- .sort((a, b) => a.time - b.time)
870
- let images = []
871
- if (args && args.include_images !== false) {
872
- const picked = []
873
- for (const m of raw.slice().sort((a, b) => (a.time || 0) - (b.time || 0))) {
874
- const segs = Array.isArray(m.message) ? m.message : []
875
- for (const seg of segs) {
876
- if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
877
- picked.push({ m, seg, animated: animatedSeg(seg) })
878
- }
879
- }
880
- const anim = picked.filter((x) => x.animated).slice(-2)
881
- const stat = picked.filter((x) => !x.animated).slice(-2)
882
- const last = [...anim, ...stat].slice(0, 4)
883
- if (last.length && typeof admitImages === 'function') {
884
- images = await admitImages(last.map(({ m, seg, animated }, i) => ({
885
- data: seg.data,
886
- cfg,
887
- animated,
888
- name: (seg.data && seg.data.file) || `qq-${m.message_id || 'x'}-${i}.jpg`,
889
- caption: `群 ${groupId} ${senderName(m)} 发的图片(消息 ${m.message_id || '?'})`,
890
- })))
891
- }
892
- }
893
- return { ok: true, group_id: groupId, count: messages.length, messages, images }
894
- } catch (error) {
895
- return { ok: false, error: String((error && error.message) || error) }
896
- }
897
- },
898
- render(_args, v) {
899
- if (!v || !v.ok) return [{ type: 'text', text: '看群记录失败:' + String((v && v.error) || '未知错误') }]
900
- if (!v.messages.length) return [{ type: 'text', text: `群 ${v.group_id} 最近没有消息。` }]
901
- const lines = v.messages.map((m) => `${m.t || ''} [#${m.message_id || '?'}] ${m.sender}(${m.user_id}):${m.text || '(空)'}`)
902
- return [{ type: 'text', text: `群 ${v.group_id} 最近 ${v.count} 条:\n` + lines.join('\n') }]
903
- },
904
- }
905
- }
906
-
907
- export function makeMembersTool() {
908
- return {
909
- name: 'qq_get_group_members',
910
- description: '看看某个群的成员(昵称、群名片、QQ 号、身份)。需要知道谁是谁时用。',
911
- parameters: {
912
- type: 'object',
913
- properties: {
914
- group_id: { type: 'number', description: '群号' },
915
- },
916
- required: ['group_id'],
917
- additionalProperties: false,
918
- },
919
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
920
- async execute(args) {
921
- const cfg = loadCfg()
922
- const groupId = Number(args && args.group_id)
923
- if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
924
- if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
925
- try {
926
- const data = await onebot('get_group_member_list', { group_id: groupId }, cfg)
927
- const members = listOf(data, 'members').slice(0, 100).map((m) => ({
928
- user_id: String(m.user_id || ''),
929
- nickname: String(m.nickname || ''),
930
- card: String(m.card || ''),
931
- role: String(m.role || ''),
932
- }))
933
- return { ok: true, group_id: groupId, members }
934
- } catch (error) {
935
- return { ok: false, error: String((error && error.message) || error) }
936
- }
937
- },
938
- render(_args, v) {
939
- if (!v || !v.ok) return [{ type: 'text', text: '看群成员失败:' + String((v && v.error) || '未知错误') }]
940
- const lines = v.members.map((m) => `- ${m.card || m.nickname || m.user_id}(QQ ${m.user_id},${m.role || 'member'})`)
941
- return [{ type: 'text', text: `群 ${v.group_id} 成员:\n` + (lines.join('\n') || '(空)') }]
942
- },
943
- }
944
- }
945
-
946
- export function makeSendTool(onSent) {
947
- return {
948
- name: 'qq_send_group_message',
949
- description:
950
- '在指定群里发一条消息。发之前先确认群号;只发到允许的群。想分几条说就**多次调用本工具**,一次一条。' +
951
- 'reply_to 传消息前面的 #编号 可以引用那条消息;at_user_ids 传 QQ 号可以 @ 人,at_all=true 是 @全体成员。' +
952
- CHAT_STYLE +
953
- '不想说话就不要调用这个工具;调用后不要再重复说自己发了什么。',
954
- parameters: {
955
- type: 'object',
956
- properties: {
957
- group_id: { type: 'number', description: '群号' },
958
- message: { type: 'string', description: '这一条的文字,一句短话;只 @ 不说话时可以留空' },
959
- reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选', },
960
- at_user_ids: { type: 'array', items: { type: 'number' }, description: '要 @ 的 QQ 号列表,可选', },
961
- at_all: { type: 'boolean', description: 'true = @全体成员,可选' },
962
- },
963
- required: ['group_id'],
964
- additionalProperties: false,
965
- },
966
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
967
- async execute(args) {
968
- const cfg = loadCfg()
969
- const groupId = Number(args && args.group_id)
970
- const text = chatText(args)
971
- const atIds = [...new Set(
972
- (Array.isArray(args && args.at_user_ids) ? args.at_user_ids : [])
973
- .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0),
974
- )].slice(0, 20)
975
- const atAll = args && args.at_all === true
976
- if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
977
- if (!text && !atIds.length && !atAll) return { ok: false, error: '消息是空的' }
978
- if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
979
- try {
980
- const replyTo = args && args.reply_to
981
- const message = []
982
- if (replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
983
- if (atAll) message.push({ type: 'at', data: { qq: 'all' } })
984
- else for (const qq of atIds) message.push({ type: 'at', data: { qq: String(qq) } })
985
- if (text) message.push({ type: 'text', data: { text } })
986
- const data = await onebot('send_group_msg', { group_id: groupId, message }, cfg)
987
- try { if (typeof onSent === 'function') onSent(groupId, '发群消息') } catch (error) {}
988
- return {
989
- ok: true,
990
- group_id: groupId,
991
- message_id: data && data.message_id,
992
- reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
993
- at_user_ids: atAll ? [] : atIds,
994
- at_all: atAll,
995
- }
996
- } catch (error) {
997
- return { ok: false, error: String((error && error.message) || error) }
998
- }
999
- },
1000
- render(_args, v) {
1001
- if (!v || !v.ok) return [{ type: 'text', text: '发群消息失败:' + String((v && v.error) || '未知错误') }]
1002
- const extra = []
1003
- if (v.reply_to) extra.push('引用了 #' + v.reply_to)
1004
- if (v.at_all) extra.push('@全体成员')
1005
- else if (Array.isArray(v.at_user_ids) && v.at_user_ids.length) extra.push('@了 ' + v.at_user_ids.join('、'))
1006
- return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'}${extra.length ? ',' + extra.join(',') : ''})。` }]
1007
- },
1008
- }
1009
- }
1010
-
1011
-
1012
- // ---------- 私聊:好友列表 / 私聊记录 / 发私聊消息 ----------
1013
- export function makeListFriendsTool() {
1014
- return {
1015
- name: 'qq_list_friends',
1016
- description:
1017
- '看看你的 QQ 好友列表。想私聊谁时先用这个拿 QQ 号;可以按昵称、备注或 QQ 号搜。' +
1018
- '私聊消息唤醒里也会直接给出发送者的 QQ 号。',
1019
- parameters: {
1020
- type: 'object',
1021
- properties: {
1022
- query: { type: 'string', description: '按昵称、备注或 QQ 号模糊找;不传就列全部' },
1023
- count: { type: 'number', description: '最多列多少个,默认 100,最多 500' },
1024
- },
1025
- additionalProperties: false,
1026
- },
1027
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1028
- async execute(args) {
1029
- const cfg = loadCfg()
1030
- const q = String((args && args.query) || '').trim().toLowerCase()
1031
- const count = Math.max(1, Math.min(Number(args && args.count) || 100, 500))
1032
- try {
1033
- const data = await onebot('get_friend_list', {}, cfg)
1034
- const friends = (Array.isArray(data) ? data : [])
1035
- .map((f) => ({
1036
- user_id: String(f.user_id || ''),
1037
- nickname: clean(f.nickname),
1038
- remark: clean(f.remark),
1039
- }))
1040
- .filter((f) => f.user_id && allowedPrivate(f.user_id, cfg))
1041
- .filter((f) => !q || (f.nickname + ' ' + f.remark + ' ' + f.user_id).toLowerCase().includes(q))
1042
- return { ok: true, query: q, total: friends.length, friends: friends.slice(0, count) }
1043
- } catch (error) {
1044
- return { ok: false, error: String((error && error.message) || error) }
1045
- }
1046
- },
1047
- render(_args, v) {
1048
- if (!v || !v.ok) return [{ type: 'text', text: '看好友列表失败:' + String((v && v.error) || '未知错误') }]
1049
- if (!v.friends.length) return [{ type: 'text', text: v.query ? '没有匹配的好友。' : '好友列表是空的。' }]
1050
- const lines = v.friends.map((f) => `- ${f.remark || f.nickname || f.user_id}(${f.nickname || ''},QQ ${f.user_id})`)
1051
- const more = v.total > v.friends.length ? `,只列了前 ${v.friends.length} 个` : ''
1052
- return [{ type: 'text', text: `你的好友(${v.total} 个${more}):\n` + lines.join('\n') }]
1053
- },
1054
- }
1055
- }
1056
-
1057
- export function makePrivateHistoryTool(admitImages) {
1058
- return {
1059
- name: 'qq_get_private_history',
1060
- description:
1061
- '看和某个 QQ 好友的私聊记录(只读)。默认最近 30 条,最多 50 条。' +
1062
- '消息前面的 #编号 是 message_id,可以用于回复或收藏图片。' +
1063
- 'user_id 从 qq_list_friends 或私聊唤醒提示里拿。',
1064
- parameters: {
1065
- type: 'object',
1066
- properties: {
1067
- user_id: { type: 'number', description: '对方的 QQ 号' },
1068
- count: { type: 'number', description: '看最近多少条,默认 30,最多 50' },
1069
- include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
1070
- },
1071
- required: ['user_id'],
1072
- additionalProperties: false,
1073
- },
1074
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1075
- async execute(args) {
1076
- const cfg = loadCfg()
1077
- const userId = Number(args && args.user_id)
1078
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1079
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1080
- const count = Math.max(1, Math.min(Number(args && args.count) || cfg.historyCount, cfg.maxHistoryCount))
1081
- try {
1082
- const data = await onebot('get_friend_msg_history', { user_id: userId, count }, cfg)
1083
- const raw = listOf(data, 'messages')
1084
- if (!selfId) {
1085
- try {
1086
- const info = await onebot('get_login_info', {}, cfg)
1087
- selfId = String((info && info.user_id) || '')
1088
- } catch (error) {}
1089
- }
1090
- const messages = raw
1091
- .map((m) => {
1092
- const uid = senderId(m)
1093
- const isSelf = !!selfId && uid === selfId
1094
- return {
1095
- time: Number(m.time || 0),
1096
- t: fmtTime(m.time),
1097
- sender: isSelf ? '我' : senderName(m),
1098
- user_id: uid,
1099
- self: isSelf,
1100
- text: segText(m.message),
1101
- message_id: m.message_id,
1102
- }
1103
- })
1104
- .sort((a, b) => a.time - b.time)
1105
- let images = []
1106
- if (args && args.include_images !== false) {
1107
- const picked = []
1108
- for (const m of raw.slice().sort((a, b) => (a.time || 0) - (b.time || 0))) {
1109
- const segs = Array.isArray(m.message) ? m.message : []
1110
- for (const seg of segs) {
1111
- if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
1112
- picked.push({ m, seg, animated: animatedSeg(seg) })
1113
- }
1114
- }
1115
- const anim = picked.filter((x) => x.animated).slice(-2)
1116
- const stat = picked.filter((x) => !x.animated).slice(-2)
1117
- const last = [...anim, ...stat].slice(0, 4)
1118
- if (last.length && typeof admitImages === 'function') {
1119
- images = await admitImages(last.map(({ m, seg, animated }, i) => ({
1120
- data: seg.data,
1121
- cfg,
1122
- animated,
1123
- name: (seg.data && seg.data.file) || `qq-private-${m.message_id || 'x'}-${i}.jpg`,
1124
- caption: `私聊 ${senderName(m)} 发的图片(消息 ${m.message_id || '?'})`,
1125
- })))
1126
- }
1127
- }
1128
- return { ok: true, user_id: userId, count: messages.length, messages, images }
1129
- } catch (error) {
1130
- return { ok: false, error: String((error && error.message) || error) }
1131
- }
1132
- },
1133
- render(_args, v) {
1134
- if (!v || !v.ok) return [{ type: 'text', text: '看私聊记录失败:' + String((v && v.error) || '未知错误') }]
1135
- if (!v.messages.length) return [{ type: 'text', text: `和 QQ ${v.user_id} 最近没有消息。` }]
1136
- const lines = v.messages.map((m) => `${m.t || ''} [#${m.message_id || '?'}] ${m.sender}(${m.user_id}):${m.text || '(空)'}`)
1137
- const blocks = [{ type: 'text', text: `和 QQ ${v.user_id} 最近 ${v.count} 条:\n` + lines.join('\n') }]
1138
- for (const img of Array.isArray(v.images) ? v.images : []) {
1139
- if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
1140
- if (img && img.block) blocks.push(img.block)
1141
- }
1142
- return blocks
1143
- },
1144
- }
1145
- }
1146
-
1147
- export function makeSendPrivateTool() {
1148
- return {
1149
- name: 'qq_send_private_message',
1150
- description:
1151
- '给某个 QQ 好友发一条私聊消息。user_id 从 qq_list_friends 或私聊唤醒提示里拿。想分几条说就多次调用本工具,一次一条。' +
1152
- 'reply_to 传消息前面的 #编号 可以引用那条消息。' +
1153
- CHAT_STYLE +
1154
- '不想说话就不要调用;调用后不要再重复说自己发了什么。',
1155
- parameters: {
1156
- type: 'object',
1157
- properties: {
1158
- user_id: { type: 'number', description: '对方的 QQ 号' },
1159
- message: { type: 'string', description: '这一条的文字,一句短话' },
1160
- reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
1161
- group_id: { type: 'number', description: '临时会话的来源群号,可选;普通好友私聊不用传' },
1162
- },
1163
- required: ['user_id', 'message'],
1164
- additionalProperties: false,
1165
- },
1166
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1167
- async execute(args) {
1168
- const cfg = loadCfg()
1169
- const userId = Number(args && args.user_id)
1170
- const text = chatText(args)
1171
- const groupId = args && args.group_id != null ? Number(args.group_id) : 0
1172
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1173
- if (!text) return { ok: false, error: '消息是空的' }
1174
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1175
- try {
1176
- const replyTo = args && args.reply_to
1177
- const message = []
1178
- if (replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
1179
- message.push({ type: 'text', data: { text } })
1180
- const body = { user_id: userId, message }
1181
- if (groupId) body.group_id = groupId
1182
- const data = await onebot('send_private_msg', body, cfg)
1183
- return {
1184
- ok: true,
1185
- user_id: userId,
1186
- message_id: data && data.message_id,
1187
- reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
1188
- }
1189
- } catch (error) {
1190
- return { ok: false, error: String((error && error.message) || error) }
1191
- }
1192
- },
1193
- render(_args, v) {
1194
- if (!v || !v.ok) return [{ type: 'text', text: '发私聊消息失败:' + String((v && v.error) || '未知错误') }]
1195
- const quote = v.reply_to ? ',引用了 #' + v.reply_to : ''
1196
- return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'}${quote})。` }]
1197
- },
1198
- }
1199
- }
1200
-
1201
-
1202
- // ---------- 表情:系统表情编号表 + SnowLuma 收藏表情 ----------
1203
- // 发表情走 OneBot 11 的 face 段,SnowLuma 会按表情目录自动选 classic / small / super 编码。
1204
- // 收藏表情走 SnowLuma 的自定义表情扩展接口:
1205
- // fetch_custom_face_detail / add_custom_face / delete_custom_face /
1206
- // modify_custom_face / move_custom_face_to_front / send_custom_face
1207
- // 这些接口 NapCat 没有;读不到时只影响收藏表情,系统表情照常可用。
1208
- const COMMON_FACES = [
1209
- [0, '惊讶'], [1, '撇嘴'], [2, '色'], [3, '发呆'], [4, '得意'], [5, '流泪'], [6, '害羞'],
1210
- [7, '闭嘴'], [8, '睡'], [9, '大哭'], [10, '尴尬'], [11, '发怒'], [12, '调皮'], [13, '呲牙'],
1211
- [14, '微笑'], [15, '难过'], [16, '酷'], [17, '抓狂'], [18, '吐'], [19, '偷笑'], [20, '可爱'],
1212
- [21, '白眼'], [22, '傲慢'], [23, '饥饿'], [24, '困'], [25, '惊恐'], [26, '流汗'], [27, '憨笑'],
1213
- [28, '大兵'], [29, '奋斗'], [30, '咒骂'], [31, '疑问'], [32, '嘘'], [33, '晕'], [34, '折磨'],
1214
- [35, '衰'], [36, '骷髅'], [37, '敲打'], [38, '再见'], [39, '擦汗'], [40, '抠鼻'], [41, '鼓掌'],
1215
- [42, '糗大了'], [43, '坏笑'], [44, '左哼哼'], [45, '右哼哼'], [46, '哈欠'], [47, '鄙视'],
1216
- [48, '委屈'], [49, '快哭了'], [50, '阴险'], [51, '亲亲'], [52, '吓'], [53, '可怜'], [54, '菜刀'],
1217
- [55, '西瓜'], [56, '啤酒'], [57, '篮球'], [58, '乒乓'], [59, '咖啡'], [60, '饭'], [61, '猪头'],
1218
- [62, '玫瑰'], [63, '凋谢'], [64, '示爱'], [65, '爱心'], [66, '心碎'], [67, '蛋糕'], [68, '闪电'],
1219
- [69, '炸弹'], [70, '刀'], [71, '足球'], [72, '瓢虫'], [73, '便便'], [74, '月亮'], [75, '太阳'],
1220
- [76, '礼物'], [77, '拥抱'], [78, '强'], [79, '弱'], [80, '握手'], [81, '胜利'], [82, '抱拳'],
1221
- [83, '勾引'], [84, '拳头'], [85, '差劲'], [86, '爱你'], [87, 'NO'], [88, 'OK'], [89, '爱情'],
1222
- [90, '飞吻'], [91, '跳跳'], [92, '发抖'], [93, '怄火'], [94, '转圈'], [95, '磕头'], [96, '回头'],
1223
- [97, '跳绳'], [98, '挥手'], [99, '激动'], [100, '街舞'], [101, '献吻'], [102, '左太极'],
1224
- [103, '右太极'], [104, '双喜'], [105, '鞭炮'], [106, '灯笼'], [107, '发财'], [108, 'K歌'],
1225
- [109, '购物'], [110, '邮件'], [111, '帅'], [112, '喝彩'], [113, '祈祷'], [114, '爆筋'],
1226
- [115, '棒棒糖'], [116, '喝奶'], [118, '香蕉'], [119, '飞机'], [120, '开车'], [124, '多云'],
1227
- [125, '下雨'], [126, '钞票'], [127, '熊猫'], [128, '灯泡'], [129, '风车'], [130, '闹钟'],
1228
- [131, '打伞'], [132, '彩球'], [133, '钻戒'], [134, '沙发'], [136, '药'], [137, '手枪'],
1229
- [138, '青蛙'],
1230
- ].map(([id, name]) => ({ id, name }))
1231
-
1232
- const CUSTOM_FACE_COUNT = 100
1233
-
1234
- function sysFaceOf(entry) {
1235
- const id = Number(entry && entry.q_sid)
1236
- return {
1237
- face_id: Number.isInteger(id) && id >= 0 ? id : null,
1238
- name: clean(entry && (entry.q_des || entry.desc || '')),
1239
- aliases: Array.isArray(entry && entry.emoji_name_alias) ? entry.emoji_name_alias.map(clean).filter(Boolean) : [],
1240
- is_super: !!(entry && entry.is_super),
1241
- url: String((entry && entry.url) || ''),
1242
- }
1243
- }
1244
-
1245
- function customFaceOf(entry) {
1246
- return {
1247
- emoji_id: String((entry && (entry.emoji_id || entry.emojiId || entry.resId)) || ''),
1248
- desc: clean(entry && entry.desc),
1249
- md5: String((entry && entry.md5) || ''),
1250
- url: String((entry && entry.url) || ''),
1251
- }
1252
- }
1253
-
1254
- async function fetchCustomFaces(cfg, count = CUSTOM_FACE_COUNT) {
1255
- const data = await onebot('fetch_custom_face_detail', { count }, cfg)
1256
- return (Array.isArray(data) ? data : []).map(customFaceOf).filter((f) => f.emoji_id)
1257
- }
1258
-
1259
- async function searchSysFaces(cfg, query) {
1260
- const data = await onebot('search_sys_faces', { query }, cfg)
1261
- const faces = Array.isArray(data && data.faces) ? data.faces : []
1262
- return faces.map(sysFaceOf).filter((f) => f.face_id != null)
1263
- }
1264
-
1265
- // SnowLuma 的 add_custom_face 只认 file:///、base64://、http(s)://,
1266
- // 本机路径要先转成 file:/// URL,data:image/... 转成 base64://。
1267
- function imageSourceOf(raw) {
1268
- const s = String(raw || '').trim()
1269
- if (!s) return ''
1270
- if (/^(https?:\/\/|base64:\/\/|file:\/\/)/i.test(s)) return s
1271
- const dataUrl = s.match(/^data:[^;,]*;base64,(.*)$/is)
1272
- if (dataUrl) return 'base64://' + dataUrl[1].replace(/\s+/g, '')
1273
- try {
1274
- if (existsSync(s)) return pathToFileURL(s).href
1275
- } catch (error) {}
1276
- return s
1277
- }
1278
-
1279
- // 从聊天消息里取图片:get_msg → 图片段 → 可直接交给 SnowLuma 的 file 来源。
1280
- // 收藏表情、换头像、发说说都用这一条路径,索引从 0 开始。
1281
- async function imageSourceFromMessage(messageId, index, cfg) {
1282
- const msg = await onebot('get_msg', { message_id: messageId }, cfg)
1283
- const segs = Array.isArray(msg && msg.message) ? msg.message : []
1284
- const images = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
1285
- const idx = Number.isInteger(Number(index)) ? Math.max(0, Number(index)) : 0
1286
- const pick = images[idx]
1287
- if (!pick) return { error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
1288
- const source = imageSourceOf(pick.data.url || pick.data.file)
1289
- if (!source) return { error: '拿不到这条消息里的图片地址' }
1290
- return { source, total: images.length }
1291
- }
1292
-
1293
- async function imageSourcesFromMessage(messageId, indexes, cfg, max = 9) {
1294
- const msg = await onebot('get_msg', { message_id: messageId }, cfg)
1295
- const segs = Array.isArray(msg && msg.message) ? msg.message : []
1296
- const picks = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
1297
- const wanted = Array.isArray(indexes) && indexes.length
1298
- ? indexes.map((x) => Number(x)).filter((x) => Number.isInteger(x))
1299
- : picks.map((_, i) => i)
1300
- const sources = []
1301
- for (const i of wanted) {
1302
- if (sources.length >= max) break
1303
- const pick = picks[i]
1304
- if (!pick) continue
1305
- const source = imageSourceOf(pick.data.url || pick.data.file)
1306
- if (source) sources.push(source)
1307
- }
1308
- return { sources, total: picks.length }
1309
- }
1310
-
1311
- // 工具回执里不要把 base64 原样吐回去,不然一次换头像就能塞满上下文。
1312
- function describeSource(source) {
1313
- const s = String(source || '')
1314
- if (/^base64:\/\//i.test(s)) return 'base64://…'
1315
- if (/^file:\/\//i.test(s)) {
1316
- try {
1317
- return decodeURIComponent(new URL(s).pathname).split('/').pop() || '本机图片'
1318
- } catch (error) {
1319
- return '本机图片'
1320
- }
1321
- }
1322
- if (/^https?:/i.test(s)) return s.length > 200 ? s.slice(0, 200) + '…' : s
1323
- return s.slice(0, 200)
1324
- }
1325
-
1326
-
1327
- export function makeSendFaceTool(onSent) {
1328
- return {
1329
- name: 'qq_send_face',
1330
- description:
1331
- '发一个表情:QQ 系统表情(face_id)或你 QQ 收藏夹里的自定义表情(emoji_id),二选一。' +
1332
- '群聊传 group_id;私聊传 user_id(可选 group_id 表示临时会话来源群)。' +
1333
- 'face_id 从 qq_list_faces 按名字搜;emoji_id 从 qq_list_faces 的收藏列表拿。' +
1334
- '可以顺带一句话 text;发收藏表情时文字会单独先发一条。',
1335
- parameters: {
1336
- type: 'object',
1337
- properties: {
1338
- group_id: { type: 'number', description: '群号;私聊时如果是从群里发起的临时会话,也可以传来源群号' },
1339
- user_id: { type: 'number', description: '对方 QQ 号;传了就是私聊,和只传 group_id 的群聊区分开' },
1340
- face_id: { type: 'number', description: 'QQ 系统表情编号(>=0),和 emoji_id 二选一' },
1341
- emoji_id: { type: 'string', description: 'QQ 收藏表情的 emoji_id(SnowLuma),和 face_id 二选一' },
1342
- text: { type: 'string', description: '跟着表情一起发的文字,可选,不要 Markdown' },
1343
- reply_to: { type: 'string', description: '要回复的消息 ID,可选' },
1344
- },
1345
- additionalProperties: false,
1346
- },
1347
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1348
- async execute(args) {
1349
- const cfg = loadCfg()
1350
- const hasUser = args && args.user_id != null && args.user_id !== ''
1351
- const userId = hasUser ? Number(args.user_id) : 0
1352
- const groupId = args && args.group_id != null && args.group_id !== '' ? Number(args.group_id) : 0
1353
- const hasFace = args && args.face_id != null && args.face_id !== ''
1354
- const faceId = hasFace ? Number(args.face_id) : null
1355
- const emojiId = String((args && args.emoji_id) || '').trim()
1356
- const text = String((args && args.text) || '').trim()
1357
- if (hasUser) {
1358
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1359
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1360
- if (groupId && !allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
1361
- } else {
1362
- if (!Number.isFinite(groupId) || !groupId) return { ok: false, error: 'group_id 和 user_id 至少传一个' }
1363
- if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
1364
- }
1365
- if (hasFace && emojiId) return { ok: false, error: 'face_id 和 emoji_id 只能二选一' }
1366
- if (!hasFace && !emojiId) return { ok: false, error: 'face_id 和 emoji_id 至少传一个' }
1367
- const target = hasUser
1368
- ? { user_id: userId, ...(groupId ? { group_id: groupId } : {}) }
1369
- : { group_id: groupId }
1370
- try {
1371
- if (hasFace) {
1372
- if (!Number.isInteger(faceId) || faceId < 0 || faceId > 1000000) {
1373
- return { ok: false, error: 'face_id 不是有效表情编号' }
1374
- }
1375
- const message = []
1376
- const replyTo = args && args.reply_to
1377
- if (replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
1378
- if (text) message.push({ type: 'text', data: { text: text.slice(0, 1000) } })
1379
- message.push({ type: 'face', data: { id: String(faceId) } })
1380
- const action = hasUser ? 'send_private_msg' : 'send_group_msg'
1381
- const data = await onebot(action, { ...target, message }, cfg)
1382
- try { if (!hasUser && typeof onSent === 'function') onSent(groupId, '发表情') } catch (error) {}
1383
- return {
1384
- ok: true,
1385
- kind: 'system',
1386
- chat: hasUser ? 'private' : 'group',
1387
- group_id: hasUser ? (groupId || null) : groupId,
1388
- user_id: hasUser ? userId : null,
1389
- message_id: data && data.message_id,
1390
- face_id: faceId,
1391
- }
1392
- }
1393
- let textMessageId = null
1394
- if (text) {
1395
- const action = hasUser ? 'send_private_msg' : 'send_group_msg'
1396
- const t = await onebot(
1397
- action,
1398
- { ...target, message: [{ type: 'text', data: { text: text.slice(0, 1000) } }] },
1399
- cfg,
1400
- )
1401
- textMessageId = t && t.message_id
1402
- }
1403
- const body = { emoji_id: emojiId, ...target }
1404
- const replyTo = args && args.reply_to
1405
- if (replyTo != null && replyTo !== '') body.reply_to = String(replyTo)
1406
- const data = await onebot('send_custom_face', body, cfg)
1407
- try { if (!hasUser && typeof onSent === 'function') onSent(groupId, '发表情') } catch (error) {}
1408
- return {
1409
- ok: true,
1410
- kind: 'custom',
1411
- chat: hasUser ? 'private' : 'group',
1412
- group_id: hasUser ? (groupId || null) : groupId,
1413
- user_id: hasUser ? userId : null,
1414
- message_id: data && data.message_id,
1415
- text_message_id: textMessageId,
1416
- emoji_id: emojiId,
1417
- }
1418
- } catch (error) {
1419
- return { ok: false, error: String((error && error.message) || error) }
1420
- }
1421
- },
1422
- render(_args, v) {
1423
- if (!v || !v.ok) return [{ type: 'text', text: '发表情失败:' + String((v && v.error) || '未知错误') }]
1424
- const where = v.chat === 'private'
1425
- ? (v.group_id ? '群 ' + v.group_id + ' 的临时会话 QQ ' + v.user_id : 'QQ ' + v.user_id)
1426
- : '群 ' + v.group_id
1427
- if (v.kind === 'custom') {
1428
- const parts = []
1429
- if (v.text_message_id) parts.push('文字已发(消息 ' + v.text_message_id + ')')
1430
- parts.push('收藏表情 ' + v.emoji_id + ' 已发到' + where + '(消息 ' + (v.message_id || '?') + ')')
1431
- return [{ type: 'text', text: parts.join(';') + '。' }]
1432
- }
1433
- return [{ type: 'text', text: `已在${where}发出系统表情 ${v.face_id}(消息 ${v.message_id || '?'})。` }]
1434
- },
1435
- }
1436
- }
1437
-
1438
- export function makeListFacesTool(admitImages) {
1439
- return {
1440
- name: 'qq_list_faces',
1441
- description:
1442
- '看表情:你 QQ 收藏夹里的自定义表情(emoji_id + 备注 + 预览图),以及 QQ 系统表情编号。' +
1443
- '默认给 3 张不同位置的预览;想看指定表情就传 preview(列表序号或 emoji_id,最多 6 张)。' +
1444
- '按名字找系统表情就传 query,比如「笑」「哭」「点赞」。收藏/取消收藏用 qq_favorite_face;发表情用 qq_send_face。',
1445
- parameters: {
1446
- type: 'object',
1447
- properties: {
1448
- query: { type: 'string', description: '按名字/别名/编号搜系统表情,或按备注搜收藏表情;不传就列收藏夹 + 常用表情' },
1449
- count: { type: 'number', description: '最多列多少个收藏表情,默认 50,最多 200' },
1450
- preview: { type: 'string', description: '想看图的收藏表情:序号(从 1 开始)或 emoji_id,用逗号分隔,最多 6 个;不传就默认看 3 张不同位置的预览' },
1451
- },
1452
- additionalProperties: false,
1453
- },
1454
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1455
- async execute(args) {
1456
- const cfg = loadCfg()
1457
- const q = String((args && args.query) || '').trim().toLowerCase()
1458
- const count = Math.max(1, Math.min(Number(args && args.count) || 50, 200))
1459
- let favorites = []
1460
- let favoritesError = ''
1461
- try {
1462
- favorites = await fetchCustomFaces(cfg, Math.max(count, CUSTOM_FACE_COUNT))
1463
- } catch (error) {
1464
- favoritesError = String((error && error.message) || error)
1465
- }
1466
- const matched = q
1467
- ? favorites.filter((f) => (f.desc + ' ' + f.emoji_id + ' ' + f.md5).toLowerCase().includes(q))
1468
- : favorites
1469
- const previewRaw = String((args && args.preview) || '').trim()
1470
- const previewList = previewRaw ? previewRaw.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean) : []
1471
- const previewIndex = new Map(matched.map((f, i) => [f.emoji_id, i + 1]))
1472
- let picks = []
1473
- if (previewList.length) {
1474
- const seen = new Set()
1475
- for (const want of previewList) {
1476
- let hit = null
1477
- if (/^\d+$/.test(want)) {
1478
- const i = Number(want) - 1
1479
- if (i >= 0 && i < matched.length) hit = matched[i]
1480
- }
1481
- if (!hit) hit = matched.find((f) => f.emoji_id === want) || null
1482
- if (!hit) hit = matched.find((f) => f.md5 && f.md5.toLowerCase() === want.toLowerCase()) || null
1483
- if (hit && !seen.has(hit.emoji_id)) {
1484
- seen.add(hit.emoji_id)
1485
- picks.push(hit)
1486
- }
1487
- if (picks.length >= 6) break
1488
- }
1489
- } else if (matched.length) {
1490
- // 默认不按前几张来,挑开头、中间、结尾三张,让模型先有个整体印象。
1491
- if (matched.length <= 3) picks = matched.slice()
1492
- else picks = [matched[0], matched[Math.floor(matched.length / 2)], matched[matched.length - 1]]
1493
- }
1494
- let images = []
1495
- if (typeof admitImages === 'function') {
1496
- const shown = picks.filter((f) => /^https?:/i.test(f.url))
1497
- if (shown.length) {
1498
- images = await admitImages(shown.map((f) => ({
1499
- data: { url: f.url },
1500
- cfg,
1501
- name: `qq-face-${f.emoji_id}.jpg`,
1502
- caption: `收藏表情 #${previewIndex.get(f.emoji_id) || '?'} ${f.desc || f.emoji_id}`,
1503
- })))
1504
- }
1505
- }
1506
- let sysFaces = []
1507
- let sysSource = 'builtin'
1508
- if (q) {
1509
- try {
1510
- sysFaces = (await searchSysFaces(cfg, q)).slice(0, 30)
1511
- if (sysFaces.length) sysSource = 'snowluma'
1512
- } catch (error) {}
1513
- if (!sysFaces.length) {
1514
- sysFaces = COMMON_FACES
1515
- .filter((f) => f.name.toLowerCase().includes(q) || String(f.id) === q)
1516
- .map((f) => ({ face_id: f.id, name: f.name, aliases: [], is_super: false, url: '' }))
1517
- }
1518
- } else {
1519
- sysFaces = COMMON_FACES.map((f) => ({ face_id: f.id, name: f.name, aliases: [], is_super: false, url: '' }))
1520
- }
1521
- return {
1522
- ok: true,
1523
- query: q,
1524
- favorites: matched.slice(0, count),
1525
- favorites_total: favorites.length,
1526
- favorites_error: favoritesError,
1527
- images,
1528
- preview_requested: previewList,
1529
- preview_matched: picks.map((f) => previewIndex.get(f.emoji_id)).filter(Boolean),
1530
- sys_faces: sysFaces,
1531
- sys_source: sysSource,
1532
- }
1533
- },
1534
- render(_args, v) {
1535
- if (!v || !v.ok) return [{ type: 'text', text: '看表情失败:' + String((v && v.error) || '未知错误') }]
1536
- const lines = []
1537
- if (v.favorites.length) {
1538
- lines.push('你 QQ 收藏夹里的表情:')
1539
- v.favorites.forEach((f, i) => {
1540
- lines.push(`${i + 1}. ${f.desc || '(无备注)'} emoji_id=${f.emoji_id}`)
1541
- })
1542
- if (v.favorites_total > v.favorites.length) {
1543
- lines.push(`(共 ${v.favorites_total} 个,只列了前 ${v.favorites.length} 个)`)
1544
- }
1545
- if (v.preview_requested && v.preview_requested.length && !v.images.length) {
1546
- lines.push('preview 没匹配到可看的收藏表情;序号从 1 开始,也可以直接传 emoji_id。')
1547
- } else {
1548
- lines.push('想看指定表情的图:再调一次 qq_list_faces,preview 传序号或 emoji_id(最多 6 张)。')
1549
- }
1550
- } else if (v.favorites_error) {
1551
- lines.push('收藏表情读不到(SnowLuma 自定义表情接口不可用):' + String(v.favorites_error).slice(0, 200))
1552
- } else if (!v.query) {
1553
- lines.push('QQ 收藏夹里还没有表情。用 qq_favorite_face 从图片或消息收藏。')
1554
- }
1555
- if (v.sys_faces.length) {
1556
- lines.push(v.query ? '搜到的系统表情(编号 名字):' : '常用系统表情(编号 名字):')
1557
- lines.push(v.sys_faces.map((f) => `${f.face_id} ${f.name}${f.is_super ? '(超级表情)' : ''}`).join(','))
1558
- } else if (v.query) {
1559
- lines.push('没有匹配的系统表情。')
1560
- }
1561
- const blocks = [{ type: 'text', text: lines.join('\n') || '没有匹配的表情,换个词试试。' }]
1562
- for (const img of Array.isArray(v.images) ? v.images : []) {
1563
- if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
1564
- if (img && img.block) blocks.push(img.block)
1565
- }
1566
- return blocks
1567
- },
1568
- }
1569
- }
1570
-
1571
- export function makeFavoriteFaceTool() {
1572
- return {
1573
- name: 'qq_favorite_face',
1574
- description:
1575
- '管理你 QQ 账号的收藏表情(SnowLuma):收藏一张图片、取消收藏、改备注、移到最前。' +
1576
- '收藏来源可以是 http(s) 图片链接、base64://、本机文件路径,或某条消息里的图片(message_id + index)。' +
1577
- 'emoji_id 从 qq_list_faces 的收藏列表拿;发表情用 qq_send_face。',
1578
- parameters: {
1579
- type: 'object',
1580
- properties: {
1581
- file: { type: 'string', description: '要收藏的图片:http(s) 链接、base64:// 或本机文件路径;和 message_id 二选一' },
1582
- message_id: { type: 'string', description: '从这条消息里取图片收藏;和 file 二选一' },
1583
- index: { type: 'number', description: 'message_id 里第几张图片,从 0 开始,默认 0' },
1584
- emoji_id: { type: 'string', description: '已收藏表情的 emoji_id;取消收藏、改备注、移到最前时用' },
1585
- desc: { type: 'string', description: '收藏表情的备注名;收藏时可选,配合 emoji_id 可修改' },
1586
- remove: { type: 'boolean', description: 'true = 取消收藏(需要 emoji_id)' },
1587
- move_to_front: { type: 'boolean', description: 'true = 移到收藏夹最前(需要 emoji_id)' },
1588
- },
1589
- additionalProperties: false,
1590
- },
1591
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1592
- async execute(args) {
1593
- const cfg = loadCfg()
1594
- const emojiId = String((args && args.emoji_id) || '').trim()
1595
- const desc = clean(args && args.desc)
1596
- const remove = args && args.remove === true
1597
- const moveToFront = args && args.move_to_front === true
1598
- if (remove && moveToFront) return { ok: false, error: 'remove 和 move_to_front 只能选一个' }
1599
- try {
1600
- if (remove || moveToFront) {
1601
- if (!emojiId) return { ok: false, error: (remove ? '取消收藏' : '移到最前') + '需要 emoji_id' }
1602
- if (remove) {
1603
- await onebot('delete_custom_face', { emoji_id: emojiId }, cfg)
1604
- return { ok: true, removed: true, emoji_id: emojiId }
1605
- }
1606
- await onebot('move_custom_face_to_front', { emoji_id: emojiId }, cfg)
1607
- return { ok: true, moved: true, emoji_id: emojiId }
1608
- }
1609
- if (emojiId) {
1610
- if (args && args.desc == null) return { ok: false, error: '改备注需要同时传 emoji_id 和 desc' }
1611
- await onebot('modify_custom_face', { emoji_id: emojiId, desc }, cfg)
1612
- return { ok: true, updated: true, emoji_id: emojiId, desc }
1613
- }
1614
- let source = imageSourceOf(args && args.file)
1615
- if (!source && args && args.message_id != null && args.message_id !== '') {
1616
- const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
1617
- if (got.error) return { ok: false, error: got.error }
1618
- source = got.source
1619
- }
1620
- if (!source) return { ok: false, error: '要收藏就传 file 或 message_id' }
1621
- const data = await onebot('add_custom_face', { file: source }, cfg)
1622
- const newId = String((data && (data.emoji_id || data.emojiId || data.resId)) || '')
1623
- let descSet = true
1624
- let warning = ''
1625
- if (desc) {
1626
- if (newId) {
1627
- try {
1628
- await onebot('modify_custom_face', { emoji_id: newId, desc }, cfg)
1629
- } catch (error) {
1630
- descSet = false
1631
- warning = String((error && error.message) || error)
1632
- }
1633
- } else {
1634
- descSet = false
1635
- warning = '没有拿到新表情的 emoji_id,备注未设置'
1636
- }
1637
- }
1638
- return { ok: true, added: true, emoji_id: newId, desc, desc_set: descSet, warning, source }
1639
- } catch (error) {
1640
- return { ok: false, error: String((error && error.message) || error) }
1641
- }
1642
- },
1643
- render(_args, v) {
1644
- if (!v || !v.ok) return [{ type: 'text', text: '收藏表情失败:' + String((v && v.error) || '未知错误') }]
1645
- if (v.removed) return [{ type: 'text', text: `已取消收藏表情 ${v.emoji_id}。` }]
1646
- if (v.moved) return [{ type: 'text', text: `已把表情 ${v.emoji_id} 移到收藏夹最前。` }]
1647
- if (v.updated) return [{ type: 'text', text: `已把表情 ${v.emoji_id} 的备注改成「${v.desc || '空'}」。` }]
1648
- let text = `已收藏表情${v.desc ? '「' + v.desc + '」' : ''}(emoji_id: ${v.emoji_id || '?'})。`
1649
- if (v.desc && v.desc_set === false) text += ` 备注没设置成功:${String(v.warning || '').slice(0, 200)}`
1650
- return [{ type: 'text', text }]
1651
- },
1652
- }
1653
- }
1654
-
1655
-
1656
- export function makePokeTool(onSent) {
1657
- return {
1658
- name: 'qq_send_poke',
1659
- description:
1660
- '戳一戳某人。群聊传 group_id + user_id;私聊只传 user_id。' +
1661
- '别人戳你时可以戳回去,但别频繁;戳完不用再解释。',
1662
- parameters: {
1663
- type: 'object',
1664
- properties: {
1665
- user_id: { type: 'number', description: '对方的 QQ 号' },
1666
- group_id: { type: 'number', description: '群号;私聊不传' },
1667
- },
1668
- required: ['user_id'],
1669
- additionalProperties: false,
1670
- },
1671
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1672
- async execute(args) {
1673
- const cfg = loadCfg()
1674
- const userId = Number(args && args.user_id)
1675
- const groupId = args && args.group_id != null ? Number(args.group_id) : 0
1676
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1677
- if (groupId && !allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
1678
- if (!groupId && !allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1679
- try {
1680
- const body = { user_id: userId }
1681
- if (groupId) body.group_id = groupId
1682
- await onebot('send_poke', body, cfg)
1683
- try { if (groupId && typeof onSent === 'function') onSent(groupId, '戳回去') } catch (error) {}
1684
- return { ok: true, poke: true, user_id: userId, group_id: groupId || null }
1685
- } catch (error) {
1686
- return { ok: false, error: String((error && error.message) || error) }
1687
- }
1688
- },
1689
- }
1690
- }
1691
-
1692
-
1693
- // ---------- 头像 ----------
1694
- // set_qq_avatar 是 SnowLuma 扩展,file 和图片接口一样吃 http(s)/base64:///file://。
1695
- // 换头像会立刻对所有好友和群友可见,描述里明确要求只在用户提出时调用。
1696
- export function makeSetAvatarTool() {
1697
- return {
1698
- name: 'qq_set_avatar',
1699
- description:
1700
- '改自己的 QQ 头像(SnowLuma set_qq_avatar)。' +
1701
- 'file 传 http(s) 链接、base64://、data:image/... 或本机文件路径;' +
1702
- '也可以用 message_id + index 从聊天记录里的某张图片取图。' +
1703
- '只有用户明确要求换头像时才用;换完不用再重复说明。',
1704
- parameters: {
1705
- type: 'object',
1706
- properties: {
1707
- file: { type: 'string', description: '图片:http(s) 链接、base64://、data:image/... 或本机路径;和 message_id 二选一' },
1708
- message_id: { type: 'string', description: '从这条消息里取图当头像;和 file 二选一' },
1709
- index: { type: 'number', description: 'message_id 里第几张图片,从 0 开始,默认 0' },
1710
- },
1711
- additionalProperties: false,
1712
- },
1713
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1714
- async execute(args) {
1715
- const cfg = loadCfg()
1716
- const fileRaw = String((args && args.file) || '').trim()
1717
- const hasMessage = args && args.message_id != null && args.message_id !== ''
1718
- if (fileRaw && hasMessage) return { ok: false, error: 'file 和 message_id 只能选一个' }
1719
- if (!fileRaw && !hasMessage) return { ok: false, error: '要换头像就传 file 或 message_id' }
1720
- try {
1721
- let source = imageSourceOf(fileRaw)
1722
- if (!source && hasMessage) {
1723
- const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
1724
- if (got.error) return { ok: false, error: got.error }
1725
- source = got.source
1726
- }
1727
- if (!source) return { ok: false, error: '拿不到头像图片' }
1728
- await onebot('set_qq_avatar', { file: source }, cfg)
1729
- return { ok: true, avatar: true, source: describeSource(source) }
1730
- } catch (error) {
1731
- return { ok: false, error: String((error && error.message) || error) }
1732
- }
1733
- },
1734
- render(_args, v) {
1735
- if (!v || !v.ok) return [{ type: 'text', text: '换头像失败:' + String((v && v.error) || '未知错误') }]
1736
- return [{ type: 'text', text: 'QQ 头像已更新(' + (v.source || '图片') + ')。' }]
1737
- },
1738
- }
1739
- }
1740
-
1741
-
1742
- // ---------- 好友管理 ----------
1743
- // 读写好友资料、备注、分组、名片赞,以及好友申请。
1744
- // 普通好友申请只有 request 事件,SnowLuma 没有「列出历史好友申请」的 HTTP 接口,
1745
- // 所以这里一边接 WebSocket 事件进收件箱,一边用 get_doubt_friends_add_request
1746
- // 拿服务端保存的可疑申请;历史普通申请只能去 QQ 客户端看。
1747
- const FRIEND_REQUEST_LIMIT = 30
1748
- const friendRequestInbox = new Map()
1749
-
1750
- function rememberFriendRequest(ev) {
1751
- const flag = clean(ev && ev.flag)
1752
- if (!flag) return
1753
- const userId = String((ev && ev.user_id) || '')
1754
- const time = Number(ev && ev.time) || Math.floor(Date.now() / 1000)
1755
- friendRequestInbox.set(flag, {
1756
- flag,
1757
- user_id: userId,
1758
- nickname: '',
1759
- comment: String((ev && ev.comment) || '').slice(0, 500),
1760
- time,
1761
- at: Date.now(),
1762
- })
1763
- if (friendRequestInbox.size > FRIEND_REQUEST_LIMIT) {
1764
- const oldest = [...friendRequestInbox.entries()].sort((a, b) => (a[1].at || 0) - (b[1].at || 0))[0]
1765
- if (oldest) friendRequestInbox.delete(oldest[0])
1766
- }
1767
- diag('好友申请 → 收件箱:' + userId + ' ' + clean(ev && ev.comment).slice(0, 120))
1768
- }
1769
-
1770
- async function friendEntryOf(cfg, userId) {
1771
- try {
1772
- const groups = await onebot('get_friends_with_category', {}, cfg)
1773
- if (Array.isArray(groups) && groups.length) {
1774
- for (const g of groups) {
1775
- const list = Array.isArray(g && g.buddyList) ? g.buddyList : []
1776
- const hit = list.find((b) => String(b && b.user_id) === String(userId))
1777
- if (hit) {
1778
- const categoryId = Number(g.categoryId)
1779
- return {
1780
- entry: hit,
1781
- category: { id: Number.isFinite(categoryId) ? categoryId : 0, name: clean(g.categoryName) },
1782
- }
1783
- }
1784
- }
1785
- }
1786
- } catch (error) {}
1787
- // 分组接口不可用(或好友不在分组结果里)时退回普通好友列表:
1788
- // 至少能判断是不是好友、拿到备注。
1789
- try {
1790
- const data = await onebot('get_friend_list', {}, cfg)
1791
- const hit = (Array.isArray(data) ? data : []).find((f) => String(f && f.user_id) === String(userId))
1792
- if (hit) return { entry: hit, category: null }
1793
- } catch (error) {}
1794
- return null
1795
- }
1796
-
1797
- // 接口不可用时返回 null(未知),不要谎报「不是单向好友」。
1798
- async function oneWayOf(cfg, userId) {
1799
- try {
1800
- const list = await onebot('get_unidirectional_friend_list', {}, cfg)
1801
- return (Array.isArray(list) ? list : []).some((x) => {
1802
- const id = x && typeof x === 'object'
1803
- ? String(x.user_id || x.uin || x.qq || '')
1804
- : String(x || '')
1805
- return !!id && id === String(userId)
1806
- })
1807
- } catch (error) {
1808
- return null
1809
- }
1810
- }
1811
-
1812
- function sexText(value) {
1813
- const s = String(value == null ? '' : value).toLowerCase()
1814
- if (s === 'male' || s === '1' || s === '男') return '男'
1815
- if (s === 'female' || s === '2' || s === '女') return '女'
1816
- if (!s || s === '0' || s === 'unknown' || s === '未知') return '未知'
1817
- return clean(value)
1818
- }
1819
-
1820
- export function makeGetFriendInfoTool() {
1821
- return {
1822
- name: 'qq_get_friend_info',
1823
- description:
1824
- '看某个 QQ 的资料和好友关系:昵称、备注、分组、性别年龄、等级、VIP、是否单向好友。' +
1825
- 'user_id 从 qq_list_friends、聊天记录或好友申请里拿。只读。',
1826
- parameters: {
1827
- type: 'object',
1828
- properties: {
1829
- user_id: { type: 'number', description: '对方 QQ 号' },
1830
- },
1831
- required: ['user_id'],
1832
- additionalProperties: false,
1833
- },
1834
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1835
- async execute(args) {
1836
- const cfg = loadCfg()
1837
- const userId = Number(args && args.user_id)
1838
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1839
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1840
- let info = null
1841
- let infoError = ''
1842
- try {
1843
- info = await onebot('get_stranger_info', { user_id: userId }, cfg)
1844
- } catch (error) {
1845
- infoError = String((error && error.message) || error)
1846
- }
1847
- const found = await friendEntryOf(cfg, userId)
1848
- if (!info && !found) {
1849
- return { ok: false, error: infoError || '查不到这个 QQ 的资料' }
1850
- }
1851
- const entry = (found && found.entry) || {}
1852
- const oneWay = await oneWayOf(cfg, userId)
1853
- const rawLevel = info
1854
- ? (info.qq_level != null ? info.qq_level : info.qqLevel != null ? info.qqLevel : info.level)
1855
- : 0
1856
- const friend = {
1857
- user_id: String(userId),
1858
- nickname: clean((info && info.nickname) || entry.nickname),
1859
- remark: clean(entry.remark || (info && info.remark)),
1860
- category: found ? found.category : null,
1861
- sex: sexText(info && info.sex),
1862
- age: Number((info && info.age) || 0),
1863
- qid: clean(info && info.qid),
1864
- level: Number(rawLevel || 0),
1865
- vip: !!(info && info.is_vip),
1866
- vip_level: Number((info && info.vip_level) || 0),
1867
- is_friend: !!found,
1868
- one_way: oneWay,
1869
- }
1870
- return { ok: true, friend }
1871
- },
1872
- render(_args, v) {
1873
- if (!v || !v.ok) return [{ type: 'text', text: '看好友资料失败:' + String((v && v.error) || '未知错误') }]
1874
- const f = v.friend || {}
1875
- const lines = [
1876
- `QQ ${f.user_id}:`,
1877
- '- 昵称:' + (f.nickname || '未知'),
1878
- '- 备注:' + (f.remark || '(无)'),
1879
- '- 分组:' + (f.category ? `${f.category.name || '未命名'}(id ${f.category.id})` : (f.is_friend ? '未知(分组接口没返回)' : '不是好友')),
1880
- '- 性别年龄:' + (f.sex || '未知') + (f.age ? ',' + f.age + ' 岁' : ''),
1881
- '- 等级:' + (f.level || '未知'),
1882
- '- VIP:' + (f.vip ? '是' + (f.vip_level ? '(' + f.vip_level + ' 级)' : '') : '否'),
1883
- '- 单向好友:' + (f.one_way === true ? '是' : f.one_way === false ? '否' : '未知(接口不可用)'),
1884
- ]
1885
- if (f.qid) lines.splice(2, 0, '- QID:' + f.qid)
1886
- return [{ type: 'text', text: lines.join('\n') }]
1887
- },
1888
- }
1889
- }
1890
-
1891
- export function makeListFriendCategoriesTool() {
1892
- return {
1893
- name: 'qq_list_friend_categories',
1894
- description:
1895
- '看好友分组和每个分组里的好友(SnowLuma get_friends_with_category)。' +
1896
- '移动好友分组时,category_id / category_name 从这里拿。' +
1897
- '分组接口漏掉的好友会补进「未分组(接口未返回)」里。只读。',
1898
- parameters: { type: 'object', properties: {}, additionalProperties: false },
1899
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1900
- async execute() {
1901
- const cfg = loadCfg()
1902
- try {
1903
- let categories = []
1904
- let usedFriendListFallback = false
1905
- try {
1906
- const data = await onebot('get_friends_with_category', {}, cfg)
1907
- if (!Array.isArray(data)) throw new Error('好友分组接口返回的不是数组')
1908
- categories = data.map((g) => ({
1909
- id: Number.isFinite(Number(g && g.categoryId)) ? Number(g.categoryId) : 0,
1910
- name: clean(g && g.categoryName),
1911
- count: Number((g && g.categoryMbCount) || 0),
1912
- friends: (Array.isArray(g && g.buddyList) ? g.buddyList : []).map((b) => ({
1913
- user_id: String((b && b.user_id) || ''),
1914
- nickname: clean(b && b.nickname),
1915
- remark: clean(b && b.remark),
1916
- })),
1917
- }))
1918
- } catch (error) {
1919
- // 老版本或 NapCat 没有分组接口时,退回普通好友列表,至少还能看到人。
1920
- const data = await onebot('get_friend_list', {}, cfg)
1921
- const friends = (Array.isArray(data) ? data : []).map((f) => ({
1922
- user_id: String((f && f.user_id) || ''),
1923
- nickname: clean(f && f.nickname),
1924
- remark: clean(f && f.remark),
1925
- }))
1926
- categories = [{ id: 0, name: '我的好友', count: friends.length, friends }]
1927
- usedFriendListFallback = true
1928
- }
1929
- // SnowLuma 的分组接口可能漏人(真机见过 86 个好友只返回 83 个),
1930
- // 用普通好友列表补齐,免得模型以为这些人不存在。
1931
- if (!usedFriendListFallback) {
1932
- try {
1933
- const data = await onebot('get_friend_list', {}, cfg)
1934
- const known = new Set()
1935
- for (const c of categories) for (const f of c.friends) known.add(f.user_id)
1936
- const missing = (Array.isArray(data) ? data : []).map((f) => ({
1937
- user_id: String((f && f.user_id) || ''),
1938
- nickname: clean(f && f.nickname),
1939
- remark: clean(f && f.remark),
1940
- })).filter((f) => f.user_id && !known.has(f.user_id))
1941
- if (missing.length) {
1942
- categories.push({ id: -1, name: '未分组(接口未返回)', count: missing.length, friends: missing })
1943
- }
1944
- } catch (error) {}
1945
- }
1946
- for (const c of categories) {
1947
- c.friends = c.friends.filter((f) => f.user_id && allowedPrivate(f.user_id, cfg))
1948
- c.count = c.friends.length
1949
- }
1950
- const total = categories.reduce((n, c) => n + c.friends.length, 0)
1951
- return { ok: true, total, categories }
1952
- } catch (error) {
1953
- return { ok: false, error: String((error && error.message) || error) }
1954
- }
1955
- },
1956
- render(_args, v) {
1957
- if (!v || !v.ok) return [{ type: 'text', text: '看好友分组失败:' + String((v && v.error) || '未知错误') }]
1958
- if (!v.categories.length) return [{ type: 'text', text: '没有好友分组。' }]
1959
- const lines = [`好友分组(${v.categories.length} 个,共 ${v.total} 人):`]
1960
- for (const c of v.categories) {
1961
- const names = c.friends.slice(0, 20).map((f) => f.remark || f.nickname || f.user_id)
1962
- const idText = c.id >= 0 ? `id ${c.id},` : ''
1963
- lines.push(`- ${c.name || '未命名'}(${idText}${c.count} 人)${names.length ? ':' + names.join('、') : ''}${c.friends.length > names.length ? ' …' : ''}`)
1964
- }
1965
- return [{ type: 'text', text: lines.join('\n') }]
1966
- },
1967
- }
1968
- }
1969
-
1970
- export function makeUpdateFriendTool() {
1971
- return {
1972
- name: 'qq_update_friend',
1973
- description:
1974
- '改好友备注或把好友移到别的分组。' +
1975
- 'remark 传空字符串表示清空备注;分组用 category_id 或 category_name(名称必须唯一且完全匹配),' +
1976
- '分组 id/名称从 qq_list_friend_categories 拿。两件事至少做一件。',
1977
- parameters: {
1978
- type: 'object',
1979
- properties: {
1980
- user_id: { type: 'number', description: '好友 QQ 号' },
1981
- remark: { type: 'string', description: '新备注;传空字符串清空;不传就不改备注' },
1982
- category_id: { type: 'number', description: '目标分组 ID' },
1983
- category_name: { type: 'string', description: '目标分组名称(完全匹配)' },
1984
- },
1985
- required: ['user_id'],
1986
- additionalProperties: false,
1987
- },
1988
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1989
- async execute(args) {
1990
- const cfg = loadCfg()
1991
- const userId = Number(args && args.user_id)
1992
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1993
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1994
- const hasRemark = args && args.remark != null
1995
- const remark = clean(args && args.remark)
1996
- const hasCategoryId = args && args.category_id != null && args.category_id !== ''
1997
- const categoryId = hasCategoryId ? Number(args.category_id) : null
1998
- const categoryName = clean(args && args.category_name)
1999
- if (!hasRemark && !hasCategoryId && !categoryName) {
2000
- return { ok: false, error: '要改什么?传 remark,或 category_id / category_name' }
2001
- }
2002
- if (hasCategoryId && !Number.isInteger(categoryId)) return { ok: false, error: 'category_id 不是有效整数' }
2003
- try {
2004
- const changes = []
2005
- if (hasRemark) {
2006
- await onebot('set_friend_remark', { user_id: userId, remark }, cfg)
2007
- changes.push(remark ? '备注改成「' + remark + '」' : '备注已清空')
2008
- }
2009
- if (hasCategoryId || categoryName) {
2010
- let targetId = categoryId
2011
- let targetName = categoryName
2012
- if (!hasCategoryId) {
2013
- try {
2014
- const data = await onebot('get_friends_with_category', {}, cfg)
2015
- const hits = (Array.isArray(data) ? data : []).filter((g) => clean(g && g.categoryName) === categoryName)
2016
- if (hits.length !== 1) {
2017
- return {
2018
- ok: false,
2019
- error: (hits.length
2020
- ? `有 ${hits.length} 个分组都叫「${categoryName}」,请改用 category_id`
2021
- : `没找到叫「${categoryName}」的分组,先用 qq_list_friend_categories 看`)
2022
- + (changes.length ? '(已' + changes.join(';') + ')' : ''),
2023
- changed: changes,
2024
- }
2025
- }
2026
- targetId = Number(hits[0].categoryId)
2027
- if (!Number.isInteger(targetId)) targetId = null
2028
- targetName = clean(hits[0].categoryName)
2029
- } catch (error) {
2030
- // 分组列表接口不可用时直接让 SnowLuma 按名称处理。
2031
- targetId = null
2032
- }
2033
- }
2034
- const body = { uin: userId }
2035
- if (targetId != null) body.categoryId = targetId
2036
- else if (targetName) body.categoryName = targetName
2037
- await onebot('set_friends_category', body, cfg)
2038
- changes.push('移到分组「' + (targetName || targetId) + '」')
2039
- }
2040
- return { ok: true, user_id: userId, remark: hasRemark ? remark : undefined, changes }
2041
- } catch (error) {
2042
- return { ok: false, error: String((error && error.message) || error) }
2043
- }
2044
- },
2045
- render(_args, v) {
2046
- if (!v || !v.ok) return [{ type: 'text', text: '改好友资料失败:' + String((v && v.error) || '未知错误') }]
2047
- return [{ type: 'text', text: `QQ ${v.user_id} 已更新:${(v.changes || []).join(';')}。` }]
2048
- },
2049
- }
2050
- }
2051
-
2052
- export function makeDeleteFriendTool() {
2053
- return {
2054
- name: 'qq_delete_friend',
2055
- description:
2056
- '删除一个好友。不可撤销:只有用户明确要求删好友时才用,并且必须传 confirm=true。' +
2057
- 'block=true 会在删除的同时屏蔽对方。',
2058
- parameters: {
2059
- type: 'object',
2060
- properties: {
2061
- user_id: { type: 'number', description: '好友 QQ 号' },
2062
- block: { type: 'boolean', description: 'true = 删除并屏蔽,默认 false' },
2063
- confirm: { type: 'boolean', description: '必须显式传 true,防止误删' },
2064
- },
2065
- required: ['user_id', 'confirm'],
2066
- additionalProperties: false,
2067
- },
2068
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2069
- async execute(args) {
2070
- const cfg = loadCfg()
2071
- const userId = Number(args && args.user_id)
2072
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
2073
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2074
- if (!args || args.confirm !== true) return { ok: false, error: '删好友不可撤销,需要 confirm=true 才会执行' }
2075
- try {
2076
- const block = args.block === true
2077
- await onebot('delete_friend', { user_id: userId, block }, cfg)
2078
- return { ok: true, removed: true, user_id: userId, blocked: block }
2079
- } catch (error) {
2080
- return { ok: false, error: String((error && error.message) || error) }
2081
- }
2082
- },
2083
- render(_args, v) {
2084
- if (!v || !v.ok) return [{ type: 'text', text: '删好友失败:' + String((v && v.error) || '未知错误') }]
2085
- return [{ type: 'text', text: `已删除好友 QQ ${v.user_id}${v.blocked ? ',并已屏蔽' : ''}。` }]
2086
- },
2087
- }
2088
- }
2089
-
2090
- export function makeSendLikeTool() {
2091
- return {
2092
- name: 'qq_send_like',
2093
- description:
2094
- '给好友的名片点赞(QQ 资料卡赞)。times 是次数,默认 1,最多 20。别频繁调用。',
2095
- parameters: {
2096
- type: 'object',
2097
- properties: {
2098
- user_id: { type: 'number', description: '好友 QQ 号' },
2099
- times: { type: 'number', description: '点赞次数,默认 1,最多 20' },
2100
- },
2101
- required: ['user_id'],
2102
- additionalProperties: false,
2103
- },
2104
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2105
- async execute(args) {
2106
- const cfg = loadCfg()
2107
- const userId = Number(args && args.user_id)
2108
- if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
2109
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2110
- const times = Math.max(1, Math.min(Math.floor(Number(args && args.times) || 1), 20))
2111
- try {
2112
- await onebot('send_like', { user_id: userId, times }, cfg)
2113
- return { ok: true, user_id: userId, times }
2114
- } catch (error) {
2115
- return { ok: false, error: String((error && error.message) || error) }
2116
- }
2117
- },
2118
- render(_args, v) {
2119
- if (!v || !v.ok) return [{ type: 'text', text: '点赞失败:' + String((v && v.error) || '未知错误') }]
2120
- return [{ type: 'text', text: `已给 QQ ${v.user_id} 点了 ${v.times} 个赞。` }]
2121
- },
2122
- }
2123
- }
2124
-
2125
- export function makeListFriendRequestsTool() {
2126
- return {
2127
- name: 'qq_list_friend_requests',
2128
- description:
2129
- '看待处理的好友申请:插件运行期间通过 WebSocket 收到的普通申请 + QQ 账号里保存的可疑好友申请。' +
2130
- '每项带 flag,用 qq_handle_friend_request 同意/拒绝。' +
2131
- '普通申请只在插件启动后收到才在这里,历史申请要去 QQ 客户端看。只读。',
2132
- parameters: {
2133
- type: 'object',
2134
- properties: {
2135
- include_doubt: { type: 'boolean', description: '要不要一起查可疑好友申请,默认 true' },
2136
- },
2137
- additionalProperties: false,
2138
- },
2139
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2140
- async execute(args) {
2141
- const cfg = loadCfg()
2142
- const includeDoubt = !(args && args.include_doubt === false)
2143
- const requests = [...friendRequestInbox.values()]
2144
- .sort((a, b) => (b.time || 0) - (a.time || 0) || (b.at || 0) - (a.at || 0))
2145
- .slice(0, FRIEND_REQUEST_LIMIT)
2146
- if (requests.length) {
2147
- await Promise.all(requests.slice(0, 5).map(async (r) => {
2148
- if (r.nickname || !r.user_id) return
2149
- try {
2150
- const info = await onebot('get_stranger_info', { user_id: Number(r.user_id) }, cfg)
2151
- r.nickname = clean(info && info.nickname)
2152
- } catch (error) {}
2153
- }))
2154
- }
2155
- let doubt = []
2156
- let doubtError = ''
2157
- if (includeDoubt) {
2158
- try {
2159
- const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
2160
- doubt = (Array.isArray(data) ? data : []).map((x) => ({
2161
- kind: 'doubt',
2162
- flag: String((x && x.uid) || ''),
2163
- user_id: String((x && x.user_id) || ''),
2164
- nickname: clean(x && x.nick),
2165
- source: clean(x && x.source),
2166
- reason: clean(x && x.reason),
2167
- comment: String((x && x.msg) || '').slice(0, 500),
2168
- group_code: String((x && x.group_code) || ''),
2169
- time: Number((x && x.reqTime) || 0),
2170
- })).filter((x) => x.flag)
2171
- } catch (error) {
2172
- doubtError = String((error && error.message) || error)
2173
- }
2174
- }
2175
- return {
2176
- ok: true,
2177
- requests: requests.map(({ at, ...r }) => ({ kind: 'friend', ...r })),
2178
- doubt,
2179
- doubt_error: doubtError,
2180
- inbox_note: '普通申请从插件启动后开始收,历史申请不在收件箱里',
2181
- }
2182
- },
2183
- render(_args, v) {
2184
- if (!v || !v.ok) return [{ type: 'text', text: '看好友申请失败:' + String((v && v.error) || '未知错误') }]
2185
- const lines = []
2186
- if (v.requests.length) {
2187
- lines.push(`普通好友申请(${v.requests.length} 个):`)
2188
- for (const r of v.requests) {
2189
- const who = r.nickname ? `${r.nickname}(${r.user_id})` : `QQ ${r.user_id}`
2190
- lines.push(`- ${who}${r.comment ? ':' + r.comment : ''} flag=${r.flag}`)
2191
- }
2192
- }
2193
- if (v.doubt.length) {
2194
- lines.push(`可疑好友申请(${v.doubt.length} 个):`)
2195
- for (const d of v.doubt) {
2196
- const who = d.nickname ? `${d.nickname}(${d.user_id})` : `QQ ${d.user_id}`
2197
- const extra = [d.source && '来源 ' + d.source, d.reason && '原因 ' + d.reason].filter(Boolean).join(',')
2198
- lines.push(`- ${who}${extra ? '(' + extra + ')' : ''}${d.comment ? ':' + d.comment : ''} flag=${d.flag}`)
2199
- }
2200
- }
2201
- if (!v.requests.length && !v.doubt.length) {
2202
- lines.push(v.doubt_error
2203
- ? '现在没有普通好友申请;可疑申请接口不可用:' + v.doubt_error.slice(0, 200)
2204
- : '现在没有待处理的好友申请。')
2205
- } else if (v.doubt_error) {
2206
- lines.push('可疑申请接口不可用:' + v.doubt_error.slice(0, 200))
2207
- }
2208
- if (v.requests.length || v.doubt.length) {
2209
- lines.push('用 qq_handle_friend_request 处理:传 flag、kind(friend/doubt)、approve=true/false。')
2210
- }
2211
- return [{ type: 'text', text: lines.join('\n') }]
2212
- },
2213
- }
2214
- }
2215
-
2216
- export function makeHandleFriendRequestTool() {
2217
- return {
2218
- name: 'qq_handle_friend_request',
2219
- description:
2220
- '同意或拒绝好友申请。flag 从 qq_list_friend_requests 拿,也可以传 user_id 自动在待处理列表里找。' +
2221
- 'kind=friend 是普通申请,kind=doubt 是可疑申请。approve 必须明确传 true(同意)或 false(拒绝)。',
2222
- parameters: {
2223
- type: 'object',
2224
- properties: {
2225
- flag: { type: 'string', description: '申请的处理标识,来自 qq_list_friend_requests' },
2226
- user_id: { type: 'number', description: '申请人 QQ 号;不传 flag 时用它查收件箱' },
2227
- kind: { type: 'string', enum: ['friend', 'doubt'], description: '申请类型,默认 friend' },
2228
- approve: { type: 'boolean', description: 'true = 同意,false = 拒绝' },
2229
- },
2230
- required: ['approve'],
2231
- additionalProperties: false,
2232
- },
2233
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2234
- async execute(args) {
2235
- const cfg = loadCfg()
2236
- const kind = args && args.kind === 'doubt' ? 'doubt' : 'friend'
2237
- const userId = args && args.user_id != null ? Number(args.user_id) : 0
2238
- if (args && args.user_id != null && !Number.isFinite(userId)) {
2239
- return { ok: false, error: 'user_id 不是有效 QQ 号' }
2240
- }
2241
- if (!args || typeof args.approve !== 'boolean') return { ok: false, error: 'approve 必须明确传 true 或 false' }
2242
- const approve = args.approve
2243
- let flag = clean(args && args.flag)
2244
- let nickname = ''
2245
- if (!flag && userId) {
2246
- if (kind === 'friend') {
2247
- const hit = [...friendRequestInbox.values()]
2248
- .filter((r) => String(r.user_id) === String(userId))
2249
- .sort((a, b) => (b.at || 0) - (a.at || 0))[0]
2250
- if (hit) {
2251
- flag = hit.flag
2252
- nickname = hit.nickname
2253
- }
2254
- } else {
2255
- try {
2256
- const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
2257
- const hit = (Array.isArray(data) ? data : []).find((x) => String((x && x.user_id) || '') === String(userId))
2258
- if (hit) {
2259
- flag = String((hit && hit.uid) || '')
2260
- nickname = clean(hit && hit.nick)
2261
- }
2262
- } catch (error) {}
2263
- }
2264
- }
2265
- if (!flag) {
2266
- return { ok: false, error: '没有找到要处理的申请;先用 qq_list_friend_requests 拿 flag' }
2267
- }
2268
- try {
2269
- if (kind === 'doubt') {
2270
- await onebot('set_doubt_friends_add_request', { flag, approve }, cfg)
2271
- } else {
2272
- await onebot('set_friend_add_request', { flag, approve }, cfg)
2273
- friendRequestInbox.delete(flag)
2274
- }
2275
- return { ok: true, kind, approved: approve, flag, user_id: userId || null, nickname }
2276
- } catch (error) {
2277
- return { ok: false, error: String((error && error.message) || error) }
2278
- }
2279
- },
2280
- render(_args, v) {
2281
- if (!v || !v.ok) return [{ type: 'text', text: '处理好友申请失败:' + String((v && v.error) || '未知错误') }]
2282
- const who = v.nickname ? `${v.nickname}(${v.user_id || ''})` : (v.user_id ? 'QQ ' + v.user_id : '这条申请')
2283
- return [{ type: 'text', text: `已${v.approved ? '同意' : '拒绝'}${who}的好友申请。` }]
2284
- },
2285
- }
2286
- }
2287
-
2288
- export function startFriendRequestWatcher(ctx) {
2289
- startWsWatcher(ctx, '好友申请', () => true, (ev) => {
2290
- if (ev && ev.post_type === 'request' && ev.request_type === 'friend') rememberFriendRequest(ev)
2291
- })
2292
- }
2293
-
2294
-
2295
- // ---------- QQ 空间(说说) ----------
2296
- // SnowLuma 的「空间」扩展:send_qzone_msg / get_qzone_msg_list / get_qzone_feeds /
2297
- // get_qzone_msg_detail / like_qzone / unlike_qzone / comment_qzone /
2298
- // delete_qzone_msg / set_qzone_msg_right / set_qzone_ban。
2299
- // 图片统一走 imageSourceOf:http(s)、base64://、file://(本机路径会转成 file://)。
2300
- const QZONE_VISIBILITY = { public: 1, friends: 4, partial: 16, private: 64, exclude: 128 }
2301
- const QZONE_VISIBILITY_NAMES = {
2302
- 1: '所有人可见',
2303
- 4: '好友可见',
2304
- 16: '部分好友可见',
2305
- 64: '仅自己可见',
2306
- 128: '部分好友不可见',
2307
- }
2308
-
2309
- function qzoneRightOf(value) {
2310
- const key = String(value == null ? '' : value).trim()
2311
- if (QZONE_VISIBILITY[key] != null) return QZONE_VISIBILITY[key]
2312
- const n = Number(key)
2313
- if (Number.isInteger(n) && QZONE_VISIBILITY_NAMES[n]) return n
2314
- return 0
2315
- }
2316
-
2317
- function plainText(s) {
2318
- return clean(String(s || '')
2319
- .replace(/<br\s*\/?>/gi, ' ')
2320
- .replace(/<[^>]*>/g, ' ')
2321
- .replace(/&nbsp;/gi, ' ')
2322
- .replace(/&amp;/gi, '&')
2323
- .replace(/&lt;/gi, '<')
2324
- .replace(/&gt;/gi, '>')
2325
- .replace(/&quot;/gi, '"')
2326
- .replace(/&#39;/gi, "'"))
2327
- }
2328
-
2329
- function qzoneImagesOf(list) {
2330
- return (Array.isArray(list) ? list : [])
2331
- .map((x) => {
2332
- const url = String(x || '').trim()
2333
- return url.startsWith('//') ? 'https:' + url : url
2334
- })
2335
- .filter((x) => /^https?:/i.test(x))
2336
- }
2337
-
2338
- async function admitQzoneImages(admitImages, cfg, items) {
2339
- if (typeof admitImages !== 'function') return []
2340
- const picked = []
2341
- for (const item of Array.isArray(items) ? items : []) {
2342
- if (picked.length >= 4) break
2343
- if (!item || !/^https?:/i.test(String(item.url || ''))) continue
2344
- picked.push(item)
2345
- }
2346
- if (!picked.length) return []
2347
- return admitImages(picked.map((item) => ({
2348
- data: { url: item.url },
2349
- cfg,
2350
- name: item.name || 'qq-qzone.jpg',
2351
- caption: item.caption || 'QQ 空间图片',
2352
- })))
2353
- }
2354
-
2355
- function qzonePostOf(x, max = 500) {
2356
- const time = Number((x && x.time) || 0)
2357
- return {
2358
- tid: String((x && x.tid) || ''),
2359
- content: clean((x && x.content) || plainText(x && x.html)).slice(0, max),
2360
- time,
2361
- t: fmtTime(time),
2362
- comment_num: Number((x && (x.comment_num != null ? x.comment_num : x.comment_count)) || 0),
2363
- is_private: !!(x && x.is_private),
2364
- images: qzoneImagesOf(x && x.images),
2365
- }
2366
- }
2367
-
2368
- // 部分 SnowLuma 版本只会把 feed 的 HTML 透传回来,tid/content 仍是 null。
2369
- // 说说(appid=311)的 tid 在 HTML 的 tid="..." / data-tid="..." 里,
2370
- // 取出来就能继续用详情、点赞和评论;补不回来的 feed 保持只读。
2371
- function qzoneTidOf(x) {
2372
- const direct = x && x.tid != null ? String(x.tid).trim() : ''
2373
- if (direct) return direct
2374
- if (Number(x && x.appid) !== 311) return ''
2375
- const html = String((x && x.html) || '')
2376
- const hit = html.match(/(?:data-)?tid[="'\s:]+([0-9a-fA-F]{16,64})/)
2377
- return hit ? hit[1] : ''
2378
- }
2379
-
2380
- export function makeListQzoneTool(admitImages) {
2381
- return {
2382
- name: 'qq_list_qzone',
2383
- description:
2384
- '看 QQ 空间说说。默认看自己的说说;传 target_uin 看别人的;feeds=true 看好友动态(可用 cursor 翻页)。' +
2385
- '好友动态里只保留说说(appid=311);如果 SnowLuma 没从 HTML 里解析出 tid,会尽量自己补回来,补不回来的只能看不能互动。' +
2386
- '返回 tid、正文、时间、评论数和图片;tid 可以传给 qq_get_qzone_post / qq_manage_qzone。只读。',
2387
- parameters: {
2388
- type: 'object',
2389
- properties: {
2390
- feeds: { type: 'boolean', description: 'true = 看好友动态(get_qzone_feeds),默认 false' },
2391
- target_uin: { type: 'number', description: '看谁的说说;不传就是自己(feeds 模式忽略)' },
2392
- pos: { type: 'number', description: '从第几条开始,默认 0(feeds 模式忽略)' },
2393
- count: { type: 'number', description: '本页数量,默认 10,最多 50' },
2394
- cursor: { type: 'string', description: '好友动态翻页游标,传上一页返回的 next_cursor' },
2395
- include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
2396
- },
2397
- additionalProperties: false,
2398
- },
2399
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2400
- async execute(args) {
2401
- const cfg = loadCfg()
2402
- const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 10), 50))
2403
- const withImages = !(args && args.include_images === false)
2404
- try {
2405
- if (args && args.feeds === true) {
2406
- const body = { count }
2407
- const cursor = String((args && args.cursor) || '').trim()
2408
- if (cursor) body.cursor = cursor
2409
- const data = await onebot('get_qzone_feeds', body, cfg)
2410
- const rawFeeds = Array.isArray(data && data.feeds) ? data.feeds : []
2411
- const feeds = rawFeeds.map((x) => {
2412
- const post = qzonePostOf(x)
2413
- const tid = qzoneTidOf(x)
2414
- return {
2415
- ...post,
2416
- tid,
2417
- actionable: !!tid,
2418
- appid: Number((x && x.appid) || 0),
2419
- uin: String((x && x.uin) || ''),
2420
- name: clean(x && x.nickname),
2421
- like_count: Number((x && x.like_count) || 0),
2422
- content_complete: !!(x && x.content_complete),
2423
- }
2424
- }).filter((x) => x.appid === 311 || x.tid)
2425
- const imageItems = []
2426
- for (const f of feeds) {
2427
- for (const url of f.images) {
2428
- imageItems.push({
2429
- url,
2430
- name: `qq-qzone-${f.tid}.jpg`,
2431
- caption: `好友动态 ${f.tid} 的图片(${f.name || f.uin || '好友'})`,
2432
- })
2433
- }
2434
- }
2435
- const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
2436
- return {
2437
- ok: true,
2438
- mode: 'feeds',
2439
- count: feeds.length,
2440
- feeds,
2441
- skipped: rawFeeds.length - feeds.length,
2442
- has_more: !!(data && data.has_more),
2443
- next_cursor: String((data && data.next_cursor) || ''),
2444
- images,
2445
- }
2446
- }
2447
- const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2448
- if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2449
- return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2450
- }
2451
- if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2452
- const body = {
2453
- pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)),
2454
- num: count,
2455
- }
2456
- if (targetUin) body.target_uin = targetUin
2457
- const data = await onebot('get_qzone_msg_list', body, cfg)
2458
- const raw = listOf(data, 'msglist')
2459
- const posts = raw.map((x) => qzonePostOf(x)).filter((x) => x.tid)
2460
- const imageItems = []
2461
- for (const p of posts) {
2462
- for (const url of p.images) {
2463
- imageItems.push({
2464
- url,
2465
- name: `qq-qzone-${p.tid}.jpg`,
2466
- caption: `说说 ${p.tid} 的图片${p.content ? '(' + p.content.slice(0, 30) + ')' : ''}`,
2467
- })
2468
- }
2469
- }
2470
- const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
2471
- return {
2472
- ok: true,
2473
- mode: 'list',
2474
- target_uin: targetUin || null,
2475
- total: Number((data && data.total) || posts.length),
2476
- count: posts.length,
2477
- posts,
2478
- images,
2479
- }
2480
- } catch (error) {
2481
- return { ok: false, error: String((error && error.message) || error) }
2482
- }
2483
- },
2484
- render(_args, v) {
2485
- if (!v || !v.ok) return [{ type: 'text', text: '看 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
2486
- const blocks = []
2487
- if (v.mode === 'feeds') {
2488
- if (!v.feeds.length) {
2489
- return [{ type: 'text', text: '好友动态里现在没有能识别的说说。' + (v.skipped ? `(跳过 ${v.skipped} 条非说说动态)` : '') }]
2490
- }
2491
- const lines = [`好友动态(${v.feeds.length} 条${v.has_more ? ',还有更多' : ''}):`]
2492
- for (const f of v.feeds) {
2493
- const tag = f.tid ? `[tid=${f.tid}]` : '[没有 tid,只能看]'
2494
- lines.push(`- ${f.name || f.uin || '好友'}(${f.uin || '?'}) ${tag} ${f.t} 赞 ${f.like_count} 评论 ${f.comment_num}`)
2495
- if (f.content) lines.push(' ' + f.content.split('\n').join('\n '))
2496
- }
2497
- if (v.next_cursor) lines.push(`翻下一页:feeds=true,cursor=${v.next_cursor}`)
2498
- if (v.skipped) lines.push(`另有 ${v.skipped} 条非说说动态(相册、广告等)没有列出。`)
2499
- if (v.feeds.some((f) => !f.tid)) lines.push('没有 tid 的动态不能直接点赞/评论,可以配合 qq_get_qzone_post 或去 QQ 客户端看。')
2500
- blocks.push({ type: 'text', text: lines.join('\n') })
2501
- } else {
2502
- if (!v.posts.length) return [{ type: 'text', text: '没有看到说说。' }]
2503
- const where = v.target_uin ? `QQ ${v.target_uin} 的空间` : '你的空间'
2504
- const lines = [`${where}(共 ${v.total} 条,本页 ${v.count} 条):`]
2505
- for (const p of v.posts) {
2506
- lines.push(`- [tid=${p.tid}] ${p.t} 评论 ${p.comment_num}${p.is_private ? '(仅自己可见)' : ''}`)
2507
- if (p.content) lines.push(' ' + p.content.split('\n').join('\n '))
2508
- }
2509
- lines.push('看评论详情用 qq_get_qzone_post;发新说说用 qq_send_qzone_post。')
2510
- blocks.push({ type: 'text', text: lines.join('\n') })
2511
- }
2512
- for (const img of Array.isArray(v.images) ? v.images : []) {
2513
- if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
2514
- if (img && img.block) blocks.push(img.block)
2515
- }
2516
- return blocks
2517
- },
2518
- }
2519
- }
2520
-
2521
- export function makeGetQzonePostTool(admitImages) {
2522
- return {
2523
- name: 'qq_get_qzone_post',
2524
- description:
2525
- '看一条说说的详情和评论(SnowLuma get_qzone_msg_detail)。tid 从 qq_list_qzone 拿。' +
2526
- '返回评论的 comment_id 和 uin;回复时传给 qq_manage_qzone:' +
2527
- '回复一级评论传 comment_id + comment_uin,回复楼中楼再传 reply_uin + reply_name。只读。',
2528
- parameters: {
2529
- type: 'object',
2530
- properties: {
2531
- tid: { type: 'string', description: '说说 tid' },
2532
- target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
2533
- pos: { type: 'number', description: '从第几条评论开始,默认 0' },
2534
- count: { type: 'number', description: '看多少条一级评论,默认 20,最多 50' },
2535
- include_images: { type: 'boolean', description: '详情里带图片时要不要看图,默认 true' },
2536
- },
2537
- required: ['tid'],
2538
- additionalProperties: false,
2539
- },
2540
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2541
- async execute(args) {
2542
- const cfg = loadCfg()
2543
- const tid = args && args.tid != null ? String(args.tid).trim() : ''
2544
- if (!tid) return { ok: false, error: 'tid 不能为空' }
2545
- const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2546
- if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2547
- return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2548
- }
2549
- if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2550
- const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 20), 50))
2551
- try {
2552
- const body = { tid, pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)), num: count }
2553
- if (targetUin) body.target_uin = targetUin
2554
- const data = await onebot('get_qzone_msg_detail', body, cfg)
2555
- const comments = (Array.isArray(data && data.comments) ? data.comments : []).map((c) => ({
2556
- uin: String((c && (c.uin || c.user_id)) || ''),
2557
- name: clean(c && (c.name || c.nickname)),
2558
- time: Number((c && c.time) || 0),
2559
- t: fmtTime(c && c.time),
2560
- content: cleanMultiline(c && c.content, 1000),
2561
- comment_id: c && c.comment_id != null ? String(c.comment_id) : '',
2562
- replies: (Array.isArray(c && c.replies) ? c.replies : []).map((r) => ({
2563
- uin: String((r && (r.uin || r.user_id)) || ''),
2564
- name: clean(r && (r.name || r.nickname)),
2565
- time: Number((r && r.time) || 0),
2566
- t: fmtTime(r && r.time),
2567
- content: cleanMultiline(r && r.content, 1000),
2568
- comment_id: r && r.comment_id != null ? String(r.comment_id) : '',
2569
- parent_comment_id: r && r.parent_comment_id != null ? String(r.parent_comment_id) : '',
2570
- parent_comment_uin: r && r.parent_comment_uin != null ? String(r.parent_comment_uin) : '',
2571
- })),
2572
- }))
2573
- const post = {
2574
- tid: String((data && data.tid) || tid),
2575
- content: cleanMultiline(data && data.content, 4000),
2576
- comment_num: Number((data && data.comment_num) || 0),
2577
- comments,
2578
- images: qzoneImagesOf(data && data.images),
2579
- }
2580
- let images = []
2581
- if (!(args && args.include_images === false) && post.images.length) {
2582
- images = await admitQzoneImages(admitImages, cfg, post.images.map((url) => ({
2583
- url,
2584
- name: `qq-qzone-${post.tid}.jpg`,
2585
- caption: `说说 ${post.tid} 的图片`,
2586
- })))
2587
- }
2588
- return { ok: true, post, images }
2589
- } catch (error) {
2590
- return { ok: false, error: String((error && error.message) || error) }
2591
- }
2592
- },
2593
- render(_args, v) {
2594
- if (!v || !v.ok) return [{ type: 'text', text: '看说说详情失败:' + String((v && v.error) || '未知错误') }]
2595
- const p = v.post || {}
2596
- const lines = [`说说 ${p.tid}:`, p.content || '(没有正文)']
2597
- lines.push(`评论 ${p.comment_num || 0} 条:`)
2598
- if (!p.comments.length) lines.push('(还没有评论)')
2599
- for (const c of p.comments) {
2600
- lines.push(`- ${c.name || c.uin}(${c.uin}) [comment_id=${c.comment_id}] ${c.t}:${c.content || '(空)'}`)
2601
- for (const r of c.replies) {
2602
- lines.push(` ↳ ${r.name || r.uin}(${r.uin}) [comment_id=${r.comment_id} parent=${r.parent_comment_id}] ${r.t}:${r.content || '(空)'}`)
2603
- }
2604
- }
2605
- if (p.comments.length) {
2606
- lines.push('回复一级评论:qq_manage_qzone action=comment,传 comment_id + comment_uin=该评论 uin;' +
2607
- '回复楼中楼再加 reply_uin + reply_name,comment_id 仍用一级评论的 id(不要用二级回复自己的 id)。')
2608
- }
2609
- const blocks = [{ type: 'text', text: lines.join('\n') }]
2610
- for (const img of Array.isArray(v.images) ? v.images : []) {
2611
- if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
2612
- if (img && img.block) blocks.push(img.block)
2613
- }
2614
- return blocks
2615
- },
2616
- }
2617
- }
2618
-
2619
- export function makeSendQzonePostTool() {
2620
- return {
2621
- name: 'qq_send_qzone_post',
2622
- description:
2623
- '发一条 QQ 空间说说(SnowLuma send_qzone_msg)。content 是正文;' +
2624
- 'images 是图片(http(s)、base64://、本机路径,最多 9 张),也可以用 message_id 从消息里取图。' +
2625
- 'visibility:public=所有人,friends=好友,partial=指定好友可见,private=仅自己,exclude=指定好友不可见;' +
2626
- 'partial/exclude 要传 target_uins。发说说对外可见,只有用户明确要求时才用。',
2627
- parameters: {
2628
- type: 'object',
2629
- properties: {
2630
- content: { type: 'string', description: '说说正文,不要 Markdown' },
2631
- images: { type: 'array', items: { type: 'string' }, description: '图片列表,最多 9 张' },
2632
- message_id: { type: 'string', description: '从这条消息里取图片一起发' },
2633
- image_indexes: { type: 'array', items: { type: 'number' }, description: 'message_id 里要哪几张图片(从 0 开始);不传就全取' },
2634
- visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: '可见范围,默认 public' },
2635
- target_uins: { type: 'array', items: { type: 'number' }, description: 'partial/exclude 时的 QQ 号列表' },
2636
- },
2637
- required: ['content'],
2638
- additionalProperties: false,
2639
- },
2640
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2641
- async execute(args) {
2642
- const cfg = loadCfg()
2643
- const content = cleanMultiline(args && args.content, 2000)
2644
- if (!content) return { ok: false, error: '说说正文是空的' }
2645
- const visibilityKey = String((args && args.visibility) || 'public')
2646
- const ugcRight = qzoneRightOf(visibilityKey)
2647
- if (!ugcRight) return { ok: false, error: 'visibility 只能是 public/friends/partial/private/exclude' }
2648
- const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
2649
- .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
2650
- if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
2651
- return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
2652
- }
2653
- try {
2654
- const images = (Array.isArray(args && args.images) ? args.images : [])
2655
- .map(imageSourceOf).filter(Boolean)
2656
- const messageId = args && args.message_id
2657
- if (messageId != null && messageId !== '') {
2658
- if (images.length >= 9) return { ok: false, error: '最多 9 张图片' }
2659
- const got = await imageSourcesFromMessage(messageId, args.image_indexes, cfg, 9 - images.length)
2660
- if (!got.sources.length) {
2661
- return {
2662
- ok: false,
2663
- error: got.total
2664
- ? `选中的图片序号没有匹配到(这条消息共 ${got.total} 张)`
2665
- : '这条消息里没有图片',
2666
- }
2667
- }
2668
- images.push(...got.sources)
2669
- }
2670
- if (images.length > 9) return { ok: false, error: '最多 9 张图片' }
2671
- const body = { content, ugc_right: ugcRight }
2672
- if (images.length) body.images = images
2673
- if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
2674
- const data = await onebot('send_qzone_msg', body, cfg)
2675
- return {
2676
- ok: true,
2677
- tid: String((data && data.tid) || ''),
2678
- time: Number((data && data.time) || 0),
2679
- visibility: QZONE_VISIBILITY_NAMES[ugcRight],
2680
- image_count: images.length,
2681
- }
2682
- } catch (error) {
2683
- return { ok: false, error: String((error && error.message) || error) }
2684
- }
2685
- },
2686
- render(_args, v) {
2687
- if (!v || !v.ok) return [{ type: 'text', text: '发说说失败:' + String((v && v.error) || '未知错误') }]
2688
- const extra = v.image_count ? `,${v.image_count} 张图` : ''
2689
- return [{ type: 'text', text: `说说已发布(tid ${v.tid || '?'}${extra},${v.visibility || '所有人可见'})。` }]
2690
- },
2691
- }
2692
- }
2693
-
2694
- export function makeManageQzoneTool() {
2695
- return {
2696
- name: 'qq_manage_qzone',
2697
- description:
2698
- '管理 QQ 空间说说。action:\n' +
2699
- '- like / unlike:点赞或取消赞,需要 tid(abstime 传列表里的 time 更可靠)\n' +
2700
- '- comment:评论或回复,需要 tid + content;回复一级评论再传 comment_id + comment_uin,' +
2701
- '回复楼中楼再传 reply_uin + reply_name;只有普通评论能带图\n' +
2702
- '- delete:删除自己空间的一条说说,需要 tid + confirm=true,不可撤销\n' +
2703
- '- set_right:改自己空间一条说说的可见范围,需要 tid + visibility(partial/exclude 再传 target_uins)\n' +
2704
- '- ban / unban:拉黑或解除拉黑某人的空间访问,需要 user_id',
2705
- parameters: {
2706
- type: 'object',
2707
- properties: {
2708
- action: {
2709
- type: 'string',
2710
- enum: ['like', 'unlike', 'comment', 'delete', 'set_right', 'ban', 'unban'],
2711
- description: '要做什么',
2712
- },
2713
- tid: { type: 'string', description: '说说 tid;like/unlike/comment/delete/set_right 需要' },
2714
- target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
2715
- abstime: { type: 'number', description: '说说发表时间(unix 秒),点赞时传更可靠' },
2716
- content: { type: 'string', description: '评论内容(action=comment)' },
2717
- images: { type: 'array', items: { type: 'string' }, description: '评论图片(普通评论可选,最多 9 张)' },
2718
- comment_id: { type: 'string', description: '要回复的一级评论 ID(评论详情里的 comment_id)' },
2719
- comment_uin: { type: 'number', description: '一级评论作者 QQ 号' },
2720
- reply_uin: { type: 'number', description: '楼中楼里要回复的用户 QQ 号' },
2721
- reply_name: { type: 'string', description: '楼中楼里要回复的用户昵称' },
2722
- visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: 'set_right 的新可见范围' },
2723
- target_uins: { type: 'array', items: { type: 'number' }, description: 'set_right 时 partial/exclude 的 QQ 号列表' },
2724
- user_id: { type: 'number', description: 'ban/unban 的目标 QQ 号' },
2725
- confirm: { type: 'boolean', description: 'delete 时必须显式传 true' },
2726
- },
2727
- required: ['action'],
2728
- additionalProperties: false,
2729
- },
2730
- output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2731
- async execute(args) {
2732
- const cfg = loadCfg()
2733
- const action = String((args && args.action) || '')
2734
- const tid = args && args.tid != null ? String(args.tid).trim() : ''
2735
- const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2736
- if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2737
- return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2738
- }
2739
- if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2740
- const needTid = ['like', 'unlike', 'comment', 'delete', 'set_right']
2741
- if (needTid.includes(action) && !tid) return { ok: false, error: action + ' 需要 tid' }
2742
- try {
2743
- if (action === 'like' || action === 'unlike') {
2744
- const body = { tid }
2745
- if (targetUin) body.target_uin = targetUin
2746
- const abstime = Number(args && args.abstime)
2747
- if (Number.isFinite(abstime) && abstime > 0) body.abstime = Math.floor(abstime)
2748
- await onebot(action === 'like' ? 'like_qzone' : 'unlike_qzone', body, cfg)
2749
- return { ok: true, action, tid, liked: action === 'like' }
2750
- }
2751
- if (action === 'comment') {
2752
- const content = cleanMultiline(args && args.content, 1000)
2753
- if (!content) return { ok: false, error: '评论内容是空的' }
2754
- const images = (Array.isArray(args && args.images) ? args.images : [])
2755
- .map(imageSourceOf).filter(Boolean)
2756
- if (images.length > 9) return { ok: false, error: '评论最多 9 张图片' }
2757
- const parentId = args && args.comment_id != null ? String(args.comment_id).trim() : ''
2758
- const hasParentUin = args && args.comment_uin != null
2759
- const parentUin = hasParentUin ? Number(args.comment_uin) : 0
2760
- const hasReplyUin = args && args.reply_uin != null
2761
- const replyUin = hasReplyUin ? Number(args.reply_uin) : 0
2762
- const replyName = clean(args && args.reply_name)
2763
- if (parentId && (!hasParentUin || !Number.isInteger(parentUin) || parentUin <= 0)) {
2764
- return { ok: false, error: 'comment_id 和 comment_uin 要一起传,comment_uin 是正整数 QQ 号' }
2765
- }
2766
- if (hasParentUin && !parentId) {
2767
- return { ok: false, error: 'comment_uin 要配合 comment_id 一起传' }
2768
- }
2769
- if (hasReplyUin && (!Number.isInteger(replyUin) || replyUin <= 0)) {
2770
- return { ok: false, error: 'reply_uin 不是有效 QQ 号' }
2771
- }
2772
- if (hasReplyUin && (!parentId || !replyName)) {
2773
- return { ok: false, error: '回复楼中楼需要 comment_id + comment_uin + reply_uin + reply_name' }
2774
- }
2775
- if (parentId && images.length) return { ok: false, error: '回复评论只能发文字,不能带图' }
2776
- const body = { tid, content }
2777
- if (targetUin) body.target_uin = targetUin
2778
- if (images.length) body.images = images
2779
- if (parentId) {
2780
- body.comment_id = parentId
2781
- body.comment_uin = parentUin
2782
- }
2783
- if (replyUin) {
2784
- body.reply_uin = replyUin
2785
- body.reply_name = replyName
2786
- }
2787
- const data = await onebot('comment_qzone', body, cfg)
2788
- return {
2789
- ok: true,
2790
- action,
2791
- tid,
2792
- comment_id: data && data.comment_id != null ? String(data.comment_id) : '',
2793
- parent_comment_id: data && data.parent_comment_id != null ? String(data.parent_comment_id) : '',
2794
- parent_comment_uin: Number((data && data.parent_comment_uin) || 0),
2795
- }
2796
- }
2797
- if (action === 'delete') {
2798
- if (!args || args.confirm !== true) {
2799
- return { ok: false, error: '删说说不可撤销,需要 confirm=true 才会执行' }
2800
- }
2801
- await onebot('delete_qzone_msg', { tid }, cfg)
2802
- return { ok: true, action, tid, deleted: true }
2803
- }
2804
- if (action === 'set_right') {
2805
- const ugcRight = qzoneRightOf(args && args.visibility)
2806
- if (!ugcRight) return { ok: false, error: 'set_right 需要 visibility(public/friends/partial/private/exclude)' }
2807
- const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
2808
- .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
2809
- if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
2810
- return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
2811
- }
2812
- const body = { tid, ugc_right: ugcRight }
2813
- if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
2814
- const data = await onebot('set_qzone_msg_right', body, cfg)
2815
- return {
2816
- ok: true,
2817
- action,
2818
- tid,
2819
- ugc_right: Number((data && data.ugc_right) || ugcRight),
2820
- visibility: QZONE_VISIBILITY_NAMES[Number((data && data.ugc_right) || ugcRight)],
2821
- }
2822
- }
2823
- if (action === 'ban' || action === 'unban') {
2824
- const userId = Number(args && args.user_id)
2825
- if (!Number.isFinite(userId) || !userId) return { ok: false, error: 'ban/unban 需要 user_id' }
2826
- if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2827
- await onebot('set_qzone_ban', { user_id: userId, enable: action === 'ban' }, cfg)
2828
- return { ok: true, action, user_id: userId, banned: action === 'ban' }
2829
- }
2830
- return { ok: false, error: '不认识的 action:' + action }
2831
- } catch (error) {
2832
- return { ok: false, error: String((error && error.message) || error) }
2833
- }
2834
- },
2835
- render(_args, v) {
2836
- if (!v || !v.ok) return [{ type: 'text', text: '操作 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
2837
- if (v.action === 'like' || v.action === 'unlike') {
2838
- return [{ type: 'text', text: `${v.liked ? '已点赞' : '已取消赞'}(tid ${v.tid})。` }]
2839
- }
2840
- if (v.action === 'comment') {
2841
- return [{ type: 'text', text: `评论已发(tid ${v.tid},comment_id ${v.comment_id || '?'})。` }]
2842
- }
2843
- if (v.action === 'delete') {
2844
- return [{ type: 'text', text: `已删除说说 ${v.tid}。` }]
2845
- }
2846
- if (v.action === 'set_right') {
2847
- return [{ type: 'text', text: `说说 ${v.tid} 的可见范围已改为「${v.visibility || v.ugc_right}」。` }]
2848
- }
2849
- if (v.action === 'ban' || v.action === 'unban') {
2850
- return [{ type: 'text', text: `已${v.banned ? '拉黑' : '解除拉黑'} QQ ${v.user_id} 的空间访问。` }]
2851
- }
2852
- return [{ type: 'text', text: 'QQ 空间操作完成。' }]
2853
- },
2854
- }
2855
- }
2856
-
2857
-
2858
- // ---------- 当前活着的工具组件 ----------
2859
- // @ 提醒的提示词只提还开着的工具,免得模型去调一个被关掉的组件。
2860
- const activeCounts = new Map()
2861
-
2862
- function markActive(name, delta) {
2863
- const next = Math.max(0, (activeCounts.get(name) || 0) + delta)
2864
- if (next === 0) activeCounts.delete(name)
2865
- else activeCounts.set(name, next)
2866
- }
2867
-
2868
- export function hasActiveTool(name) {
2869
- return (activeCounts.get(name) || 0) > 0
2870
- }
2871
-
2872
- function lookHint() {
2873
- const parts = []
2874
- if (hasActiveTool('qq_list_groups')) parts.push('先用 qq_list_groups 看看群')
2875
- if (hasActiveTool('qq_get_group_history')) parts.push('再用 qq_get_group_history 看 2~3 个群最近 30 条')
2876
- return parts.length ? '你可以' + parts.join(',') + ',' : ''
2877
- }
2878
-
2879
-
2880
- // ---------- 观察窗(群聊发消息 / 戳回去之后的后续消息) ----------
2881
- // 私聊不走观察窗:每条私聊消息都会直接唤醒目标会话。
2882
- const windows = new Map()
2883
-
2884
- export function startObservation(groupId, reason) {
2885
- const cfg = loadCfg()
2886
- const id = String(groupId || '')
2887
- if (!id) return
2888
- const ms = Math.max(30000, Math.min(Number(cfg.observationMs) || 60000, 600000))
2889
- windows.set(id, Date.now() + ms)
2890
- diag('观察窗:群 ' + id + ' 保持 ' + Math.round(ms / 1000) + ' 秒(' + String(reason || '') + ')')
2891
- }
2892
-
2893
-
2894
- // ---------- 把一个工具挂到目标会话 ----------
2895
- // 每个工具组件各自调用一次:自己找目标会话、自己挂、自己卸,
2896
- // 所以关掉某个组件只会影响它自己的工具。
2897
- export function mountOneTool(ctx, tool) {
2898
- ensureCfg()
2899
- markActive(tool.name, 1)
2900
- const mounted = new Map()
2901
-
2902
- function unmountAll(reason) {
2903
- for (const [sid, v] of mounted) {
2904
- try {
2905
- if (typeof v.off === 'function') v.off()
2906
- } catch (error) {}
2907
- diag('工具 ' + tool.name + ' 已从会话 ' + sid + ' 卸下:' + reason)
2908
- }
2909
- mounted.clear()
2910
- }
2911
-
2912
- function tick() {
2913
- const target = targetSessionId()
2914
- if (!target) {
2915
- if (mounted.size) unmountAll('没有目标会话')
2916
- return
2917
- }
2918
- const agent = findAgent(ctx.agents, target)
2919
- if (!agent) {
2920
- if (mounted.size) unmountAll('目标会话没有活着')
2921
- return
2922
- }
2923
- const sid = sessionIdOf(agent)
2924
- if (mounted.has(sid)) return
2925
- unmountAll('目标会话变了')
2926
- try {
2927
- const off = agent.ctx.tools.register(tool)
2928
- mounted.set(sid, { agent, off })
2929
- diag('工具 ' + tool.name + ' 已挂到会话 ' + sid)
2930
- } catch (error) {
2931
- diag('工具挂载失败 ' + tool.name + ':' + String((error && error.message) || error))
2932
- }
2933
- }
2934
-
2935
- tick()
2936
- ctx.effect(() => ctx.interval(tick, 3000))
2937
- ctx.effect(() => () => {
2938
- markActive(tool.name, -1)
2939
- unmountAll('插件卸载')
2940
- })
2941
- return { tool, tick, unmountAll, mounted }
2942
- }
2943
-
2944
-
2945
- // ---------- @ 提醒轮询(由「发群消息」组件启动) ----------
2946
- let selfId = ''
2947
- let names = new Map()
2948
- let namesAt = 0
2949
- let lastSeen = new Map()
2950
- let seen = new Set()
2951
- let busy = false
2952
- let noAgentAt = 0
2953
-
2954
- async function refreshNames(cfg) {
2955
- if (names.size && Date.now() - namesAt < 300000) return
2956
- try {
2957
- const data = await onebot('get_group_list', {}, cfg)
2958
- const next = new Map()
2959
- for (const g of Array.isArray(data) ? data : []) next.set(String(g.group_id), clean(g.group_name || g.group_id))
2960
- if (next.size) {
2961
- names = next
2962
- namesAt = Date.now()
2963
- }
2964
- } catch (error) {}
2965
- }
2966
-
2967
- async function pollMentions(ctx) {
2968
- const cfg = loadCfg()
2969
- if (!cfg.mentionEnabled) return
2970
- if (busy) return
2971
- busy = true
2972
- try {
2973
- const target = targetSessionId()
2974
- const agent = findAgent(ctx.agents, target)
2975
- if (!agent) {
2976
- if (Date.now() - noAgentAt > 60000) {
2977
- noAgentAt = Date.now()
2978
- diag('@ 轮询:目标会话没有活着(' + (target || '未设置') + ')')
2979
- }
2980
- return
2981
- }
2982
- if (!selfId) {
2983
- try {
2984
- const info = await onebot('get_login_info', {}, cfg)
2985
- selfId = String((info && info.user_id) || '')
2986
- } catch (error) {}
2987
- }
2988
- const now = Math.floor(Date.now() / 1000)
2989
- if (!lastSeen.size) {
2990
- for (const gid of cfg.groups) lastSeen.set(String(gid), now)
2991
- diag('@ 轮询初始化,从现在开始(' + now + ')')
2992
- }
2993
- await refreshNames(cfg)
2994
- const mentions = []
2995
- const followups = new Map()
2996
- for (const gid of cfg.groups) {
2997
- let messages = []
2998
- try {
2999
- messages = listOf(await onebot('get_group_msg_history', { group_id: Number(gid), count: 10 }, cfg), 'messages')
3000
- } catch (error) {
3001
- continue
3002
- }
3003
- const prev = lastSeen.get(String(gid)) || 0
3004
- let maxTime = prev
3005
- for (const m of listOf(messages, 'messages').slice().sort((a, b) => (a.time || 0) - (b.time || 0))) {
3006
- const t = Number(m.time || 0)
3007
- if (t < prev) continue
3008
- if (t > maxTime) maxTime = t
3009
- const mid = m.message_id
3010
- if (mid != null) {
3011
- if (seen.has(mid)) continue
3012
- seen.add(mid)
3013
- }
3014
- const uid = Number((m.sender && m.sender.user_id) || m.user_id || 0)
3015
- if (selfId && uid === Number(selfId)) continue
3016
- const segs = Array.isArray(m.message) ? m.message : []
3017
- const atMe = segs.some(
3018
- (s) => s && s.type === 'at' && (String(s.data && s.data.qq) === selfId || String(s.data && s.data.qq) === 'all'),
3019
- )
3020
- const text = segText(segs) || '(没有文字)'
3021
- const name = clean((m.sender && (m.sender.card || m.sender.nickname)) || String(uid))
3022
- const senderQQ = String(uid || (m.sender && m.sender.user_id) || '?')
3023
- const winUntil = windows.get(String(gid)) || 0
3024
- if (Date.now() < winUntil) {
3025
- const arr = followups.get(String(gid)) || []
3026
- arr.push({
3027
- gid: String(gid),
3028
- group: names.get(String(gid)) || String(gid),
3029
- sender: name,
3030
- user_id: senderQQ,
3031
- message_id: mid,
3032
- text,
3033
- time: t,
3034
- })
3035
- followups.set(String(gid), arr)
3036
- } else if (atMe) {
3037
- mentions.push({
3038
- gid: String(gid),
3039
- group: names.get(String(gid)) || String(gid),
3040
- sender: name,
3041
- user_id: senderQQ,
3042
- message_id: mid,
3043
- text,
3044
- time: t,
3045
- })
3046
- }
3047
- }
3048
- lastSeen.set(String(gid), maxTime)
3049
- }
3050
- if (seen.size > 500) seen.clear()
3051
- for (const [gid, until] of windows) if (Date.now() >= until) windows.delete(gid)
3052
- if (!mentions.length && followups.size === 0) return
3053
- const fmtLine = (x) => formatIncomingLine(x.sender, x.user_id, x.text, x.message_id)
3054
- const lines = mentions.slice(-5).map((x) => `群「${x.group}」${fmtLine(x)}`)
3055
- const sendTools = []
3056
- if (hasActiveTool('qq_send_group_message')) sendTools.push('qq_send_group_message')
3057
- if (hasActiveTool('qq_send_face')) sendTools.push('qq_send_face')
3058
- const sendHint = sendTools.length ? '想说话可以用 ' + sendTools.join(' / ') + ' 回复;' : ''
3059
- const quoteHint = hasActiveTool('qq_send_group_message')
3060
- ? '确实要指代某条消息时才传 reply_to=消息编号;必须提醒某个人时才传 at_user_ids=[QQ号];'
3061
- : ''
3062
- const prompt =
3063
- '【群聊 @ 提醒】有人 @ 了你:\n' +
3064
- lines.join('\n') +
3065
- '\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + quoteHint + '不想说话就安静待着;' +
3066
- '不要提工具和私聊内容。' + CHAT_STYLE
3067
- const message = {
3068
- id: randomUUID(),
3069
- role: 'user',
3070
- content: [{ type: 'text', text: prompt }],
3071
- source: { kind: 'qq-mention' },
3072
- }
3073
- if (mentions.length) {
3074
- await agent.followup(message)
3075
- diag('@ 提醒已送进会话:' + lines.join(' | ').slice(0, 300))
3076
- }
3077
- for (const [gid, list] of followups) {
3078
- const group = list[0] && list[0].group ? list[0].group : gid
3079
- const lines2 = list.slice(-10).map(fmtLine)
3080
- const prompt2 =
3081
- '【群聊后续】你刚才在群「' + group + '」里说过话,现在又有人说话:\n' +
3082
- lines2.join('\n') +
3083
- '\n想接就接;' +
3084
- (hasActiveTool('qq_send_group_message') ? '想说话用 qq_send_group_message 发到这个群,确实要指代某条才传 reply_to=消息编号,必须提醒某人才传 at_user_ids=[QQ号];' : '') +
3085
- (hasActiveTool('qq_send_face') ? '想回表情用 qq_send_face;' : '') +
3086
- '不想接就安静待着。' + CHAT_STYLE
3087
- await agent.followup({
3088
- id: randomUUID(),
3089
- role: 'user',
3090
- content: [{ type: 'text', text: prompt2 }],
3091
- source: { kind: 'qq-followup' },
3092
- })
3093
- diag('群后续 → 会话:群 ' + gid + ' ' + lines2.join(' | ').slice(0, 240))
3094
- }
3095
- } catch (error) {
3096
- diag('@ 轮询出错:' + String((error && error.message) || error))
3097
- } finally {
3098
- busy = false
3099
- }
3100
- }
3101
-
3102
- export function startMentionPolling(ctx) {
3103
- // 组件重新加载时从头开始,避免把停用期间的旧 @ 一次性翻出来。
3104
- selfId = ''
3105
- names = new Map()
3106
- namesAt = 0
3107
- lastSeen = new Map()
3108
- seen = new Set()
3109
- busy = false
3110
- noAgentAt = 0
3111
- ctx.effect(() => ctx.interval(() => pollMentions(ctx), loadCfg().pollMs))
3112
- }
3113
-
3114
-
3115
- // ---------- 戳一戳(由「戳一戳」组件启动) ----------
3116
- const pokeSeen = new Set()
3117
- const memberNames = new Map()
3118
-
3119
- async function memberName(groupId, userId) {
3120
- const key = String(groupId) + ':' + String(userId)
3121
- const hit = memberNames.get(key)
3122
- if (hit && Date.now() - hit.at < 300000) return hit.name
3123
- try {
3124
- const d = await onebot('get_group_member_info', { group_id: Number(groupId), user_id: Number(userId) }, loadCfg())
3125
- const name = clean((d && (d.card || d.nickname)) || userId)
3126
- memberNames.set(key, { name, at: Date.now() })
3127
- return name
3128
- } catch (error) {
3129
- return String(userId)
3130
- }
3131
- }
3132
-
3133
- async function handlePoke(ctx, ev) {
3134
- const cfg = loadCfg()
3135
- if (!cfg.pokeEnabled) return
3136
- const self = String(ev.self_id || selfId || '')
3137
- const sender = String(ev.user_id || ev.sender_id || '')
3138
- const target = String(ev.target_id || '')
3139
- if (!self || !sender || sender === self) return
3140
- if (target && target !== self) return
3141
- const groupId = ev.group_id ? String(ev.group_id) : ''
3142
- if (!groupId && !allowedPrivate(sender, cfg)) return
3143
- const key = [ev.time, groupId, sender, target].join('|')
3144
- if (pokeSeen.has(key)) return
3145
- pokeSeen.add(key)
3146
- if (pokeSeen.size > 300) pokeSeen.clear()
3147
- const agent = findAgent(ctx.agents, targetSessionId())
3148
- if (!agent) {
3149
- diag('戳一戳:目标会话没有活着')
3150
- return
3151
- }
3152
- const name = groupId ? await memberName(groupId, sender) : sender
3153
- const groupName = groupId ? (names.get(groupId) || groupId) : ''
3154
- let prompt
3155
- if (groupId) {
3156
- const actions = []
3157
- if (hasActiveTool('qq_send_group_message')) actions.push('想回就用 qq_send_group_message 发到这个群')
3158
- if (hasActiveTool('qq_send_face')) actions.push('想回个表情用 qq_send_face(group_id=' + groupId + ')')
3159
- if (hasActiveTool('qq_send_poke')) actions.push(`想戳回去用 qq_send_poke(group_id=${groupId}, user_id=${sender})`)
3160
- prompt = `【戳一戳】群「${groupName}」里 ${name}(${sender}) 戳了你一下。`
3161
- + (actions.length ? actions.join(';') + '。不想回就安静待着。' : '不用回复。')
3162
- + CHAT_STYLE
3163
- } else {
3164
- const actions = []
3165
- if (hasActiveTool('qq_send_private_message')) actions.push(`想回就用 qq_send_private_message(user_id=${sender})`)
3166
- if (hasActiveTool('qq_send_face')) actions.push(`想回表情用 qq_send_face(user_id=${sender})`)
3167
- if (hasActiveTool('qq_send_poke')) actions.push(`想戳回去用 qq_send_poke(user_id=${sender})`)
3168
- prompt = `【戳一戳】${sender} 在私聊里戳了你一下。`
3169
- + (actions.length ? actions.join(';') + '。不想回就安静待着。' : '不用回复。')
3170
- + CHAT_STYLE
3171
- }
3172
- const sourceKind = groupId ? 'qq-poke' : 'qq-private'
3173
- await agent.followup({
3174
- id: randomUUID(),
3175
- role: 'user',
3176
- content: [{ type: 'text', text: prompt }],
3177
- source: { kind: sourceKind },
3178
- })
3179
- diag('戳一戳 → 会话:' + (groupId ? '群 ' + groupId : '私聊') + ' ' + sender)
3180
- }
3181
-
3182
- const privateSeen = new Set()
3183
-
3184
- async function handlePrivateMessage(ctx, ev, admitImages) {
3185
- const cfg = loadCfg()
3186
- if (!cfg.privateEnabled) return
3187
- const self = String(ev.self_id || selfId || '')
3188
- const userId = String(ev.user_id || (ev.sender && ev.sender.user_id) || '')
3189
- if (!userId) return
3190
- if (self && userId === self) return
3191
- if (!allowedPrivate(userId, cfg)) return
3192
- const key = String(ev.message_id != null ? ev.message_id : [ev.time, userId].join('|'))
3193
- if (privateSeen.has(key)) return
3194
- privateSeen.add(key)
3195
- if (privateSeen.size > 500) privateSeen.clear()
3196
- const agent = findAgent(ctx.agents, targetSessionId())
3197
- if (!agent) {
3198
- diag('私聊:目标会话没有活着')
3199
- return
3200
- }
3201
- if (!selfId) {
3202
- try {
3203
- const info = await onebot('get_login_info', {}, cfg)
3204
- selfId = String((info && info.user_id) || '')
3205
- } catch (error) {}
3206
- }
3207
- const name = clean((ev.sender && (ev.sender.card || ev.sender.nickname)) || userId)
3208
- const segs = Array.isArray(ev.message) ? ev.message : []
3209
- const text = segText(segs) || clean(ev.raw_message) || '(没有文字)'
3210
- const groupId = ev.group_id ? String(ev.group_id) : ''
3211
- const midHint = ev.message_id != null ? ',引用传 reply_to=' + ev.message_id : ''
3212
- const actions = []
3213
- if (hasActiveTool('qq_get_private_history')) {
3214
- actions.push('想看上下文用 qq_get_private_history(user_id=' + userId + ')')
3215
- }
3216
- if (hasActiveTool('qq_send_private_message')) {
3217
- actions.push('想回就用 qq_send_private_message(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + midHint + ')')
3218
- }
3219
- if (hasActiveTool('qq_send_face')) {
3220
- actions.push('想回表情用 qq_send_face(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + midHint + ')')
3221
- }
3222
- const where = groupId ? '群 ' + groupId + ' 的临时会话' : '私聊'
3223
- const prompt =
3224
- '【QQ ' + where + '】' + formatIncomingLine(name, userId, text, ev.message_id) +
3225
- '\n' + (actions.length ? actions.join(';') + '。' : '') +
3226
- '不想回就安静待着;不要提工具。' + CHAT_STYLE
3227
- const content = [{ type: 'text', text: prompt }]
3228
- if (typeof admitImages === 'function') {
3229
- // 和群/私聊记录一样:先保证最近两张动图能进来,剩下的名额给静图。
3230
- const anim = []
3231
- const stat = []
3232
- for (const seg of segs) {
3233
- if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
3234
- const item = { seg, animated: animatedSeg(seg) }
3235
- if (item.animated) anim.push(item)
3236
- else stat.push(item)
3237
- }
3238
- const animPick = anim.slice(-2)
3239
- const picked = [...animPick, ...stat.slice(-(4 - animPick.length))]
3240
- if (picked.length) {
3241
- const images = await admitImages(picked.map(({ seg, animated }, i) => ({
3242
- data: seg.data,
3243
- cfg,
3244
- animated,
3245
- name: (seg.data && seg.data.file) || `qq-private-${ev.message_id || 'x'}-${i}.jpg`,
3246
- caption: `私聊 ${name} 发的图片(消息 ${ev.message_id || '?'})`,
3247
- })))
3248
- for (const img of images) {
3249
- if (img && img.caption) content.push({ type: 'text', text: img.caption })
3250
- if (img && img.block) content.push(img.block)
3251
- }
3252
- }
3253
- }
3254
- await agent.followup({
3255
- id: randomUUID(),
3256
- role: 'user',
3257
- content,
3258
- source: { kind: 'qq-private' },
3259
- })
3260
- diag('私聊消息 → 会话:' + userId + ' ' + text.slice(0, 200))
3261
- }
3262
-
3263
- function startWsWatcher(ctx, tag, enabled, onEvent) {
3264
- let ws = null
3265
- let stopped = false
3266
- let timer = null
3267
-
3268
- function connectWs() {
3269
- if (stopped) return
3270
- const cfg = loadCfg()
3271
- if (!enabled(cfg)) return
3272
- const url = String(cfg.wsUrl || '')
3273
- if (!url) return
3274
- if (typeof WebSocket === 'undefined') {
3275
- diag(tag + ':WS 不可用,当前 Node 没有 WebSocket')
3276
- return
3277
- }
3278
- try {
3279
- let targetUrl = url
3280
- if (cfg.accessToken) {
3281
- const u = new URL(url)
3282
- u.searchParams.set('access_token', cfg.accessToken)
3283
- targetUrl = u.toString()
3284
- }
3285
- const socket = new WebSocket(targetUrl)
3286
- ws = socket
3287
- socket.onopen = () => diag(tag + ':WS 已连接')
3288
- socket.onmessage = (event) => {
3289
- try {
3290
- const ev = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data))
3291
- if (ev) {
3292
- void Promise.resolve(onEvent(ev, cfg)).catch((error) =>
3293
- diag(tag + ':处理事件失败 ' + String((error && error.message) || error)),
3294
- )
3295
- }
3296
- } catch (error) {}
3297
- }
3298
- socket.onclose = () => {
3299
- if (ws === socket) ws = null
3300
- if (!stopped) timer = setTimeout(connectWs, 5000)
3301
- }
3302
- socket.onerror = () => {
3303
- try { socket.close() } catch (error) {}
3304
- }
3305
- } catch (error) {
3306
- diag(tag + ':WS 连接失败 ' + String((error && error.message) || error))
3307
- if (!stopped) timer = setTimeout(connectWs, 5000)
3308
- }
3309
- }
3310
-
3311
- ctx.effect(() => {
3312
- connectWs()
3313
- return () => {
3314
- stopped = true
3315
- if (timer) clearTimeout(timer)
3316
- try { if (ws) ws.close() } catch (error) {}
3317
- }
3318
- })
3319
- }
3320
-
3321
- export function startPokeWatcher(ctx) {
3322
- startWsWatcher(ctx, '戳一戳', (cfg) => cfg.pokeEnabled, (ev) => {
3323
- if (ev.post_type === 'notice' && ev.notice_type === 'notify' && ev.sub_type === 'poke') {
3324
- return handlePoke(ctx, ev)
3325
- }
3326
- })
3327
- }
3328
-
3329
- export function startPrivateWatcher(ctx, admitImages) {
3330
- startWsWatcher(ctx, '私聊', (cfg) => cfg.privateEnabled, (ev) => {
3331
- if (ev.post_type === 'message' && ev.message_type === 'private') {
3332
- return handlePrivateMessage(ctx, ev, admitImages)
3333
- }
3334
- })
3335
- }
3336
-
3337
-
3338
- // ---------- 兼容:老的整体插件入口(一个插件挂全部工具) ----------
3339
- export function mountAll(ctx) {
3340
- const admitImages = admitImagesFor(ctx)
3341
- const list = mountOneTool(ctx, makeListTool())
3342
- const history = mountOneTool(ctx, makeHistoryTool(admitImages))
3343
- const members = mountOneTool(ctx, makeMembersTool())
3344
- const send = mountOneTool(ctx, makeSendTool(startObservation))
3345
- const face = mountOneTool(ctx, makeSendFaceTool(startObservation))
3346
- const faceList = mountOneTool(ctx, makeListFacesTool(admitImages))
3347
- const faceFav = mountOneTool(ctx, makeFavoriteFaceTool())
3348
- const poke = mountOneTool(ctx, makePokeTool(startObservation))
3349
- const avatar = mountOneTool(ctx, makeSetAvatarTool())
3350
- const friends = mountOneTool(ctx, makeListFriendsTool())
3351
- const privateHistory = mountOneTool(ctx, makePrivateHistoryTool(admitImages))
3352
- const privateSend = mountOneTool(ctx, makeSendPrivateTool())
3353
- const friendInfo = mountOneTool(ctx, makeGetFriendInfoTool())
3354
- const friendCategories = mountOneTool(ctx, makeListFriendCategoriesTool())
3355
- const friendUpdate = mountOneTool(ctx, makeUpdateFriendTool())
3356
- const friendDelete = mountOneTool(ctx, makeDeleteFriendTool())
3357
- const friendLike = mountOneTool(ctx, makeSendLikeTool())
3358
- const friendRequests = mountOneTool(ctx, makeListFriendRequestsTool())
3359
- const friendHandle = mountOneTool(ctx, makeHandleFriendRequestTool())
3360
- const qzoneList = mountOneTool(ctx, makeListQzoneTool(admitImages))
3361
- const qzoneDetail = mountOneTool(ctx, makeGetQzonePostTool(admitImages))
3362
- const qzoneSend = mountOneTool(ctx, makeSendQzonePostTool())
3363
- const qzoneManage = mountOneTool(ctx, makeManageQzoneTool())
3364
- startMentionPolling(ctx)
3365
- startPokeWatcher(ctx)
3366
- startPrivateWatcher(ctx, admitImages)
3367
- startFriendRequestWatcher(ctx)
3368
- return {
3369
- tools: [
3370
- list, history, members, send, face, faceList, faceFav, poke, avatar,
3371
- friends, privateHistory, privateSend,
3372
- friendInfo, friendCategories, friendUpdate, friendDelete, friendLike, friendRequests, friendHandle,
3373
- qzoneList, qzoneDetail, qzoneSend, qzoneManage,
3374
- ],
3375
- startObservation,
3376
- }
3377
- }
1
+ // dsh-qq-tools —— 共享层 barrel。
2
+ //
3
+ // 实现按域拆在 lib/ 下(config / utils / onebot / qq-state / render / prompts /
4
+ // images / session / observation / tools/* / watchers/*);这里只做 re-export,
5
+ // 保持 tools/*/index.js、lib/index.js 和 test/smoke.mjs 原来的 import 路径可用。
6
+ export * from './config.js'
7
+ export * from './utils.js'
8
+ export * from './onebot.js'
9
+ export * from './qq-state.js'
10
+ export * from './render.js'
11
+ export * from './prompts.js'
12
+ export * from './observation.js'
13
+ export * from './images.js'
14
+ export * from './session.js'
15
+ export * from './tools/groups.js'
16
+ export * from './tools/private.js'
17
+ export * from './tools/faces.js'
18
+ export * from './tools/avatar.js'
19
+ export * from './tools/poke.js'
20
+ export * from './tools/friends.js'
21
+ export * from './tools/qzone.js'
22
+ export * from './watchers/ws.js'
23
+ export * from './watchers/mentions.js'
24
+ export * from './watchers/poke.js'
25
+ export * from './watchers/private.js'
26
+ export * from './mount-all.js'