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.
- package/README.md +23 -11
- package/cordis.patch.yml +1 -1
- package/lib/config.js +96 -0
- package/lib/images.js +420 -0
- package/lib/index.js +2 -1
- package/lib/mount-all.js +59 -0
- package/lib/observation.js +24 -0
- package/lib/onebot.js +32 -0
- package/lib/prompts.js +56 -0
- package/lib/qq-state.js +51 -0
- package/lib/render.js +49 -0
- package/lib/session.js +201 -0
- package/lib/shared.js +26 -3377
- package/lib/tools/avatar.js +54 -0
- package/lib/tools/faces.js +296 -0
- package/lib/tools/friends.js +559 -0
- package/lib/tools/groups.js +220 -0
- package/lib/tools/poke.js +41 -0
- package/lib/tools/private.js +190 -0
- package/lib/tools/qzone.js +569 -0
- package/lib/utils.js +73 -0
- package/lib/watchers/mentions.js +167 -0
- package/lib/watchers/poke.js +87 -0
- package/lib/watchers/private.js +95 -0
- package/lib/watchers/ws.js +61 -0
- package/package.json +98 -103
- package/tools/qq-favorite-faces/index.js +11 -10
- package/tools/qq-favorite-faces/locale/en.json +1 -1
- package/tools/qq-favorite-faces/locale/zh.json +1 -1
- package/tools/qq-friend-management/index.js +28 -28
- package/tools/qq-get-group-history/index.js +10 -8
- package/tools/qq-get-group-members/index.js +9 -8
- package/tools/qq-list-groups/index.js +9 -8
- package/tools/qq-private-chat/index.js +16 -20
- package/tools/qq-qzone/index.js +22 -22
- package/tools/qq-send-face/index.js +11 -8
- package/tools/qq-send-face/locale/en.json +1 -1
- package/tools/qq-send-face/locale/zh.json +1 -1
- package/tools/qq-send-group-message/index.js +13 -10
- package/tools/qq-send-poke/index.js +13 -10
- package/tools/qq-set-avatar/index.js +9 -8
package/lib/shared.js
CHANGED
|
@@ -1,3377 +1,26 @@
|
|
|
1
|
-
// dsh-qq-tools ——
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(/ /gi, ' ')
|
|
2322
|
-
.replace(/&/gi, '&')
|
|
2323
|
-
.replace(/</gi, '<')
|
|
2324
|
-
.replace(/>/gi, '>')
|
|
2325
|
-
.replace(/"/gi, '"')
|
|
2326
|
-
.replace(/'/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'
|