dsh-qq-tools 0.9.5 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +23 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/config.js +96 -0
  4. package/lib/images.js +420 -0
  5. package/lib/index.js +2 -1
  6. package/lib/mount-all.js +59 -0
  7. package/lib/observation.js +24 -0
  8. package/lib/onebot.js +32 -0
  9. package/lib/prompts.js +56 -0
  10. package/lib/qq-state.js +51 -0
  11. package/lib/render.js +49 -0
  12. package/lib/session.js +201 -0
  13. package/lib/shared.js +26 -3377
  14. package/lib/tools/avatar.js +54 -0
  15. package/lib/tools/faces.js +296 -0
  16. package/lib/tools/friends.js +559 -0
  17. package/lib/tools/groups.js +220 -0
  18. package/lib/tools/poke.js +41 -0
  19. package/lib/tools/private.js +190 -0
  20. package/lib/tools/qzone.js +569 -0
  21. package/lib/utils.js +73 -0
  22. package/lib/watchers/mentions.js +167 -0
  23. package/lib/watchers/poke.js +87 -0
  24. package/lib/watchers/private.js +95 -0
  25. package/lib/watchers/ws.js +61 -0
  26. package/package.json +98 -103
  27. package/tools/qq-favorite-faces/index.js +11 -10
  28. package/tools/qq-favorite-faces/locale/en.json +1 -1
  29. package/tools/qq-favorite-faces/locale/zh.json +1 -1
  30. package/tools/qq-friend-management/index.js +28 -28
  31. package/tools/qq-get-group-history/index.js +10 -8
  32. package/tools/qq-get-group-members/index.js +9 -8
  33. package/tools/qq-list-groups/index.js +9 -8
  34. package/tools/qq-private-chat/index.js +16 -20
  35. package/tools/qq-qzone/index.js +22 -22
  36. package/tools/qq-send-face/index.js +11 -8
  37. package/tools/qq-send-face/locale/en.json +1 -1
  38. package/tools/qq-send-face/locale/zh.json +1 -1
  39. package/tools/qq-send-group-message/index.js +13 -10
  40. package/tools/qq-send-poke/index.js +13 -10
  41. package/tools/qq-set-avatar/index.js +9 -8
