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
|
@@ -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
|
+
}
|