dsh-qq-tools 0.10.1 → 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.
@@ -0,0 +1,569 @@
1
+ // QQ 空间工具:说说列表、详情评论、发表、管理。
2
+
3
+ import { loadCfg, allowedPrivate } from '../config.js'
4
+ import { onebot } from '../onebot.js'
5
+ import { clean, cleanMultiline, fmtTime } from '../utils.js'
6
+ import { listOf, renderTool } from '../render.js'
7
+ import { imageSourceOf, imageSourcesFromMessage } from '../images.js'
8
+
9
+ // ---------- QQ 空间(说说) ----------
10
+ // SnowLuma 的「空间」扩展:send_qzone_msg / get_qzone_msg_list / get_qzone_feeds /
11
+ // get_qzone_msg_detail / like_qzone / unlike_qzone / comment_qzone /
12
+ // delete_qzone_msg / set_qzone_msg_right / set_qzone_ban。
13
+ // 图片统一走 imageSourceOf:http(s)、base64://、file://(本机路径会转成 file://)。
14
+ const QZONE_VISIBILITY = { public: 1, friends: 4, partial: 16, private: 64, exclude: 128 }
15
+ const QZONE_VISIBILITY_NAMES = {
16
+ 1: '所有人可见',
17
+ 4: '好友可见',
18
+ 16: '部分好友可见',
19
+ 64: '仅自己可见',
20
+ 128: '部分好友不可见',
21
+ }
22
+
23
+ function qzoneRightOf(value) {
24
+ const key = String(value == null ? '' : value).trim()
25
+ if (QZONE_VISIBILITY[key] != null) return QZONE_VISIBILITY[key]
26
+ const n = Number(key)
27
+ if (Number.isInteger(n) && QZONE_VISIBILITY_NAMES[n]) return n
28
+ return 0
29
+ }
30
+
31
+ function plainText(s) {
32
+ return clean(String(s || '')
33
+ .replace(/<br\s*\/?>/gi, ' ')
34
+ .replace(/<[^>]*>/g, ' ')
35
+ .replace(/&nbsp;/gi, ' ')
36
+ .replace(/&amp;/gi, '&')
37
+ .replace(/&lt;/gi, '<')
38
+ .replace(/&gt;/gi, '>')
39
+ .replace(/&quot;/gi, '"')
40
+ .replace(/&#39;/gi, "'"))
41
+ }
42
+
43
+ function qzoneImagesOf(list) {
44
+ return (Array.isArray(list) ? list : [])
45
+ .map((x) => {
46
+ const url = String(x || '').trim()
47
+ return url.startsWith('//') ? 'https:' + url : url
48
+ })
49
+ .filter((x) => /^https?:/i.test(x))
50
+ }
51
+
52
+ async function admitQzoneImages(admitImages, cfg, items) {
53
+ if (typeof admitImages !== 'function') return []
54
+ const picked = []
55
+ for (const item of Array.isArray(items) ? items : []) {
56
+ if (picked.length >= 4) break
57
+ if (!item || !/^https?:/i.test(String(item.url || ''))) continue
58
+ picked.push(item)
59
+ }
60
+ if (!picked.length) return []
61
+ return admitImages(picked.map((item) => ({
62
+ data: { url: item.url },
63
+ cfg,
64
+ name: item.name || 'qq-qzone.jpg',
65
+ caption: item.caption || 'QQ 空间图片',
66
+ })))
67
+ }
68
+
69
+ function qzonePostOf(x, max = 500) {
70
+ const time = Number((x && x.time) || 0)
71
+ return {
72
+ tid: String((x && x.tid) || ''),
73
+ content: clean((x && x.content) || plainText(x && x.html)).slice(0, max),
74
+ time,
75
+ t: fmtTime(time),
76
+ comment_num: Number((x && (x.comment_num != null ? x.comment_num : x.comment_count)) || 0),
77
+ is_private: !!(x && x.is_private),
78
+ images: qzoneImagesOf(x && x.images),
79
+ }
80
+ }
81
+
82
+ // 部分 SnowLuma 版本只会把 feed 的 HTML 透传回来,tid/content 仍是 null。
83
+ // 说说(appid=311)的 tid 在 HTML 的 tid="..." / data-tid="..." 里,
84
+ // 取出来就能继续用详情、点赞和评论;补不回来的 feed 保持只读。
85
+ function qzoneTidOf(x) {
86
+ const direct = x && x.tid != null ? String(x.tid).trim() : ''
87
+ if (direct) return direct
88
+ if (Number(x && x.appid) !== 311) return ''
89
+ const html = String((x && x.html) || '')
90
+ const hit = html.match(/(?:data-)?tid[="'\s:]+([0-9a-fA-F]{16,64})/)
91
+ return hit ? hit[1] : ''
92
+ }
93
+
94
+ export function makeListQzoneTool(admitImages) {
95
+ return {
96
+ name: 'qq_list_qzone',
97
+ description:
98
+ '看 QQ 空间说说。默认看自己的说说;传 target_uin 看别人的;feeds=true 看好友动态(可用 cursor 翻页)。' +
99
+ '好友动态里只保留说说(appid=311);如果 SnowLuma 没从 HTML 里解析出 tid,会尽量自己补回来,补不回来的只能看不能互动。' +
100
+ '返回 tid、正文、时间、评论数和图片;tid 可以传给 qq_get_qzone_post / qq_manage_qzone。只读。',
101
+ parameters: {
102
+ type: 'object',
103
+ properties: {
104
+ feeds: { type: 'boolean', description: 'true = 看好友动态(get_qzone_feeds),默认 false' },
105
+ target_uin: { type: 'number', description: '看谁的说说;不传就是自己(feeds 模式忽略)' },
106
+ pos: { type: 'number', description: '从第几条开始,默认 0(feeds 模式忽略)' },
107
+ count: { type: 'number', description: '本页数量,默认 10,最多 50' },
108
+ cursor: { type: 'string', description: '好友动态翻页游标,传上一页返回的 next_cursor' },
109
+ include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
110
+ },
111
+ additionalProperties: false,
112
+ },
113
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
114
+ async execute(args) {
115
+ const cfg = loadCfg()
116
+ const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 10), 50))
117
+ const withImages = !(args && args.include_images === false)
118
+ try {
119
+ if (args && args.feeds === true) {
120
+ const body = { count }
121
+ const cursor = String((args && args.cursor) || '').trim()
122
+ if (cursor) body.cursor = cursor
123
+ const data = await onebot('get_qzone_feeds', body, cfg)
124
+ const rawFeeds = Array.isArray(data && data.feeds) ? data.feeds : []
125
+ const feeds = rawFeeds.map((x) => {
126
+ const post = qzonePostOf(x)
127
+ const tid = qzoneTidOf(x)
128
+ return {
129
+ ...post,
130
+ tid,
131
+ actionable: !!tid,
132
+ appid: Number((x && x.appid) || 0),
133
+ uin: String((x && x.uin) || ''),
134
+ name: clean(x && x.nickname),
135
+ like_count: Number((x && x.like_count) || 0),
136
+ content_complete: !!(x && x.content_complete),
137
+ }
138
+ }).filter((x) => x.appid === 311 || x.tid)
139
+ const imageItems = []
140
+ for (const f of feeds) {
141
+ for (const url of f.images) {
142
+ imageItems.push({
143
+ url,
144
+ name: `qq-qzone-${f.tid}.jpg`,
145
+ caption: `好友动态 ${f.tid} 的图片(${f.name || f.uin || '好友'})`,
146
+ })
147
+ }
148
+ }
149
+ const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
150
+ return {
151
+ ok: true,
152
+ mode: 'feeds',
153
+ count: feeds.length,
154
+ feeds,
155
+ skipped: rawFeeds.length - feeds.length,
156
+ has_more: !!(data && data.has_more),
157
+ next_cursor: String((data && data.next_cursor) || ''),
158
+ images,
159
+ }
160
+ }
161
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
162
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
163
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
164
+ }
165
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
166
+ const body = {
167
+ pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)),
168
+ num: count,
169
+ }
170
+ if (targetUin) body.target_uin = targetUin
171
+ const data = await onebot('get_qzone_msg_list', body, cfg)
172
+ const raw = listOf(data, 'msglist')
173
+ const posts = raw.map((x) => qzonePostOf(x)).filter((x) => x.tid)
174
+ const imageItems = []
175
+ for (const p of posts) {
176
+ for (const url of p.images) {
177
+ imageItems.push({
178
+ url,
179
+ name: `qq-qzone-${p.tid}.jpg`,
180
+ caption: `说说 ${p.tid} 的图片${p.content ? '(' + p.content.slice(0, 30) + ')' : ''}`,
181
+ })
182
+ }
183
+ }
184
+ const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
185
+ return {
186
+ ok: true,
187
+ mode: 'list',
188
+ target_uin: targetUin || null,
189
+ total: Number((data && data.total) || posts.length),
190
+ count: posts.length,
191
+ posts,
192
+ images,
193
+ }
194
+ } catch (error) {
195
+ return { ok: false, error: String((error && error.message) || error) }
196
+ }
197
+ },
198
+ render(_args, v) {
199
+ if (!v || !v.ok) return [{ type: 'text', text: '看 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
200
+ const blocks = []
201
+ if (v.mode === 'feeds') {
202
+ if (!v.feeds.length) {
203
+ return [{ type: 'text', text: '好友动态里现在没有能识别的说说。' + (v.skipped ? `(跳过 ${v.skipped} 条非说说动态)` : '') }]
204
+ }
205
+ const lines = [`好友动态(${v.feeds.length} 条${v.has_more ? ',还有更多' : ''}):`]
206
+ for (const f of v.feeds) {
207
+ const tag = f.tid ? `[tid=${f.tid}]` : '[没有 tid,只能看]'
208
+ lines.push(`- ${f.name || f.uin || '好友'}(${f.uin || '?'}) ${tag} ${f.t} 赞 ${f.like_count} 评论 ${f.comment_num}`)
209
+ if (f.content) lines.push(' ' + f.content.split('\n').join('\n '))
210
+ }
211
+ if (v.next_cursor) lines.push(`翻下一页:feeds=true,cursor=${v.next_cursor}`)
212
+ if (v.skipped) lines.push(`另有 ${v.skipped} 条非说说动态(相册、广告等)没有列出。`)
213
+ if (v.feeds.some((f) => !f.tid)) lines.push('没有 tid 的动态不能直接点赞/评论,可以配合 qq_get_qzone_post 或去 QQ 客户端看。')
214
+ blocks.push({ type: 'text', text: lines.join('\n') })
215
+ } else {
216
+ if (!v.posts.length) return [{ type: 'text', text: '没有看到说说。' }]
217
+ const where = v.target_uin ? `QQ ${v.target_uin} 的空间` : '你的空间'
218
+ const lines = [`${where}(共 ${v.total} 条,本页 ${v.count} 条):`]
219
+ for (const p of v.posts) {
220
+ lines.push(`- [tid=${p.tid}] ${p.t} 评论 ${p.comment_num}${p.is_private ? '(仅自己可见)' : ''}`)
221
+ if (p.content) lines.push(' ' + p.content.split('\n').join('\n '))
222
+ }
223
+ lines.push('看评论详情用 qq_get_qzone_post;发新说说用 qq_send_qzone_post。')
224
+ blocks.push({ type: 'text', text: lines.join('\n') })
225
+ }
226
+ for (const img of Array.isArray(v.images) ? v.images : []) {
227
+ if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
228
+ if (img && img.block) blocks.push(img.block)
229
+ }
230
+ return blocks
231
+ },
232
+ }
233
+ }
234
+
235
+ export function makeGetQzonePostTool(admitImages) {
236
+ return {
237
+ name: 'qq_get_qzone_post',
238
+ description:
239
+ '看一条说说的详情和评论(SnowLuma get_qzone_msg_detail)。tid 从 qq_list_qzone 拿。' +
240
+ '返回评论的 comment_id 和 uin;回复时传给 qq_manage_qzone:' +
241
+ '回复一级评论传 comment_id + comment_uin,回复楼中楼再传 reply_uin + reply_name。只读。',
242
+ parameters: {
243
+ type: 'object',
244
+ properties: {
245
+ tid: { type: 'string', description: '说说 tid' },
246
+ target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
247
+ pos: { type: 'number', description: '从第几条评论开始,默认 0' },
248
+ count: { type: 'number', description: '看多少条一级评论,默认 20,最多 50' },
249
+ include_images: { type: 'boolean', description: '详情里带图片时要不要看图,默认 true' },
250
+ },
251
+ required: ['tid'],
252
+ additionalProperties: false,
253
+ },
254
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
255
+ async execute(args) {
256
+ const cfg = loadCfg()
257
+ const tid = args && args.tid != null ? String(args.tid).trim() : ''
258
+ if (!tid) return { ok: false, error: 'tid 不能为空' }
259
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
260
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
261
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
262
+ }
263
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
264
+ const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 20), 50))
265
+ try {
266
+ const body = { tid, pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)), num: count }
267
+ if (targetUin) body.target_uin = targetUin
268
+ const data = await onebot('get_qzone_msg_detail', body, cfg)
269
+ const comments = (Array.isArray(data && data.comments) ? data.comments : []).map((c) => ({
270
+ uin: String((c && (c.uin || c.user_id)) || ''),
271
+ name: clean(c && (c.name || c.nickname)),
272
+ time: Number((c && c.time) || 0),
273
+ t: fmtTime(c && c.time),
274
+ content: cleanMultiline(c && c.content, 1000),
275
+ comment_id: c && c.comment_id != null ? String(c.comment_id) : '',
276
+ replies: (Array.isArray(c && c.replies) ? c.replies : []).map((r) => ({
277
+ uin: String((r && (r.uin || r.user_id)) || ''),
278
+ name: clean(r && (r.name || r.nickname)),
279
+ time: Number((r && r.time) || 0),
280
+ t: fmtTime(r && r.time),
281
+ content: cleanMultiline(r && r.content, 1000),
282
+ comment_id: r && r.comment_id != null ? String(r.comment_id) : '',
283
+ parent_comment_id: r && r.parent_comment_id != null ? String(r.parent_comment_id) : '',
284
+ parent_comment_uin: r && r.parent_comment_uin != null ? String(r.parent_comment_uin) : '',
285
+ })),
286
+ }))
287
+ const post = {
288
+ tid: String((data && data.tid) || tid),
289
+ content: cleanMultiline(data && data.content, 4000),
290
+ comment_num: Number((data && data.comment_num) || 0),
291
+ comments,
292
+ images: qzoneImagesOf(data && data.images),
293
+ }
294
+ let images = []
295
+ if (!(args && args.include_images === false) && post.images.length) {
296
+ images = await admitQzoneImages(admitImages, cfg, post.images.map((url) => ({
297
+ url,
298
+ name: `qq-qzone-${post.tid}.jpg`,
299
+ caption: `说说 ${post.tid} 的图片`,
300
+ })))
301
+ }
302
+ return { ok: true, post, images }
303
+ } catch (error) {
304
+ return { ok: false, error: String((error && error.message) || error) }
305
+ }
306
+ },
307
+ render(_args, v) {
308
+ if (!v || !v.ok) return [{ type: 'text', text: '看说说详情失败:' + String((v && v.error) || '未知错误') }]
309
+ const p = v.post || {}
310
+ const lines = [`说说 ${p.tid}:`, p.content || '(没有正文)']
311
+ lines.push(`评论 ${p.comment_num || 0} 条:`)
312
+ if (!p.comments.length) lines.push('(还没有评论)')
313
+ for (const c of p.comments) {
314
+ lines.push(`- ${c.name || c.uin}(${c.uin}) [comment_id=${c.comment_id}] ${c.t}:${c.content || '(空)'}`)
315
+ for (const r of c.replies) {
316
+ lines.push(` ↳ ${r.name || r.uin}(${r.uin}) [comment_id=${r.comment_id} parent=${r.parent_comment_id}] ${r.t}:${r.content || '(空)'}`)
317
+ }
318
+ }
319
+ if (p.comments.length) {
320
+ lines.push('回复一级评论:qq_manage_qzone action=comment,传 comment_id + comment_uin=该评论 uin;' +
321
+ '回复楼中楼再加 reply_uin + reply_name,comment_id 仍用一级评论的 id(不要用二级回复自己的 id)。')
322
+ }
323
+ const blocks = [{ type: 'text', text: lines.join('\n') }]
324
+ for (const img of Array.isArray(v.images) ? v.images : []) {
325
+ if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
326
+ if (img && img.block) blocks.push(img.block)
327
+ }
328
+ return blocks
329
+ },
330
+ }
331
+ }
332
+
333
+ export function makeSendQzonePostTool() {
334
+ return {
335
+ name: 'qq_send_qzone_post',
336
+ description:
337
+ '发一条 QQ 空间说说(SnowLuma send_qzone_msg)。content 是正文;' +
338
+ 'images 是图片(http(s)、base64://、本机路径,最多 9 张),也可以用 message_id 从消息里取图。' +
339
+ 'visibility:public=所有人,friends=好友,partial=指定好友可见,private=仅自己,exclude=指定好友不可见;' +
340
+ 'partial/exclude 要传 target_uins。发说说对外可见,只有用户明确要求时才用。',
341
+ parameters: {
342
+ type: 'object',
343
+ properties: {
344
+ content: { type: 'string', description: '说说正文,不要 Markdown' },
345
+ images: { type: 'array', items: { type: 'string' }, description: '图片列表,最多 9 张' },
346
+ message_id: { type: 'string', description: '从这条消息里取图片一起发' },
347
+ image_indexes: { type: 'array', items: { type: 'number' }, description: 'message_id 里要哪几张图片(从 0 开始);不传就全取' },
348
+ visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: '可见范围,默认 public' },
349
+ target_uins: { type: 'array', items: { type: 'number' }, description: 'partial/exclude 时的 QQ 号列表' },
350
+ },
351
+ required: ['content'],
352
+ additionalProperties: false,
353
+ },
354
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
355
+ async execute(args) {
356
+ const cfg = loadCfg()
357
+ const content = cleanMultiline(args && args.content, 2000)
358
+ if (!content) return { ok: false, error: '说说正文是空的' }
359
+ const visibilityKey = String((args && args.visibility) || 'public')
360
+ const ugcRight = qzoneRightOf(visibilityKey)
361
+ if (!ugcRight) return { ok: false, error: 'visibility 只能是 public/friends/partial/private/exclude' }
362
+ const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
363
+ .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
364
+ if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
365
+ return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
366
+ }
367
+ try {
368
+ const images = (Array.isArray(args && args.images) ? args.images : [])
369
+ .map(imageSourceOf).filter(Boolean)
370
+ const messageId = args && args.message_id
371
+ if (messageId != null && messageId !== '') {
372
+ if (images.length >= 9) return { ok: false, error: '最多 9 张图片' }
373
+ const got = await imageSourcesFromMessage(messageId, args.image_indexes, cfg, 9 - images.length)
374
+ if (!got.sources.length) {
375
+ return {
376
+ ok: false,
377
+ error: got.total
378
+ ? `选中的图片序号没有匹配到(这条消息共 ${got.total} 张)`
379
+ : '这条消息里没有图片',
380
+ }
381
+ }
382
+ images.push(...got.sources)
383
+ }
384
+ if (images.length > 9) return { ok: false, error: '最多 9 张图片' }
385
+ const body = { content, ugc_right: ugcRight }
386
+ if (images.length) body.images = images
387
+ if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
388
+ const data = await onebot('send_qzone_msg', body, cfg)
389
+ return {
390
+ ok: true,
391
+ tid: String((data && data.tid) || ''),
392
+ time: Number((data && data.time) || 0),
393
+ visibility: QZONE_VISIBILITY_NAMES[ugcRight],
394
+ image_count: images.length,
395
+ }
396
+ } catch (error) {
397
+ return { ok: false, error: String((error && error.message) || error) }
398
+ }
399
+ },
400
+ render(_args, v) {
401
+ if (!v || !v.ok) return [{ type: 'text', text: '发说说失败:' + String((v && v.error) || '未知错误') }]
402
+ const extra = v.image_count ? `,${v.image_count} 张图` : ''
403
+ return [{ type: 'text', text: `说说已发布(tid ${v.tid || '?'}${extra},${v.visibility || '所有人可见'})。` }]
404
+ },
405
+ }
406
+ }
407
+
408
+ export function makeManageQzoneTool() {
409
+ return {
410
+ name: 'qq_manage_qzone',
411
+ description:
412
+ '管理 QQ 空间说说。action:\n' +
413
+ '- like / unlike:点赞或取消赞,需要 tid(abstime 传列表里的 time 更可靠)\n' +
414
+ '- comment:评论或回复,需要 tid + content;回复一级评论再传 comment_id + comment_uin,' +
415
+ '回复楼中楼再传 reply_uin + reply_name;只有普通评论能带图\n' +
416
+ '- delete:删除自己空间的一条说说,需要 tid + confirm=true,不可撤销\n' +
417
+ '- set_right:改自己空间一条说说的可见范围,需要 tid + visibility(partial/exclude 再传 target_uins)\n' +
418
+ '- ban / unban:拉黑或解除拉黑某人的空间访问,需要 user_id',
419
+ parameters: {
420
+ type: 'object',
421
+ properties: {
422
+ action: {
423
+ type: 'string',
424
+ enum: ['like', 'unlike', 'comment', 'delete', 'set_right', 'ban', 'unban'],
425
+ description: '要做什么',
426
+ },
427
+ tid: { type: 'string', description: '说说 tid;like/unlike/comment/delete/set_right 需要' },
428
+ target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
429
+ abstime: { type: 'number', description: '说说发表时间(unix 秒),点赞时传更可靠' },
430
+ content: { type: 'string', description: '评论内容(action=comment)' },
431
+ images: { type: 'array', items: { type: 'string' }, description: '评论图片(普通评论可选,最多 9 张)' },
432
+ comment_id: { type: 'string', description: '要回复的一级评论 ID(评论详情里的 comment_id)' },
433
+ comment_uin: { type: 'number', description: '一级评论作者 QQ 号' },
434
+ reply_uin: { type: 'number', description: '楼中楼里要回复的用户 QQ 号' },
435
+ reply_name: { type: 'string', description: '楼中楼里要回复的用户昵称' },
436
+ visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: 'set_right 的新可见范围' },
437
+ target_uins: { type: 'array', items: { type: 'number' }, description: 'set_right 时 partial/exclude 的 QQ 号列表' },
438
+ user_id: { type: 'number', description: 'ban/unban 的目标 QQ 号' },
439
+ confirm: { type: 'boolean', description: 'delete 时必须显式传 true' },
440
+ },
441
+ required: ['action'],
442
+ additionalProperties: false,
443
+ },
444
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
445
+ async execute(args) {
446
+ const cfg = loadCfg()
447
+ const action = String((args && args.action) || '')
448
+ const tid = args && args.tid != null ? String(args.tid).trim() : ''
449
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
450
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
451
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
452
+ }
453
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
454
+ const needTid = ['like', 'unlike', 'comment', 'delete', 'set_right']
455
+ if (needTid.includes(action) && !tid) return { ok: false, error: action + ' 需要 tid' }
456
+ try {
457
+ if (action === 'like' || action === 'unlike') {
458
+ const body = { tid }
459
+ if (targetUin) body.target_uin = targetUin
460
+ const abstime = Number(args && args.abstime)
461
+ if (Number.isFinite(abstime) && abstime > 0) body.abstime = Math.floor(abstime)
462
+ await onebot(action === 'like' ? 'like_qzone' : 'unlike_qzone', body, cfg)
463
+ return { ok: true, action, tid, liked: action === 'like' }
464
+ }
465
+ if (action === 'comment') {
466
+ const content = cleanMultiline(args && args.content, 1000)
467
+ if (!content) return { ok: false, error: '评论内容是空的' }
468
+ const images = (Array.isArray(args && args.images) ? args.images : [])
469
+ .map(imageSourceOf).filter(Boolean)
470
+ if (images.length > 9) return { ok: false, error: '评论最多 9 张图片' }
471
+ const parentId = args && args.comment_id != null ? String(args.comment_id).trim() : ''
472
+ const hasParentUin = args && args.comment_uin != null
473
+ const parentUin = hasParentUin ? Number(args.comment_uin) : 0
474
+ const hasReplyUin = args && args.reply_uin != null
475
+ const replyUin = hasReplyUin ? Number(args.reply_uin) : 0
476
+ const replyName = clean(args && args.reply_name)
477
+ if (parentId && (!hasParentUin || !Number.isInteger(parentUin) || parentUin <= 0)) {
478
+ return { ok: false, error: 'comment_id 和 comment_uin 要一起传,comment_uin 是正整数 QQ 号' }
479
+ }
480
+ if (hasParentUin && !parentId) {
481
+ return { ok: false, error: 'comment_uin 要配合 comment_id 一起传' }
482
+ }
483
+ if (hasReplyUin && (!Number.isInteger(replyUin) || replyUin <= 0)) {
484
+ return { ok: false, error: 'reply_uin 不是有效 QQ 号' }
485
+ }
486
+ if (hasReplyUin && (!parentId || !replyName)) {
487
+ return { ok: false, error: '回复楼中楼需要 comment_id + comment_uin + reply_uin + reply_name' }
488
+ }
489
+ if (parentId && images.length) return { ok: false, error: '回复评论只能发文字,不能带图' }
490
+ const body = { tid, content }
491
+ if (targetUin) body.target_uin = targetUin
492
+ if (images.length) body.images = images
493
+ if (parentId) {
494
+ body.comment_id = parentId
495
+ body.comment_uin = parentUin
496
+ }
497
+ if (replyUin) {
498
+ body.reply_uin = replyUin
499
+ body.reply_name = replyName
500
+ }
501
+ const data = await onebot('comment_qzone', body, cfg)
502
+ return {
503
+ ok: true,
504
+ action,
505
+ tid,
506
+ comment_id: data && data.comment_id != null ? String(data.comment_id) : '',
507
+ parent_comment_id: data && data.parent_comment_id != null ? String(data.parent_comment_id) : '',
508
+ parent_comment_uin: Number((data && data.parent_comment_uin) || 0),
509
+ }
510
+ }
511
+ if (action === 'delete') {
512
+ if (!args || args.confirm !== true) {
513
+ return { ok: false, error: '删说说不可撤销,需要 confirm=true 才会执行' }
514
+ }
515
+ await onebot('delete_qzone_msg', { tid }, cfg)
516
+ return { ok: true, action, tid, deleted: true }
517
+ }
518
+ if (action === 'set_right') {
519
+ const ugcRight = qzoneRightOf(args && args.visibility)
520
+ if (!ugcRight) return { ok: false, error: 'set_right 需要 visibility(public/friends/partial/private/exclude)' }
521
+ const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
522
+ .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
523
+ if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
524
+ return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
525
+ }
526
+ const body = { tid, ugc_right: ugcRight }
527
+ if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
528
+ const data = await onebot('set_qzone_msg_right', body, cfg)
529
+ return {
530
+ ok: true,
531
+ action,
532
+ tid,
533
+ ugc_right: Number((data && data.ugc_right) || ugcRight),
534
+ visibility: QZONE_VISIBILITY_NAMES[Number((data && data.ugc_right) || ugcRight)],
535
+ }
536
+ }
537
+ if (action === 'ban' || action === 'unban') {
538
+ const userId = Number(args && args.user_id)
539
+ if (!Number.isFinite(userId) || !userId) return { ok: false, error: 'ban/unban 需要 user_id' }
540
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
541
+ await onebot('set_qzone_ban', { user_id: userId, enable: action === 'ban' }, cfg)
542
+ return { ok: true, action, user_id: userId, banned: action === 'ban' }
543
+ }
544
+ return { ok: false, error: '不认识的 action:' + action }
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: '操作 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
551
+ if (v.action === 'like' || v.action === 'unlike') {
552
+ return [{ type: 'text', text: `${v.liked ? '已点赞' : '已取消赞'}(tid ${v.tid})。` }]
553
+ }
554
+ if (v.action === 'comment') {
555
+ return [{ type: 'text', text: `评论已发(tid ${v.tid},comment_id ${v.comment_id || '?'})。` }]
556
+ }
557
+ if (v.action === 'delete') {
558
+ return [{ type: 'text', text: `已删除说说 ${v.tid}。` }]
559
+ }
560
+ if (v.action === 'set_right') {
561
+ return [{ type: 'text', text: `说说 ${v.tid} 的可见范围已改为「${v.visibility || v.ugc_right}」。` }]
562
+ }
563
+ if (v.action === 'ban' || v.action === 'unban') {
564
+ return [{ type: 'text', text: `已${v.banned ? '拉黑' : '解除拉黑'} QQ ${v.user_id} 的空间访问。` }]
565
+ }
566
+ return [{ type: 'text', text: 'QQ 空间操作完成。' }]
567
+ },
568
+ }
569
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,73 @@
1
+ // 通用文本与消息格式工具。
2
+
3
+ export function clean(s) {
4
+ return String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
5
+ }
6
+
7
+ // 说说正文和评论要保留换行;只清掉控制字符,不折叠空白。
8
+ export function cleanMultiline(s, max = 4000) {
9
+ return String(s == null ? '' : s)
10
+ .replace(/\r\n?/g, '\n')
11
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ')
12
+ .trim()
13
+ .slice(0, max)
14
+ }
15
+
16
+ export function segText(segs) {
17
+ const list = Array.isArray(segs) ? segs : []
18
+ let media = 0
19
+ return clean(list
20
+ .map((s) => {
21
+ if (!s) return ''
22
+ if (s.type === 'text') return String((s.data && s.data.text) || '')
23
+ if (s.type === 'at') {
24
+ const qq = s.data && s.data.qq
25
+ return '@' + (qq === 'all' ? '全体' : String((s.data && s.data.name) || qq || ''))
26
+ }
27
+ if (s.type === 'image') return '[图片#' + (media++) + ']'
28
+ if (s.type === 'mface') return '[表情包#' + (media++) + ']'
29
+ if (s.type === 'face') {
30
+ const id = s.data && s.data.id
31
+ return id != null && id !== '' ? '[表情:' + id + ']' : '[表情]'
32
+ }
33
+ return ''
34
+ })
35
+ .join(''))
36
+ }
37
+
38
+ // 这里只做「优先展示动图」的排序提示,不决定 mediaType。
39
+ // QQ 的 [动画表情] 摘要经常贴在静态 PNG/JPEG 上,所以别再信 summary 里的「动画」;
40
+ // 真正是不是 GIF 由后面按字节嗅探,只有 mface 和 .gif 扩展名算动图候选。
41
+ export function animatedSeg(seg) {
42
+ if (!seg || !seg.data) return false
43
+ if (seg.type === 'mface') return true
44
+ const d = seg.data
45
+ return /\.gif(?:[?#]|$)/i.test(String(d.file || '')) || /\.gif(?:[?#]|$)/i.test(String(d.url || ''))
46
+ }
47
+
48
+ export function fmtTime(sec) {
49
+ const t = Number(sec)
50
+ if (!Number.isFinite(t) || t <= 0) return ''
51
+ const d = new Date(t * 1000)
52
+ const p = (n) => String(n).padStart(2, '0')
53
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
54
+ }
55
+
56
+ export function senderName(m) {
57
+ const s = (m && m.sender) || {}
58
+ return clean(s.card || s.nickname || s.user_id || (m && m.user_id) || '?')
59
+ }
60
+
61
+ export function senderId(m) {
62
+ const s = (m && m.sender) || {}
63
+ return clean(s.user_id || (m && m.user_id) || '')
64
+ }
65
+
66
+ // 聊天记录一次最多带 10 张图:动图最多留 3 张,剩下的名额按时间给最近的静图。
67
+ export const CHAT_IMAGE_LIMIT = 10
68
+
69
+ export function pickChatImages(picked, max = CHAT_IMAGE_LIMIT) {
70
+ const anim = picked.filter((x) => x.animated).slice(-3)
71
+ const stat = picked.filter((x) => !x.animated).slice(-(max - anim.length))
72
+ return [...anim, ...stat].slice(0, max)
73
+ }