@@ -0,0 +1,559 @@
1
+ // 好友管理:资料/备注/分组/删好友/名片赞/好友申请收件箱。
2
+
3
+ import { loadCfg, allowedPrivate, diag } from '../config.js'
4
+ import { onebot } from '../onebot.js'
5
+ import { clean } from '../utils.js'
6
+ import { renderTool } from '../render.js'
7
+ import { startWsWatcher } from '../watchers/ws.js'
8
+
9
+ // ---------- 好友管理 ----------
10
+ // 读写好友资料、备注、分组、名片赞,以及好友申请。
11
+ // 普通好友申请只有 request 事件,SnowLuma 没有「列出历史好友申请」的 HTTP 接口,
12
+ // 所以这里一边接 WebSocket 事件进收件箱,一边用 get_doubt_friends_add_request
13
+ // 拿服务端保存的可疑申请;历史普通申请只能去 QQ 客户端看。
14
+ const FRIEND_REQUEST_LIMIT = 30
15
+ const friendRequestInbox = new Map()
16
+
17
+ function rememberFriendRequest(ev) {
18
+ const flag = clean(ev && ev.flag)
19
+ if (!flag) return
20
+ const userId = String((ev && ev.user_id) || '')
21
+ const time = Number(ev && ev.time) || Math.floor(Date.now() / 1000)
22
+ friendRequestInbox.set(flag, {
23
+ flag,
24
+ user_id: userId,
25
+ nickname: '',
26
+ comment: String((ev && ev.comment) || '').slice(0, 500),
27
+ time,
28
+ at: Date.now(),
29
+ })
30
+ if (friendRequestInbox.size > FRIEND_REQUEST_LIMIT) {
31
+ const oldest = [...friendRequestInbox.entries()].sort((a, b) => (a[1].at || 0) - (b[1].at || 0))[0]
32
+ if (oldest) friendRequestInbox.delete(oldest[0])
33
+ }
34
+ diag('好友申请 → 收件箱:' + userId + ' ' + clean(ev && ev.comment).slice(0, 120))
35
+ }
36
+
37
+ async function friendEntryOf(cfg, userId) {
38
+ try {
39
+ const groups = await onebot('get_friends_with_category', {}, cfg)
40
+ if (Array.isArray(groups) && groups.length) {
41
+ for (const g of groups) {
42
+ const list = Array.isArray(g && g.buddyList) ? g.buddyList : []
43
+ const hit = list.find((b) => String(b && b.user_id) === String(userId))
44
+ if (hit) {
45
+ const categoryId = Number(g.categoryId)
46
+ return {
47
+ entry: hit,
48
+ category: { id: Number.isFinite(categoryId) ? categoryId : 0, name: clean(g.categoryName) },
49
+ }
50
+ }
51
+ }
52
+ }
53
+ } catch (error) {}
54
+ // 分组接口不可用(或好友不在分组结果里)时退回普通好友列表:
55
+ // 至少能判断是不是好友、拿到备注。
56
+ try {
57
+ const data = await onebot('get_friend_list', {}, cfg)
58
+ const hit = (Array.isArray(data) ? data : []).find((f) => String(f && f.user_id) === String(userId))
59
+ if (hit) return { entry: hit, category: null }
60
+ } catch (error) {}
61
+ return null
62
+ }
63
+
64
+ // 接口不可用时返回 null(未知),不要谎报「不是单向好友」。
65
+ async function oneWayOf(cfg, userId) {
66
+ try {
67
+ const list = await onebot('get_unidirectional_friend_list', {}, cfg)
68
+ return (Array.isArray(list) ? list : []).some((x) => {
69
+ const id = x && typeof x === 'object'
70
+ ? String(x.user_id || x.uin || x.qq || '')
71
+ : String(x || '')
72
+ return !!id && id === String(userId)
73
+ })
74
+ } catch (error) {
75
+ return null
76
+ }
77
+ }
78
+
79
+ function sexText(value) {
80
+ const s = String(value == null ? '' : value).toLowerCase()
81
+ if (s === 'male' || s === '1' || s === '男') return '男'
82
+ if (s === 'female' || s === '2' || s === '女') return '女'
83
+ if (!s || s === '0' || s === 'unknown' || s === '未知') return '未知'
84
+ return clean(value)
85
+ }
86
+
87
+ export function makeGetFriendInfoTool() {
88
+ return {
89
+ name: 'qq_get_friend_info',
90
+ description:
91
+ '看某个 QQ 的资料和好友关系:昵称、备注、分组、性别年龄、等级、VIP、是否单向好友。' +
92
+ 'user_id 从 qq_list_friends、聊天记录或好友申请里拿。只读。',
93
+ parameters: {
94
+ type: 'object',
95
+ properties: {
96
+ user_id: { type: 'number', description: '对方 QQ 号' },
97
+ },
98
+ required: ['user_id'],
99
+ additionalProperties: false,
100
+ },
101
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
102
+ async execute(args) {
103
+ const cfg = loadCfg()
104
+ const userId = Number(args && args.user_id)
105
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
106
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
107
+ let info = null
108
+ let infoError = ''
109
+ try {
110
+ info = await onebot('get_stranger_info', { user_id: userId }, cfg)
111
+ } catch (error) {
112
+ infoError = String((error && error.message) || error)
113
+ }
114
+ const found = await friendEntryOf(cfg, userId)
115
+ if (!info && !found) {
116
+ return { ok: false, error: infoError || '查不到这个 QQ 的资料' }
117
+ }
118
+ const entry = (found && found.entry) || {}
119
+ const oneWay = await oneWayOf(cfg, userId)
120
+ const rawLevel = info
121
+ ? (info.qq_level != null ? info.qq_level : info.qqLevel != null ? info.qqLevel : info.level)
122
+ : 0
123
+ const friend = {
124
+ user_id: String(userId),
125
+ nickname: clean((info && info.nickname) || entry.nickname),
126
+ remark: clean(entry.remark || (info && info.remark)),
127
+ category: found ? found.category : null,
128
+ sex: sexText(info && info.sex),
129
+ age: Number((info && info.age) || 0),
130
+ qid: clean(info && info.qid),
131
+ level: Number(rawLevel || 0),
132
+ vip: !!(info && info.is_vip),
133
+ vip_level: Number((info && info.vip_level) || 0),
134
+ is_friend: !!found,
135
+ one_way: oneWay,
136
+ }
137
+ return { ok: true, friend }
138
+ },
139
+ render(_args, v) {
140
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友资料失败:' + String((v && v.error) || '未知错误') }]
141
+ const f = v.friend || {}
142
+ const lines = [
143
+ `QQ ${f.user_id}:`,
144
+ '- 昵称:' + (f.nickname || '未知'),
145
+ '- 备注:' + (f.remark || '(无)'),
146
+ '- 分组:' + (f.category ? `${f.category.name || '未命名'}(id ${f.category.id})` : (f.is_friend ? '未知(分组接口没返回)' : '不是好友')),
147
+ '- 性别年龄:' + (f.sex || '未知') + (f.age ? ',' + f.age + ' 岁' : ''),
148
+ '- 等级:' + (f.level || '未知'),
149
+ '- VIP:' + (f.vip ? '是' + (f.vip_level ? '(' + f.vip_level + ' 级)' : '') : '否'),
150
+ '- 单向好友:' + (f.one_way === true ? '是' : f.one_way === false ? '否' : '未知(接口不可用)'),
151
+ ]
152
+ if (f.qid) lines.splice(2, 0, '- QID:' + f.qid)
153
+ return [{ type: 'text', text: lines.join('\n') }]
154
+ },
155
+ }
156
+ }
157
+
158
+ export function makeListFriendCategoriesTool() {
159
+ return {
160
+ name: 'qq_list_friend_categories',
161
+ description:
162
+ '看好友分组和每个分组里的好友(SnowLuma get_friends_with_category)。' +
163
+ '移动好友分组时,category_id / category_name 从这里拿。' +
164
+ '分组接口漏掉的好友会补进「未分组(接口未返回)」里。只读。',
165
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
166
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
167
+ async execute() {
168
+ const cfg = loadCfg()
169
+ try {
170
+ let categories = []
171
+ let usedFriendListFallback = false
172
+ try {
173
+ const data = await onebot('get_friends_with_category', {}, cfg)
174
+ if (!Array.isArray(data)) throw new Error('好友分组接口返回的不是数组')
175
+ categories = data.map((g) => ({
176
+ id: Number.isFinite(Number(g && g.categoryId)) ? Number(g.categoryId) : 0,
177
+ name: clean(g && g.categoryName),
178
+ count: Number((g && g.categoryMbCount) || 0),
179
+ friends: (Array.isArray(g && g.buddyList) ? g.buddyList : []).map((b) => ({
180
+ user_id: String((b && b.user_id) || ''),
181
+ nickname: clean(b && b.nickname),
182
+ remark: clean(b && b.remark),
183
+ })),
184
+ }))
185
+ } catch (error) {
186
+ // 老版本 SnowLuma 没有分组接口时,退回普通好友列表,至少还能看到人。
187
+ const data = await onebot('get_friend_list', {}, cfg)
188
+ const friends = (Array.isArray(data) ? data : []).map((f) => ({
189
+ user_id: String((f && f.user_id) || ''),
190
+ nickname: clean(f && f.nickname),
191
+ remark: clean(f && f.remark),
192
+ }))
193
+ categories = [{ id: 0, name: '我的好友', count: friends.length, friends }]
194
+ usedFriendListFallback = true
195
+ }
196
+ // SnowLuma 的分组接口可能漏人(真机见过 86 个好友只返回 83 个),
197
+ // 用普通好友列表补齐,免得模型以为这些人不存在。
198
+ if (!usedFriendListFallback) {
199
+ try {
200
+ const data = await onebot('get_friend_list', {}, cfg)
201
+ const known = new Set()
202
+ for (const c of categories) for (const f of c.friends) known.add(f.user_id)
203
+ const missing = (Array.isArray(data) ? data : []).map((f) => ({
204
+ user_id: String((f && f.user_id) || ''),
205
+ nickname: clean(f && f.nickname),
206
+ remark: clean(f && f.remark),
207
+ })).filter((f) => f.user_id && !known.has(f.user_id))
208
+ if (missing.length) {
209
+ categories.push({ id: -1, name: '未分组(接口未返回)', count: missing.length, friends: missing })
210
+ }
211
+ } catch (error) {}
212
+ }
213
+ for (const c of categories) {
214
+ c.friends = c.friends.filter((f) => f.user_id && allowedPrivate(f.user_id, cfg))
215
+ c.count = c.friends.length
216
+ }
217
+ const total = categories.reduce((n, c) => n + c.friends.length, 0)
218
+ return { ok: true, total, categories }
219
+ } catch (error) {
220
+ return { ok: false, error: String((error && error.message) || error) }
221
+ }
222
+ },
223
+ render(_args, v) {
224
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友分组失败:' + String((v && v.error) || '未知错误') }]
225
+ if (!v.categories.length) return [{ type: 'text', text: '没有好友分组。' }]
226
+ const lines = [`好友分组(${v.categories.length} 个,共 ${v.total} 人):`]
227
+ for (const c of v.categories) {
228
+ const names = c.friends.slice(0, 20).map((f) => f.remark || f.nickname || f.user_id)
229
+ const idText = c.id >= 0 ? `id ${c.id},` : ''
230
+ lines.push(`- ${c.name || '未命名'}(${idText}${c.count} 人)${names.length ? ':' + names.join('、') : ''}${c.friends.length > names.length ? ' …' : ''}`)
231
+ }
232
+ return [{ type: 'text', text: lines.join('\n') }]
233
+ },
234
+ }
235
+ }
236
+
237
+ export function makeUpdateFriendTool() {
238
+ return {
239
+ name: 'qq_update_friend',
240
+ description:
241
+ '改好友备注或把好友移到别的分组。' +
242
+ 'remark 传空字符串表示清空备注;分组用 category_id 或 category_name(名称必须唯一且完全匹配),' +
243
+ '分组 id/名称从 qq_list_friend_categories 拿。两件事至少做一件。',
244
+ parameters: {
245
+ type: 'object',
246
+ properties: {
247
+ user_id: { type: 'number', description: '好友 QQ 号' },
248
+ remark: { type: 'string', description: '新备注;传空字符串清空;不传就不改备注' },
249
+ category_id: { type: 'number', description: '目标分组 ID' },
250
+ category_name: { type: 'string', description: '目标分组名称(完全匹配)' },
251
+ },
252
+ required: ['user_id'],
253
+ additionalProperties: false,
254
+ },
255
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
256
+ async execute(args) {
257
+ const cfg = loadCfg()
258
+ const userId = Number(args && args.user_id)
259
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
260
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
261
+ const hasRemark = args && args.remark != null
262
+ const remark = clean(args && args.remark)
263
+ const hasCategoryId = args && args.category_id != null && args.category_id !== ''
264
+ const categoryId = hasCategoryId ? Number(args.category_id) : null
265
+ const categoryName = clean(args && args.category_name)
266
+ if (!hasRemark && !hasCategoryId && !categoryName) {
267
+ return { ok: false, error: '要改什么?传 remark,或 category_id / category_name' }
268
+ }
269
+ if (hasCategoryId && !Number.isInteger(categoryId)) return { ok: false, error: 'category_id 不是有效整数' }
270
+ try {
271
+ const changes = []
272
+ if (hasRemark) {
273
+ await onebot('set_friend_remark', { user_id: userId, remark }, cfg)
274
+ changes.push(remark ? '备注改成「' + remark + '」' : '备注已清空')
275
+ }
276
+ if (hasCategoryId || categoryName) {
277
+ let targetId = categoryId
278
+ let targetName = categoryName
279
+ if (!hasCategoryId) {
280
+ try {
281
+ const data = await onebot('get_friends_with_category', {}, cfg)
282
+ const hits = (Array.isArray(data) ? data : []).filter((g) => clean(g && g.categoryName) === categoryName)
283
+ if (hits.length !== 1) {
284
+ return {
285
+ ok: false,
286
+ error: (hits.length
287
+ ? `有 ${hits.length} 个分组都叫「${categoryName}」,请改用 category_id`
288
+ : `没找到叫「${categoryName}」的分组,先用 qq_list_friend_categories 看`)
289
+ + (changes.length ? '(已' + changes.join(';') + ')' : ''),
290
+ changed: changes,
291
+ }
292
+ }
293
+ targetId = Number(hits[0].categoryId)
294
+ if (!Number.isInteger(targetId)) targetId = null
295
+ targetName = clean(hits[0].categoryName)
296
+ } catch (error) {
297
+ // 分组列表接口不可用时直接让 SnowLuma 按名称处理。
298
+ targetId = null
299
+ }
300
+ }
301
+ const body = { uin: userId }
302
+ if (targetId != null) body.categoryId = targetId
303
+ else if (targetName) body.categoryName = targetName
304
+ await onebot('set_friends_category', body, cfg)
305
+ changes.push('移到分组「' + (targetName || targetId) + '」')
306
+ }
307
+ return { ok: true, user_id: userId, remark: hasRemark ? remark : undefined, changes }
308
+ } catch (error) {
309
+ return { ok: false, error: String((error && error.message) || error) }
310
+ }
311
+ },
312
+ render(_args, v) {
313
+ if (!v || !v.ok) return [{ type: 'text', text: '改好友资料失败:' + String((v && v.error) || '未知错误') }]
314
+ return [{ type: 'text', text: `QQ ${v.user_id} 已更新:${(v.changes || []).join(';')}。` }]
315
+ },
316
+ }
317
+ }
318
+
319
+ export function makeDeleteFriendTool() {
320
+ return {
321
+ name: 'qq_delete_friend',
322
+ description:
323
+ '删除一个好友。不可撤销:只有用户明确要求删好友时才用,并且必须传 confirm=true。' +
324
+ 'block=true 会在删除的同时屏蔽对方。',
325
+ parameters: {
326
+ type: 'object',
327
+ properties: {
328
+ user_id: { type: 'number', description: '好友 QQ 号' },
329
+ block: { type: 'boolean', description: 'true = 删除并屏蔽,默认 false' },
330
+ confirm: { type: 'boolean', description: '必须显式传 true,防止误删' },
331
+ },
332
+ required: ['user_id', 'confirm'],
333
+ additionalProperties: false,
334
+ },
335
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
336
+ async execute(args) {
337
+ const cfg = loadCfg()
338
+ const userId = Number(args && args.user_id)
339
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
340
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
341
+ if (!args || args.confirm !== true) return { ok: false, error: '删好友不可撤销,需要 confirm=true 才会执行' }
342
+ try {
343
+ const block = args.block === true
344
+ await onebot('delete_friend', { user_id: userId, block }, cfg)
345
+ return { ok: true, removed: true, user_id: userId, blocked: block }
346
+ } catch (error) {
347
+ return { ok: false, error: String((error && error.message) || error) }
348
+ }
349
+ },
350
+ render(_args, v) {
351
+ if (!v || !v.ok) return [{ type: 'text', text: '删好友失败:' + String((v && v.error) || '未知错误') }]
352
+ return [{ type: 'text', text: `已删除好友 QQ ${v.user_id}${v.blocked ? ',并已屏蔽' : ''}。` }]
353
+ },
354
+ }
355
+ }
356
+
357
+ export function makeSendLikeTool() {
358
+ return {
359
+ name: 'qq_send_like',
360
+ description:
361
+ '给好友的名片点赞(QQ 资料卡赞)。times 是次数,默认 1,最多 20。别频繁调用。',
362
+ parameters: {
363
+ type: 'object',
364
+ properties: {
365
+ user_id: { type: 'number', description: '好友 QQ 号' },
366
+ times: { type: 'number', description: '点赞次数,默认 1,最多 20' },
367
+ },
368
+ required: ['user_id'],
369
+ additionalProperties: false,
370
+ },
371
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
372
+ async execute(args) {
373
+ const cfg = loadCfg()
374
+ const userId = Number(args && args.user_id)
375
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
376
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
377
+ const times = Math.max(1, Math.min(Math.floor(Number(args && args.times) || 1), 20))
378
+ try {
379
+ await onebot('send_like', { user_id: userId, times }, cfg)
380
+ return { ok: true, user_id: userId, times }
381
+ } catch (error) {
382
+ return { ok: false, error: String((error && error.message) || error) }
383
+ }
384
+ },
385
+ render(_args, v) {
386
+ if (!v || !v.ok) return [{ type: 'text', text: '点赞失败:' + String((v && v.error) || '未知错误') }]
387
+ return [{ type: 'text', text: `已给 QQ ${v.user_id} 点了 ${v.times} 个赞。` }]
388
+ },
389
+ }
390
+ }
391
+
392
+ export function makeListFriendRequestsTool() {
393
+ return {
394
+ name: 'qq_list_friend_requests',
395
+ description:
396
+ '看待处理的好友申请:插件运行期间通过 WebSocket 收到的普通申请 + QQ 账号里保存的可疑好友申请。' +
397
+ '每项带 flag,用 qq_handle_friend_request 同意/拒绝。' +
398
+ '普通申请只在插件启动后收到才在这里,历史申请要去 QQ 客户端看。只读。',
399
+ parameters: {
400
+ type: 'object',
401
+ properties: {
402
+ include_doubt: { type: 'boolean', description: '要不要一起查可疑好友申请,默认 true' },
403
+ },
404
+ additionalProperties: false,
405
+ },
406
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
407
+ async execute(args) {
408
+ const cfg = loadCfg()
409
+ const includeDoubt = !(args && args.include_doubt === false)
410
+ const requests = [...friendRequestInbox.values()]
411
+ .sort((a, b) => (b.time || 0) - (a.time || 0) || (b.at || 0) - (a.at || 0))
412
+ .slice(0, FRIEND_REQUEST_LIMIT)
413
+ if (requests.length) {
414
+ await Promise.all(requests.slice(0, 5).map(async (r) => {
415
+ if (r.nickname || !r.user_id) return
416
+ try {
417
+ const info = await onebot('get_stranger_info', { user_id: Number(r.user_id) }, cfg)
418
+ r.nickname = clean(info && info.nickname)
419
+ } catch (error) {}
420
+ }))
421
+ }
422
+ let doubt = []
423
+ let doubtError = ''
424
+ if (includeDoubt) {
425
+ try {
426
+ const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
427
+ doubt = (Array.isArray(data) ? data : []).map((x) => ({
428
+ kind: 'doubt',
429
+ flag: String((x && x.uid) || ''),
430
+ user_id: String((x && x.user_id) || ''),
431
+ nickname: clean(x && x.nick),
432
+ source: clean(x && x.source),
433
+ reason: clean(x && x.reason),
434
+ comment: String((x && x.msg) || '').slice(0, 500),
435
+ group_code: String((x && x.group_code) || ''),
436
+ time: Number((x && x.reqTime) || 0),
437
+ })).filter((x) => x.flag)
438
+ } catch (error) {
439
+ doubtError = String((error && error.message) || error)
440
+ }
441
+ }
442
+ return {
443
+ ok: true,
444
+ requests: requests.map(({ at, ...r }) => ({ kind: 'friend', ...r })),
445
+ doubt,
446
+ doubt_error: doubtError,
447
+ inbox_note: '普通申请从插件启动后开始收,历史申请不在收件箱里',
448
+ }
449
+ },
450
+ render(_args, v) {
451
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友申请失败:' + String((v && v.error) || '未知错误') }]
452
+ const lines = []
453
+ if (v.requests.length) {
454
+ lines.push(`普通好友申请(${v.requests.length} 个):`)
455
+ for (const r of v.requests) {
456
+ const who = r.nickname ? `${r.nickname}(${r.user_id})` : `QQ ${r.user_id}`
457
+ lines.push(`- ${who}${r.comment ? ':' + r.comment : ''} flag=${r.flag}`)
458
+ }
459
+ }
460
+ if (v.doubt.length) {
461
+ lines.push(`可疑好友申请(${v.doubt.length} 个):`)
462
+ for (const d of v.doubt) {
463
+ const who = d.nickname ? `${d.nickname}(${d.user_id})` : `QQ ${d.user_id}`
464
+ const extra = [d.source && '来源 ' + d.source, d.reason && '原因 ' + d.reason].filter(Boolean).join(',')
465
+ lines.push(`- ${who}${extra ? '(' + extra + ')' : ''}${d.comment ? ':' + d.comment : ''} flag=${d.flag}`)
466
+ }
467
+ }
468
+ if (!v.requests.length && !v.doubt.length) {
469
+ lines.push(v.doubt_error
470
+ ? '现在没有普通好友申请;可疑申请接口不可用:' + v.doubt_error.slice(0, 200)
471
+ : '现在没有待处理的好友申请。')
472
+ } else if (v.doubt_error) {
473
+ lines.push('可疑申请接口不可用:' + v.doubt_error.slice(0, 200))
474
+ }
475
+ if (v.requests.length || v.doubt.length) {
476
+ lines.push('用 qq_handle_friend_request 处理:传 flag、kind(friend/doubt)、approve=true/false。')
477
+ }
478
+ return [{ type: 'text', text: lines.join('\n') }]
479
+ },
480
+ }
481
+ }
482
+
483
+ export function makeHandleFriendRequestTool() {
484
+ return {
485
+ name: 'qq_handle_friend_request',
486
+ description:
487
+ '同意或拒绝好友申请。flag 从 qq_list_friend_requests 拿,也可以传 user_id 自动在待处理列表里找。' +
488
+ 'kind=friend 是普通申请,kind=doubt 是可疑申请。approve 必须明确传 true(同意)或 false(拒绝)。',
489
+ parameters: {
490
+ type: 'object',
491
+ properties: {
492
+ flag: { type: 'string', description: '申请的处理标识,来自 qq_list_friend_requests' },
493
+ user_id: { type: 'number', description: '申请人 QQ 号;不传 flag 时用它查收件箱' },
494
+ kind: { type: 'string', enum: ['friend', 'doubt'], description: '申请类型,默认 friend' },
495
+ approve: { type: 'boolean', description: 'true = 同意,false = 拒绝' },
496
+ },
497
+ required: ['approve'],
498
+ additionalProperties: false,
499
+ },
500
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
501
+ async execute(args) {
502
+ const cfg = loadCfg()
503
+ const kind = args && args.kind === 'doubt' ? 'doubt' : 'friend'
504
+ const userId = args && args.user_id != null ? Number(args.user_id) : 0
505
+ if (args && args.user_id != null && !Number.isFinite(userId)) {
506
+ return { ok: false, error: 'user_id 不是有效 QQ 号' }
507
+ }
508
+ if (!args || typeof args.approve !== 'boolean') return { ok: false, error: 'approve 必须明确传 true 或 false' }
509
+ const approve = args.approve
510
+ let flag = clean(args && args.flag)
511
+ let nickname = ''
512
+ if (!flag && userId) {
513
+ if (kind === 'friend') {
514
+ const hit = [...friendRequestInbox.values()]
515
+ .filter((r) => String(r.user_id) === String(userId))
516
+ .sort((a, b) => (b.at || 0) - (a.at || 0))[0]
517
+ if (hit) {
518
+ flag = hit.flag
519
+ nickname = hit.nickname
520
+ }
521
+ } else {
522
+ try {
523
+ const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
524
+ const hit = (Array.isArray(data) ? data : []).find((x) => String((x && x.user_id) || '') === String(userId))
525
+ if (hit) {
526
+ flag = String((hit && hit.uid) || '')
527
+ nickname = clean(hit && hit.nick)
528
+ }
529
+ } catch (error) {}
530
+ }
531
+ }
532
+ if (!flag) {
533
+ return { ok: false, error: '没有找到要处理的申请;先用 qq_list_friend_requests 拿 flag' }
534
+ }
535
+ try {
536
+ if (kind === 'doubt') {
537
+ await onebot('set_doubt_friends_add_request', { flag, approve }, cfg)
538
+ } else {
539
+ await onebot('set_friend_add_request', { flag, approve }, cfg)
540
+ friendRequestInbox.delete(flag)
541
+ }
542
+ return { ok: true, kind, approved: approve, flag, user_id: userId || null, nickname }
543
+ } catch (error) {
544
+ return { ok: false, error: String((error && error.message) || error) }
545
+ }
546
+ },
547
+ render(_args, v) {
548
+ if (!v || !v.ok) return [{ type: 'text', text: '处理好友申请失败:' + String((v && v.error) || '未知错误') }]
549
+ const who = v.nickname ? `${v.nickname}(${v.user_id || ''})` : (v.user_id ? 'QQ ' + v.user_id : '这条申请')
550
+ return [{ type: 'text', text: `已${v.approved ? '同意' : '拒绝'}${who}的好友申请。` }]
551
+ },
552
+ }
553
+ }
554
+
555
+ export function startFriendRequestWatcher(ctx) {
556
+ startWsWatcher(ctx, '好友申请', () => true, (ev) => {
557
+ if (ev && ev.post_type === 'request' && ev.request_type === 'friend') rememberFriendRequest(ev)
558
+ })
559
+ }