dsh-qq-tools 0.8.1 → 0.9.0

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/lib/shared.js CHANGED
@@ -1,12 +1,15 @@
1
1
  // dsh-qq-tools —— 给大肥鱼会话用的轻量 QQ 群/私聊工具 + 唤醒(共享核心)
2
2
  //
3
- // 结构(2026-10-23 拆分,2026-10-24 加表情和私聊):
4
- // · 每个工具是一个独立的 cordis 组件,由 cordis.patch.yml 里的 8 个 insert 行加载;
5
- // 插件详情页「包含的组件」会列出 8 个工具,每个都有自己的开关。
6
- // · 本文件只放共享逻辑:配置、OneBot、目标会话挂载、@ 轮询、私聊唤醒、戳一戳、表情、观察窗。
3
+ // 结构(2026-10-23 拆分,2026-10-24 加表情和私聊,2026-10-26 加头像/好友管理/QQ 空间):
4
+ // · 每个工具是一个独立的 cordis 组件,由 cordis.patch.yml 里的 11 个工具 insert 行加载
5
+ // (包根 carrier 另有一行);插件详情页会列出 11 个工具组,每个都有自己的开关。
6
+ // · 本文件只放共享逻辑:配置、OneBot、目标会话挂载、@ 轮询、私聊唤醒、戳一戳、表情、
7
+ // 头像、好友管理、QQ 空间、观察窗。
7
8
  // · 发群消息组件负责 @ 提醒轮询;戳一戳组件负责接收戳一戳事件;
8
9
  // 收藏表情组件负责 QQ 收藏夹(走 SnowLuma 的自定义表情接口,和 QQ 客户端同步);
9
- // 私聊组件负责好友/私聊记录/发私聊消息,以及私聊消息唤醒。
10
+ // 私聊组件负责好友/私聊记录/发私聊消息,以及私聊消息唤醒;
11
+ // 好友管理组件负责好友资料/备注/分组/删好友/名片赞和好友申请;
12
+ // QQ 空间组件负责说说列表、详情评论、发表和管理。
10
13
  //
11
14
  // 设计目标(按用户要求):
12
15
  // · 群聊不逐条转发、不自动回;由「主动搭话」或「有人 @」触发,
@@ -34,7 +37,7 @@
34
37
  //
35
38
  // 工具本身的开关在 DSH 插件页的组件列表里:关掉某个组件,它就不再注册对应工具。
36
39
 
37
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs'
40
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync, statSync } from 'node:fs'
38
41
  import { join } from 'node:path'
39
42
  import { homedir } from 'node:os'
40
43
  import { randomUUID } from 'node:crypto'
@@ -47,34 +50,82 @@ const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
47
50
 
48
51
 
49
52
  const requireFromHere = createRequire(import.meta.url)
53
+ let sharpSource = ''
50
54
 
51
- // sharp 是可选的,按这个顺序找:SHARP_PATH → 插件自己装的 → 宿主进程能解析到的。
52
- // DSH 桌面版把 sharp 打在 resources/app.asar/dsh/node_modules 下,CLI 版跟着
53
- // @deepseek-ai/dsh 的依赖走,所以从宿主的启动入口解析最稳,不用写死安装路径。
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
+ // 尺寸被宿主拒绝时也没法兜底重编码。
54
81
  function loadSharp() {
55
82
  const probes = []
56
- if (process.env.SHARP_PATH) probes.push(() => requireFromHere(process.env.SHARP_PATH))
57
- probes.push(() => requireFromHere('sharp'))
58
- if (process.argv[1]) probes.push(() => createRequire(process.argv[1])('sharp'))
59
- if (process.resourcesPath) {
60
- probes.push(() => createRequire(join(process.resourcesPath, 'app.asar', 'dsh', 'package.json'))('sharp'))
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) {}
61
97
  }
62
- for (const p of [
63
- '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
64
- '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
65
- ]) {
66
- probes.push(() => requireFromHere(p))
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
+ }
67
110
  }
111
+ add('已加载模块缓存', sharpFromCache)
112
+
68
113
  for (const probe of probes) {
69
114
  try {
70
- const s = probe()
71
- if (s) return s
115
+ const s = probe.fn()
116
+ if (s) {
117
+ sharpSource = probe.label
118
+ return s
119
+ }
72
120
  } catch (error) {}
73
121
  }
74
122
  return null
75
123
  }
76
124
 
77
125
  const sharp = loadSharp()
126
+ diag('sharp:' + (sharp
127
+ ? '已加载(' + sharpSource + ')'
128
+ : '没找到;GIF 只取首帧,大图交给 DSH 归一化,图片不会因此丢失'))
78
129
  let gifWarned = false
79
130
 
80
131
  const DEFAULT_GROUPS = []
@@ -106,6 +157,15 @@ function clean(s) {
106
157
  return String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
107
158
  }
108
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
+
109
169
  function ensureCfg() {
110
170
  try {
111
171
  if (existsSync(CFG_PATH)) return
@@ -469,8 +529,9 @@ export function makeListTool() {
469
529
 
470
530
  // ---------- 图片类型与尺寸 ----------
471
531
  // DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
472
- // animated 提示都可能撒谎,所以只信字节。DSH 默认单张源图上限 20MiB,
473
- // 归一化目标 4MiB;源图超过 4MiB 时本地先缩一下,避免白白走一遍宿主重编码。
532
+ // animated 提示都可能撒谎,所以只信字节。大图不依赖 sharp:DSH 默认单张源图
533
+ // 上限 20MiB、归一化目标 4MiB,直接交给它处理;sharp 只用于 GIF 拆帧和
534
+ // admit 被尺寸拒绝后的兜底重编码。
474
535
  const ADMIT_IMAGE_LIMIT = 6
475
536
  const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
476
537
  const IMAGE_MEDIA_TYPES = {
@@ -725,14 +786,8 @@ export function admitImagesFor(ctx) {
725
786
  diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
726
787
  }
727
788
  }
728
- // 以前这里直接丢弃 >5MiB 的图;DSH 实际能收 20MiB 并自动归一化,所以改成先缩放。
729
- if (bytes.length > IMAGE_SOFT_LIMIT) {
730
- const shrunk = await shrinkImage(bytes)
731
- if (shrunk) {
732
- bytes = shrunk.bytes
733
- mediaType = shrunk.mediaType
734
- }
735
- }
789
+ // 大图不在这里预缩放:DSH 自己会把源图归一化到 4MiB,20MiB 以内都能收。
790
+ // 只有 admit 因尺寸被拒时才在 retryImageBlock 里用 sharp 兜底重编码。
736
791
  prepared.push({
737
792
  caption: String(x.caption || ''),
738
793
  block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
@@ -1161,6 +1216,54 @@ function imageSourceOf(raw) {
1161
1216
  return s
1162
1217
  }
1163
1218
 
1219
+ // 从聊天消息里取图片:get_msg → 图片段 → 可直接交给 SnowLuma 的 file 来源。
1220
+ // 收藏表情、换头像、发说说都用这一条路径,索引从 0 开始。
1221
+ async function imageSourceFromMessage(messageId, index, cfg) {
1222
+ const msg = await onebot('get_msg', { message_id: messageId }, cfg)
1223
+ const segs = Array.isArray(msg && msg.message) ? msg.message : []
1224
+ const images = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
1225
+ const idx = Number.isInteger(Number(index)) ? Math.max(0, Number(index)) : 0
1226
+ const pick = images[idx]
1227
+ if (!pick) return { error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
1228
+ const source = imageSourceOf(pick.data.url || pick.data.file)
1229
+ if (!source) return { error: '拿不到这条消息里的图片地址' }
1230
+ return { source, total: images.length }
1231
+ }
1232
+
1233
+ async function imageSourcesFromMessage(messageId, indexes, cfg, max = 9) {
1234
+ const msg = await onebot('get_msg', { message_id: messageId }, cfg)
1235
+ const segs = Array.isArray(msg && msg.message) ? msg.message : []
1236
+ const picks = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
1237
+ const wanted = Array.isArray(indexes) && indexes.length
1238
+ ? indexes.map((x) => Number(x)).filter((x) => Number.isInteger(x))
1239
+ : picks.map((_, i) => i)
1240
+ const sources = []
1241
+ for (const i of wanted) {
1242
+ if (sources.length >= max) break
1243
+ const pick = picks[i]
1244
+ if (!pick) continue
1245
+ const source = imageSourceOf(pick.data.url || pick.data.file)
1246
+ if (source) sources.push(source)
1247
+ }
1248
+ return { sources, total: picks.length }
1249
+ }
1250
+
1251
+ // 工具回执里不要把 base64 原样吐回去,不然一次换头像就能塞满上下文。
1252
+ function describeSource(source) {
1253
+ const s = String(source || '')
1254
+ if (/^base64:\/\//i.test(s)) return 'base64://…'
1255
+ if (/^file:\/\//i.test(s)) {
1256
+ try {
1257
+ return decodeURIComponent(new URL(s).pathname).split('/').pop() || '本机图片'
1258
+ } catch (error) {
1259
+ return '本机图片'
1260
+ }
1261
+ }
1262
+ if (/^https?:/i.test(s)) return s.length > 200 ? s.slice(0, 200) + '…' : s
1263
+ return s.slice(0, 200)
1264
+ }
1265
+
1266
+
1164
1267
  export function makeSendFaceTool(onSent) {
1165
1268
  return {
1166
1269
  name: 'qq_send_face',
@@ -1450,16 +1553,9 @@ export function makeFavoriteFaceTool() {
1450
1553
  }
1451
1554
  let source = imageSourceOf(args && args.file)
1452
1555
  if (!source && args && args.message_id != null && args.message_id !== '') {
1453
- const msg = await onebot('get_msg', { message_id: args.message_id }, cfg)
1454
- const segs = Array.isArray(msg && msg.message) ? msg.message : []
1455
- const images = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
1456
- const idx = Number.isInteger(Number(args.index)) ? Math.max(0, Number(args.index)) : 0
1457
- const pick = images[idx]
1458
- if (!pick) {
1459
- return { ok: false, error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
1460
- }
1461
- source = imageSourceOf(pick.data.url || pick.data.file)
1462
- if (!source) return { ok: false, error: '拿不到这条消息里的图片地址' }
1556
+ const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
1557
+ if (got.error) return { ok: false, error: got.error }
1558
+ source = got.source
1463
1559
  }
1464
1560
  if (!source) return { ok: false, error: '要收藏就传 file 或 message_id' }
1465
1561
  const data = await onebot('add_custom_face', { file: source }, cfg)
@@ -1534,6 +1630,1171 @@ export function makePokeTool(onSent) {
1534
1630
  }
1535
1631
 
1536
1632
 
1633
+ // ---------- 头像 ----------
1634
+ // set_qq_avatar 是 SnowLuma 扩展,file 和图片接口一样吃 http(s)/base64:///file://。
1635
+ // 换头像会立刻对所有好友和群友可见,描述里明确要求只在用户提出时调用。
1636
+ export function makeSetAvatarTool() {
1637
+ return {
1638
+ name: 'qq_set_avatar',
1639
+ description:
1640
+ '改自己的 QQ 头像(SnowLuma set_qq_avatar)。' +
1641
+ 'file 传 http(s) 链接、base64://、data:image/... 或本机文件路径;' +
1642
+ '也可以用 message_id + index 从聊天记录里的某张图片取图。' +
1643
+ '只有用户明确要求换头像时才用;换完不用再重复说明。',
1644
+ parameters: {
1645
+ type: 'object',
1646
+ properties: {
1647
+ file: { type: 'string', description: '图片:http(s) 链接、base64://、data:image/... 或本机路径;和 message_id 二选一' },
1648
+ message_id: { type: 'string', description: '从这条消息里取图当头像;和 file 二选一' },
1649
+ index: { type: 'number', description: 'message_id 里第几张图片,从 0 开始,默认 0' },
1650
+ },
1651
+ additionalProperties: false,
1652
+ },
1653
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1654
+ async execute(args) {
1655
+ const cfg = loadCfg()
1656
+ const fileRaw = String((args && args.file) || '').trim()
1657
+ const hasMessage = args && args.message_id != null && args.message_id !== ''
1658
+ if (fileRaw && hasMessage) return { ok: false, error: 'file 和 message_id 只能选一个' }
1659
+ if (!fileRaw && !hasMessage) return { ok: false, error: '要换头像就传 file 或 message_id' }
1660
+ try {
1661
+ let source = imageSourceOf(fileRaw)
1662
+ if (!source && hasMessage) {
1663
+ const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
1664
+ if (got.error) return { ok: false, error: got.error }
1665
+ source = got.source
1666
+ }
1667
+ if (!source) return { ok: false, error: '拿不到头像图片' }
1668
+ await onebot('set_qq_avatar', { file: source }, cfg)
1669
+ return { ok: true, avatar: true, source: describeSource(source) }
1670
+ } catch (error) {
1671
+ return { ok: false, error: String((error && error.message) || error) }
1672
+ }
1673
+ },
1674
+ render(_args, v) {
1675
+ if (!v || !v.ok) return [{ type: 'text', text: '换头像失败:' + String((v && v.error) || '未知错误') }]
1676
+ return [{ type: 'text', text: 'QQ 头像已更新(' + (v.source || '图片') + ')。' }]
1677
+ },
1678
+ }
1679
+ }
1680
+
1681
+
1682
+ // ---------- 好友管理 ----------
1683
+ // 读写好友资料、备注、分组、名片赞,以及好友申请。
1684
+ // 普通好友申请只有 request 事件,SnowLuma 没有「列出历史好友申请」的 HTTP 接口,
1685
+ // 所以这里一边接 WebSocket 事件进收件箱,一边用 get_doubt_friends_add_request
1686
+ // 拿服务端保存的可疑申请;历史普通申请只能去 QQ 客户端看。
1687
+ const FRIEND_REQUEST_LIMIT = 30
1688
+ const friendRequestInbox = new Map()
1689
+
1690
+ function rememberFriendRequest(ev) {
1691
+ const flag = clean(ev && ev.flag)
1692
+ if (!flag) return
1693
+ const userId = String((ev && ev.user_id) || '')
1694
+ const time = Number(ev && ev.time) || Math.floor(Date.now() / 1000)
1695
+ friendRequestInbox.set(flag, {
1696
+ flag,
1697
+ user_id: userId,
1698
+ nickname: '',
1699
+ comment: String((ev && ev.comment) || '').slice(0, 500),
1700
+ time,
1701
+ at: Date.now(),
1702
+ })
1703
+ if (friendRequestInbox.size > FRIEND_REQUEST_LIMIT) {
1704
+ const oldest = [...friendRequestInbox.entries()].sort((a, b) => (a[1].at || 0) - (b[1].at || 0))[0]
1705
+ if (oldest) friendRequestInbox.delete(oldest[0])
1706
+ }
1707
+ diag('好友申请 → 收件箱:' + userId + ' ' + clean(ev && ev.comment).slice(0, 120))
1708
+ }
1709
+
1710
+ async function friendEntryOf(cfg, userId) {
1711
+ try {
1712
+ const groups = await onebot('get_friends_with_category', {}, cfg)
1713
+ if (Array.isArray(groups) && groups.length) {
1714
+ for (const g of groups) {
1715
+ const list = Array.isArray(g && g.buddyList) ? g.buddyList : []
1716
+ const hit = list.find((b) => String(b && b.user_id) === String(userId))
1717
+ if (hit) {
1718
+ const categoryId = Number(g.categoryId)
1719
+ return {
1720
+ entry: hit,
1721
+ category: { id: Number.isFinite(categoryId) ? categoryId : 0, name: clean(g.categoryName) },
1722
+ }
1723
+ }
1724
+ }
1725
+ }
1726
+ } catch (error) {}
1727
+ // 分组接口不可用(或好友不在分组结果里)时退回普通好友列表:
1728
+ // 至少能判断是不是好友、拿到备注。
1729
+ try {
1730
+ const data = await onebot('get_friend_list', {}, cfg)
1731
+ const hit = (Array.isArray(data) ? data : []).find((f) => String(f && f.user_id) === String(userId))
1732
+ if (hit) return { entry: hit, category: null }
1733
+ } catch (error) {}
1734
+ return null
1735
+ }
1736
+
1737
+ // 接口不可用时返回 null(未知),不要谎报「不是单向好友」。
1738
+ async function oneWayOf(cfg, userId) {
1739
+ try {
1740
+ const list = await onebot('get_unidirectional_friend_list', {}, cfg)
1741
+ return (Array.isArray(list) ? list : []).some((x) => {
1742
+ const id = x && typeof x === 'object'
1743
+ ? String(x.user_id || x.uin || x.qq || '')
1744
+ : String(x || '')
1745
+ return !!id && id === String(userId)
1746
+ })
1747
+ } catch (error) {
1748
+ return null
1749
+ }
1750
+ }
1751
+
1752
+ function sexText(value) {
1753
+ const s = String(value == null ? '' : value).toLowerCase()
1754
+ if (s === 'male' || s === '1' || s === '男') return '男'
1755
+ if (s === 'female' || s === '2' || s === '女') return '女'
1756
+ if (!s || s === '0' || s === 'unknown' || s === '未知') return '未知'
1757
+ return clean(value)
1758
+ }
1759
+
1760
+ export function makeGetFriendInfoTool() {
1761
+ return {
1762
+ name: 'qq_get_friend_info',
1763
+ description:
1764
+ '看某个 QQ 的资料和好友关系:昵称、备注、分组、性别年龄、等级、VIP、是否单向好友。' +
1765
+ 'user_id 从 qq_list_friends、聊天记录或好友申请里拿。只读。',
1766
+ parameters: {
1767
+ type: 'object',
1768
+ properties: {
1769
+ user_id: { type: 'number', description: '对方 QQ 号' },
1770
+ },
1771
+ required: ['user_id'],
1772
+ additionalProperties: false,
1773
+ },
1774
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1775
+ async execute(args) {
1776
+ const cfg = loadCfg()
1777
+ const userId = Number(args && args.user_id)
1778
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1779
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1780
+ let info = null
1781
+ let infoError = ''
1782
+ try {
1783
+ info = await onebot('get_stranger_info', { user_id: userId }, cfg)
1784
+ } catch (error) {
1785
+ infoError = String((error && error.message) || error)
1786
+ }
1787
+ const found = await friendEntryOf(cfg, userId)
1788
+ if (!info && !found) {
1789
+ return { ok: false, error: infoError || '查不到这个 QQ 的资料' }
1790
+ }
1791
+ const entry = (found && found.entry) || {}
1792
+ const oneWay = await oneWayOf(cfg, userId)
1793
+ const rawLevel = info
1794
+ ? (info.qq_level != null ? info.qq_level : info.qqLevel != null ? info.qqLevel : info.level)
1795
+ : 0
1796
+ const friend = {
1797
+ user_id: String(userId),
1798
+ nickname: clean((info && info.nickname) || entry.nickname),
1799
+ remark: clean(entry.remark || (info && info.remark)),
1800
+ category: found ? found.category : null,
1801
+ sex: sexText(info && info.sex),
1802
+ age: Number((info && info.age) || 0),
1803
+ qid: clean(info && info.qid),
1804
+ level: Number(rawLevel || 0),
1805
+ vip: !!(info && info.is_vip),
1806
+ vip_level: Number((info && info.vip_level) || 0),
1807
+ is_friend: !!found,
1808
+ one_way: oneWay,
1809
+ }
1810
+ return { ok: true, friend }
1811
+ },
1812
+ render(_args, v) {
1813
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友资料失败:' + String((v && v.error) || '未知错误') }]
1814
+ const f = v.friend || {}
1815
+ const lines = [
1816
+ `QQ ${f.user_id}:`,
1817
+ '- 昵称:' + (f.nickname || '未知'),
1818
+ '- 备注:' + (f.remark || '(无)'),
1819
+ '- 分组:' + (f.category ? `${f.category.name || '未命名'}(id ${f.category.id})` : (f.is_friend ? '未知(分组接口没返回)' : '不是好友')),
1820
+ '- 性别年龄:' + (f.sex || '未知') + (f.age ? ',' + f.age + ' 岁' : ''),
1821
+ '- 等级:' + (f.level || '未知'),
1822
+ '- VIP:' + (f.vip ? '是' + (f.vip_level ? '(' + f.vip_level + ' 级)' : '') : '否'),
1823
+ '- 单向好友:' + (f.one_way === true ? '是' : f.one_way === false ? '否' : '未知(接口不可用)'),
1824
+ ]
1825
+ if (f.qid) lines.splice(2, 0, '- QID:' + f.qid)
1826
+ return [{ type: 'text', text: lines.join('\n') }]
1827
+ },
1828
+ }
1829
+ }
1830
+
1831
+ export function makeListFriendCategoriesTool() {
1832
+ return {
1833
+ name: 'qq_list_friend_categories',
1834
+ description:
1835
+ '看好友分组和每个分组里的好友(SnowLuma get_friends_with_category)。' +
1836
+ '移动好友分组时,category_id / category_name 从这里拿。' +
1837
+ '分组接口漏掉的好友会补进「未分组(接口未返回)」里。只读。',
1838
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
1839
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1840
+ async execute() {
1841
+ const cfg = loadCfg()
1842
+ try {
1843
+ let categories = []
1844
+ let usedFriendListFallback = false
1845
+ try {
1846
+ const data = await onebot('get_friends_with_category', {}, cfg)
1847
+ if (!Array.isArray(data)) throw new Error('好友分组接口返回的不是数组')
1848
+ categories = data.map((g) => ({
1849
+ id: Number.isFinite(Number(g && g.categoryId)) ? Number(g.categoryId) : 0,
1850
+ name: clean(g && g.categoryName),
1851
+ count: Number((g && g.categoryMbCount) || 0),
1852
+ friends: (Array.isArray(g && g.buddyList) ? g.buddyList : []).map((b) => ({
1853
+ user_id: String((b && b.user_id) || ''),
1854
+ nickname: clean(b && b.nickname),
1855
+ remark: clean(b && b.remark),
1856
+ })),
1857
+ }))
1858
+ } catch (error) {
1859
+ // 老版本或 NapCat 没有分组接口时,退回普通好友列表,至少还能看到人。
1860
+ const data = await onebot('get_friend_list', {}, cfg)
1861
+ const friends = (Array.isArray(data) ? data : []).map((f) => ({
1862
+ user_id: String((f && f.user_id) || ''),
1863
+ nickname: clean(f && f.nickname),
1864
+ remark: clean(f && f.remark),
1865
+ }))
1866
+ categories = [{ id: 0, name: '我的好友', count: friends.length, friends }]
1867
+ usedFriendListFallback = true
1868
+ }
1869
+ // SnowLuma 的分组接口可能漏人(真机见过 86 个好友只返回 83 个),
1870
+ // 用普通好友列表补齐,免得模型以为这些人不存在。
1871
+ if (!usedFriendListFallback) {
1872
+ try {
1873
+ const data = await onebot('get_friend_list', {}, cfg)
1874
+ const known = new Set()
1875
+ for (const c of categories) for (const f of c.friends) known.add(f.user_id)
1876
+ const missing = (Array.isArray(data) ? data : []).map((f) => ({
1877
+ user_id: String((f && f.user_id) || ''),
1878
+ nickname: clean(f && f.nickname),
1879
+ remark: clean(f && f.remark),
1880
+ })).filter((f) => f.user_id && !known.has(f.user_id))
1881
+ if (missing.length) {
1882
+ categories.push({ id: -1, name: '未分组(接口未返回)', count: missing.length, friends: missing })
1883
+ }
1884
+ } catch (error) {}
1885
+ }
1886
+ for (const c of categories) {
1887
+ c.friends = c.friends.filter((f) => f.user_id && allowedPrivate(f.user_id, cfg))
1888
+ c.count = c.friends.length
1889
+ }
1890
+ const total = categories.reduce((n, c) => n + c.friends.length, 0)
1891
+ return { ok: true, total, categories }
1892
+ } catch (error) {
1893
+ return { ok: false, error: String((error && error.message) || error) }
1894
+ }
1895
+ },
1896
+ render(_args, v) {
1897
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友分组失败:' + String((v && v.error) || '未知错误') }]
1898
+ if (!v.categories.length) return [{ type: 'text', text: '没有好友分组。' }]
1899
+ const lines = [`好友分组(${v.categories.length} 个,共 ${v.total} 人):`]
1900
+ for (const c of v.categories) {
1901
+ const names = c.friends.slice(0, 20).map((f) => f.remark || f.nickname || f.user_id)
1902
+ const idText = c.id >= 0 ? `id ${c.id},` : ''
1903
+ lines.push(`- ${c.name || '未命名'}(${idText}${c.count} 人)${names.length ? ':' + names.join('、') : ''}${c.friends.length > names.length ? ' …' : ''}`)
1904
+ }
1905
+ return [{ type: 'text', text: lines.join('\n') }]
1906
+ },
1907
+ }
1908
+ }
1909
+
1910
+ export function makeUpdateFriendTool() {
1911
+ return {
1912
+ name: 'qq_update_friend',
1913
+ description:
1914
+ '改好友备注或把好友移到别的分组。' +
1915
+ 'remark 传空字符串表示清空备注;分组用 category_id 或 category_name(名称必须唯一且完全匹配),' +
1916
+ '分组 id/名称从 qq_list_friend_categories 拿。两件事至少做一件。',
1917
+ parameters: {
1918
+ type: 'object',
1919
+ properties: {
1920
+ user_id: { type: 'number', description: '好友 QQ 号' },
1921
+ remark: { type: 'string', description: '新备注;传空字符串清空;不传就不改备注' },
1922
+ category_id: { type: 'number', description: '目标分组 ID' },
1923
+ category_name: { type: 'string', description: '目标分组名称(完全匹配)' },
1924
+ },
1925
+ required: ['user_id'],
1926
+ additionalProperties: false,
1927
+ },
1928
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1929
+ async execute(args) {
1930
+ const cfg = loadCfg()
1931
+ const userId = Number(args && args.user_id)
1932
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1933
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1934
+ const hasRemark = args && args.remark != null
1935
+ const remark = clean(args && args.remark)
1936
+ const hasCategoryId = args && args.category_id != null && args.category_id !== ''
1937
+ const categoryId = hasCategoryId ? Number(args.category_id) : null
1938
+ const categoryName = clean(args && args.category_name)
1939
+ if (!hasRemark && !hasCategoryId && !categoryName) {
1940
+ return { ok: false, error: '要改什么?传 remark,或 category_id / category_name' }
1941
+ }
1942
+ if (hasCategoryId && !Number.isInteger(categoryId)) return { ok: false, error: 'category_id 不是有效整数' }
1943
+ try {
1944
+ const changes = []
1945
+ if (hasRemark) {
1946
+ await onebot('set_friend_remark', { user_id: userId, remark }, cfg)
1947
+ changes.push(remark ? '备注改成「' + remark + '」' : '备注已清空')
1948
+ }
1949
+ if (hasCategoryId || categoryName) {
1950
+ let targetId = categoryId
1951
+ let targetName = categoryName
1952
+ if (!hasCategoryId) {
1953
+ try {
1954
+ const data = await onebot('get_friends_with_category', {}, cfg)
1955
+ const hits = (Array.isArray(data) ? data : []).filter((g) => clean(g && g.categoryName) === categoryName)
1956
+ if (hits.length !== 1) {
1957
+ return {
1958
+ ok: false,
1959
+ error: (hits.length
1960
+ ? `有 ${hits.length} 个分组都叫「${categoryName}」,请改用 category_id`
1961
+ : `没找到叫「${categoryName}」的分组,先用 qq_list_friend_categories 看`)
1962
+ + (changes.length ? '(已' + changes.join(';') + ')' : ''),
1963
+ changed: changes,
1964
+ }
1965
+ }
1966
+ targetId = Number(hits[0].categoryId)
1967
+ if (!Number.isInteger(targetId)) targetId = null
1968
+ targetName = clean(hits[0].categoryName)
1969
+ } catch (error) {
1970
+ // 分组列表接口不可用时直接让 SnowLuma 按名称处理。
1971
+ targetId = null
1972
+ }
1973
+ }
1974
+ const body = { uin: userId }
1975
+ if (targetId != null) body.categoryId = targetId
1976
+ else if (targetName) body.categoryName = targetName
1977
+ await onebot('set_friends_category', body, cfg)
1978
+ changes.push('移到分组「' + (targetName || targetId) + '」')
1979
+ }
1980
+ return { ok: true, user_id: userId, remark: hasRemark ? remark : undefined, changes }
1981
+ } catch (error) {
1982
+ return { ok: false, error: String((error && error.message) || error) }
1983
+ }
1984
+ },
1985
+ render(_args, v) {
1986
+ if (!v || !v.ok) return [{ type: 'text', text: '改好友资料失败:' + String((v && v.error) || '未知错误') }]
1987
+ return [{ type: 'text', text: `QQ ${v.user_id} 已更新:${(v.changes || []).join(';')}。` }]
1988
+ },
1989
+ }
1990
+ }
1991
+
1992
+ export function makeDeleteFriendTool() {
1993
+ return {
1994
+ name: 'qq_delete_friend',
1995
+ description:
1996
+ '删除一个好友。不可撤销:只有用户明确要求删好友时才用,并且必须传 confirm=true。' +
1997
+ 'block=true 会在删除的同时屏蔽对方。',
1998
+ parameters: {
1999
+ type: 'object',
2000
+ properties: {
2001
+ user_id: { type: 'number', description: '好友 QQ 号' },
2002
+ block: { type: 'boolean', description: 'true = 删除并屏蔽,默认 false' },
2003
+ confirm: { type: 'boolean', description: '必须显式传 true,防止误删' },
2004
+ },
2005
+ required: ['user_id', 'confirm'],
2006
+ additionalProperties: false,
2007
+ },
2008
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2009
+ async execute(args) {
2010
+ const cfg = loadCfg()
2011
+ const userId = Number(args && args.user_id)
2012
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
2013
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2014
+ if (!args || args.confirm !== true) return { ok: false, error: '删好友不可撤销,需要 confirm=true 才会执行' }
2015
+ try {
2016
+ const block = args.block === true
2017
+ await onebot('delete_friend', { user_id: userId, block }, cfg)
2018
+ return { ok: true, removed: true, user_id: userId, blocked: block }
2019
+ } catch (error) {
2020
+ return { ok: false, error: String((error && error.message) || error) }
2021
+ }
2022
+ },
2023
+ render(_args, v) {
2024
+ if (!v || !v.ok) return [{ type: 'text', text: '删好友失败:' + String((v && v.error) || '未知错误') }]
2025
+ return [{ type: 'text', text: `已删除好友 QQ ${v.user_id}${v.blocked ? ',并已屏蔽' : ''}。` }]
2026
+ },
2027
+ }
2028
+ }
2029
+
2030
+ export function makeSendLikeTool() {
2031
+ return {
2032
+ name: 'qq_send_like',
2033
+ description:
2034
+ '给好友的名片点赞(QQ 资料卡赞)。times 是次数,默认 1,最多 20。别频繁调用。',
2035
+ parameters: {
2036
+ type: 'object',
2037
+ properties: {
2038
+ user_id: { type: 'number', description: '好友 QQ 号' },
2039
+ times: { type: 'number', description: '点赞次数,默认 1,最多 20' },
2040
+ },
2041
+ required: ['user_id'],
2042
+ additionalProperties: false,
2043
+ },
2044
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2045
+ async execute(args) {
2046
+ const cfg = loadCfg()
2047
+ const userId = Number(args && args.user_id)
2048
+ if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
2049
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2050
+ const times = Math.max(1, Math.min(Math.floor(Number(args && args.times) || 1), 20))
2051
+ try {
2052
+ await onebot('send_like', { user_id: userId, times }, cfg)
2053
+ return { ok: true, user_id: userId, times }
2054
+ } catch (error) {
2055
+ return { ok: false, error: String((error && error.message) || error) }
2056
+ }
2057
+ },
2058
+ render(_args, v) {
2059
+ if (!v || !v.ok) return [{ type: 'text', text: '点赞失败:' + String((v && v.error) || '未知错误') }]
2060
+ return [{ type: 'text', text: `已给 QQ ${v.user_id} 点了 ${v.times} 个赞。` }]
2061
+ },
2062
+ }
2063
+ }
2064
+
2065
+ export function makeListFriendRequestsTool() {
2066
+ return {
2067
+ name: 'qq_list_friend_requests',
2068
+ description:
2069
+ '看待处理的好友申请:插件运行期间通过 WebSocket 收到的普通申请 + QQ 账号里保存的可疑好友申请。' +
2070
+ '每项带 flag,用 qq_handle_friend_request 同意/拒绝。' +
2071
+ '普通申请只在插件启动后收到才在这里,历史申请要去 QQ 客户端看。只读。',
2072
+ parameters: {
2073
+ type: 'object',
2074
+ properties: {
2075
+ include_doubt: { type: 'boolean', description: '要不要一起查可疑好友申请,默认 true' },
2076
+ },
2077
+ additionalProperties: false,
2078
+ },
2079
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2080
+ async execute(args) {
2081
+ const cfg = loadCfg()
2082
+ const includeDoubt = !(args && args.include_doubt === false)
2083
+ const requests = [...friendRequestInbox.values()]
2084
+ .sort((a, b) => (b.time || 0) - (a.time || 0) || (b.at || 0) - (a.at || 0))
2085
+ .slice(0, FRIEND_REQUEST_LIMIT)
2086
+ if (requests.length) {
2087
+ await Promise.all(requests.slice(0, 5).map(async (r) => {
2088
+ if (r.nickname || !r.user_id) return
2089
+ try {
2090
+ const info = await onebot('get_stranger_info', { user_id: Number(r.user_id) }, cfg)
2091
+ r.nickname = clean(info && info.nickname)
2092
+ } catch (error) {}
2093
+ }))
2094
+ }
2095
+ let doubt = []
2096
+ let doubtError = ''
2097
+ if (includeDoubt) {
2098
+ try {
2099
+ const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
2100
+ doubt = (Array.isArray(data) ? data : []).map((x) => ({
2101
+ kind: 'doubt',
2102
+ flag: String((x && x.uid) || ''),
2103
+ user_id: String((x && x.user_id) || ''),
2104
+ nickname: clean(x && x.nick),
2105
+ source: clean(x && x.source),
2106
+ reason: clean(x && x.reason),
2107
+ comment: String((x && x.msg) || '').slice(0, 500),
2108
+ group_code: String((x && x.group_code) || ''),
2109
+ time: Number((x && x.reqTime) || 0),
2110
+ })).filter((x) => x.flag)
2111
+ } catch (error) {
2112
+ doubtError = String((error && error.message) || error)
2113
+ }
2114
+ }
2115
+ return {
2116
+ ok: true,
2117
+ requests: requests.map(({ at, ...r }) => ({ kind: 'friend', ...r })),
2118
+ doubt,
2119
+ doubt_error: doubtError,
2120
+ inbox_note: '普通申请从插件启动后开始收,历史申请不在收件箱里',
2121
+ }
2122
+ },
2123
+ render(_args, v) {
2124
+ if (!v || !v.ok) return [{ type: 'text', text: '看好友申请失败:' + String((v && v.error) || '未知错误') }]
2125
+ const lines = []
2126
+ if (v.requests.length) {
2127
+ lines.push(`普通好友申请(${v.requests.length} 个):`)
2128
+ for (const r of v.requests) {
2129
+ const who = r.nickname ? `${r.nickname}(${r.user_id})` : `QQ ${r.user_id}`
2130
+ lines.push(`- ${who}${r.comment ? ':' + r.comment : ''} flag=${r.flag}`)
2131
+ }
2132
+ }
2133
+ if (v.doubt.length) {
2134
+ lines.push(`可疑好友申请(${v.doubt.length} 个):`)
2135
+ for (const d of v.doubt) {
2136
+ const who = d.nickname ? `${d.nickname}(${d.user_id})` : `QQ ${d.user_id}`
2137
+ const extra = [d.source && '来源 ' + d.source, d.reason && '原因 ' + d.reason].filter(Boolean).join(',')
2138
+ lines.push(`- ${who}${extra ? '(' + extra + ')' : ''}${d.comment ? ':' + d.comment : ''} flag=${d.flag}`)
2139
+ }
2140
+ }
2141
+ if (!v.requests.length && !v.doubt.length) {
2142
+ lines.push(v.doubt_error
2143
+ ? '现在没有普通好友申请;可疑申请接口不可用:' + v.doubt_error.slice(0, 200)
2144
+ : '现在没有待处理的好友申请。')
2145
+ } else if (v.doubt_error) {
2146
+ lines.push('可疑申请接口不可用:' + v.doubt_error.slice(0, 200))
2147
+ }
2148
+ if (v.requests.length || v.doubt.length) {
2149
+ lines.push('用 qq_handle_friend_request 处理:传 flag、kind(friend/doubt)、approve=true/false。')
2150
+ }
2151
+ return [{ type: 'text', text: lines.join('\n') }]
2152
+ },
2153
+ }
2154
+ }
2155
+
2156
+ export function makeHandleFriendRequestTool() {
2157
+ return {
2158
+ name: 'qq_handle_friend_request',
2159
+ description:
2160
+ '同意或拒绝好友申请。flag 从 qq_list_friend_requests 拿,也可以传 user_id 自动在待处理列表里找。' +
2161
+ 'kind=friend 是普通申请,kind=doubt 是可疑申请。approve 必须明确传 true(同意)或 false(拒绝)。',
2162
+ parameters: {
2163
+ type: 'object',
2164
+ properties: {
2165
+ flag: { type: 'string', description: '申请的处理标识,来自 qq_list_friend_requests' },
2166
+ user_id: { type: 'number', description: '申请人 QQ 号;不传 flag 时用它查收件箱' },
2167
+ kind: { type: 'string', enum: ['friend', 'doubt'], description: '申请类型,默认 friend' },
2168
+ approve: { type: 'boolean', description: 'true = 同意,false = 拒绝' },
2169
+ },
2170
+ required: ['approve'],
2171
+ additionalProperties: false,
2172
+ },
2173
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2174
+ async execute(args) {
2175
+ const cfg = loadCfg()
2176
+ const kind = args && args.kind === 'doubt' ? 'doubt' : 'friend'
2177
+ const userId = args && args.user_id != null ? Number(args.user_id) : 0
2178
+ if (args && args.user_id != null && !Number.isFinite(userId)) {
2179
+ return { ok: false, error: 'user_id 不是有效 QQ 号' }
2180
+ }
2181
+ if (!args || typeof args.approve !== 'boolean') return { ok: false, error: 'approve 必须明确传 true 或 false' }
2182
+ const approve = args.approve
2183
+ let flag = clean(args && args.flag)
2184
+ let nickname = ''
2185
+ if (!flag && userId) {
2186
+ if (kind === 'friend') {
2187
+ const hit = [...friendRequestInbox.values()]
2188
+ .filter((r) => String(r.user_id) === String(userId))
2189
+ .sort((a, b) => (b.at || 0) - (a.at || 0))[0]
2190
+ if (hit) {
2191
+ flag = hit.flag
2192
+ nickname = hit.nickname
2193
+ }
2194
+ } else {
2195
+ try {
2196
+ const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
2197
+ const hit = (Array.isArray(data) ? data : []).find((x) => String((x && x.user_id) || '') === String(userId))
2198
+ if (hit) {
2199
+ flag = String((hit && hit.uid) || '')
2200
+ nickname = clean(hit && hit.nick)
2201
+ }
2202
+ } catch (error) {}
2203
+ }
2204
+ }
2205
+ if (!flag) {
2206
+ return { ok: false, error: '没有找到要处理的申请;先用 qq_list_friend_requests 拿 flag' }
2207
+ }
2208
+ try {
2209
+ if (kind === 'doubt') {
2210
+ await onebot('set_doubt_friends_add_request', { flag, approve }, cfg)
2211
+ } else {
2212
+ await onebot('set_friend_add_request', { flag, approve }, cfg)
2213
+ friendRequestInbox.delete(flag)
2214
+ }
2215
+ return { ok: true, kind, approved: approve, flag, user_id: userId || null, nickname }
2216
+ } catch (error) {
2217
+ return { ok: false, error: String((error && error.message) || error) }
2218
+ }
2219
+ },
2220
+ render(_args, v) {
2221
+ if (!v || !v.ok) return [{ type: 'text', text: '处理好友申请失败:' + String((v && v.error) || '未知错误') }]
2222
+ const who = v.nickname ? `${v.nickname}(${v.user_id || ''})` : (v.user_id ? 'QQ ' + v.user_id : '这条申请')
2223
+ return [{ type: 'text', text: `已${v.approved ? '同意' : '拒绝'}${who}的好友申请。` }]
2224
+ },
2225
+ }
2226
+ }
2227
+
2228
+ export function startFriendRequestWatcher(ctx) {
2229
+ startWsWatcher(ctx, '好友申请', () => true, (ev) => {
2230
+ if (ev && ev.post_type === 'request' && ev.request_type === 'friend') rememberFriendRequest(ev)
2231
+ })
2232
+ }
2233
+
2234
+
2235
+ // ---------- QQ 空间(说说) ----------
2236
+ // SnowLuma 的「空间」扩展:send_qzone_msg / get_qzone_msg_list / get_qzone_feeds /
2237
+ // get_qzone_msg_detail / like_qzone / unlike_qzone / comment_qzone /
2238
+ // delete_qzone_msg / set_qzone_msg_right / set_qzone_ban。
2239
+ // 图片统一走 imageSourceOf:http(s)、base64://、file://(本机路径会转成 file://)。
2240
+ const QZONE_VISIBILITY = { public: 1, friends: 4, partial: 16, private: 64, exclude: 128 }
2241
+ const QZONE_VISIBILITY_NAMES = {
2242
+ 1: '所有人可见',
2243
+ 4: '好友可见',
2244
+ 16: '部分好友可见',
2245
+ 64: '仅自己可见',
2246
+ 128: '部分好友不可见',
2247
+ }
2248
+
2249
+ function qzoneRightOf(value) {
2250
+ const key = String(value == null ? '' : value).trim()
2251
+ if (QZONE_VISIBILITY[key] != null) return QZONE_VISIBILITY[key]
2252
+ const n = Number(key)
2253
+ if (Number.isInteger(n) && QZONE_VISIBILITY_NAMES[n]) return n
2254
+ return 0
2255
+ }
2256
+
2257
+ function plainText(s) {
2258
+ return clean(String(s || '')
2259
+ .replace(/<br\s*\/?>/gi, ' ')
2260
+ .replace(/<[^>]*>/g, ' ')
2261
+ .replace(/&nbsp;/gi, ' ')
2262
+ .replace(/&amp;/gi, '&')
2263
+ .replace(/&lt;/gi, '<')
2264
+ .replace(/&gt;/gi, '>')
2265
+ .replace(/&quot;/gi, '"')
2266
+ .replace(/&#39;/gi, "'"))
2267
+ }
2268
+
2269
+ function qzoneImagesOf(list) {
2270
+ return (Array.isArray(list) ? list : [])
2271
+ .map((x) => {
2272
+ const url = String(x || '').trim()
2273
+ return url.startsWith('//') ? 'https:' + url : url
2274
+ })
2275
+ .filter((x) => /^https?:/i.test(x))
2276
+ }
2277
+
2278
+ async function admitQzoneImages(admitImages, cfg, items) {
2279
+ if (typeof admitImages !== 'function') return []
2280
+ const picked = []
2281
+ for (const item of Array.isArray(items) ? items : []) {
2282
+ if (picked.length >= 4) break
2283
+ if (!item || !/^https?:/i.test(String(item.url || ''))) continue
2284
+ picked.push(item)
2285
+ }
2286
+ if (!picked.length) return []
2287
+ return admitImages(picked.map((item) => ({
2288
+ data: { url: item.url },
2289
+ cfg,
2290
+ name: item.name || 'qq-qzone.jpg',
2291
+ caption: item.caption || 'QQ 空间图片',
2292
+ })))
2293
+ }
2294
+
2295
+ function qzonePostOf(x, max = 500) {
2296
+ const time = Number((x && x.time) || 0)
2297
+ return {
2298
+ tid: String((x && x.tid) || ''),
2299
+ content: clean((x && x.content) || plainText(x && x.html)).slice(0, max),
2300
+ time,
2301
+ t: fmtTime(time),
2302
+ comment_num: Number((x && (x.comment_num != null ? x.comment_num : x.comment_count)) || 0),
2303
+ is_private: !!(x && x.is_private),
2304
+ images: qzoneImagesOf(x && x.images),
2305
+ }
2306
+ }
2307
+
2308
+ // 部分 SnowLuma 版本只会把 feed 的 HTML 透传回来,tid/content 仍是 null。
2309
+ // 说说(appid=311)的 tid 在 HTML 的 tid="..." / data-tid="..." 里,
2310
+ // 取出来就能继续用详情、点赞和评论;补不回来的 feed 保持只读。
2311
+ function qzoneTidOf(x) {
2312
+ const direct = x && x.tid != null ? String(x.tid).trim() : ''
2313
+ if (direct) return direct
2314
+ if (Number(x && x.appid) !== 311) return ''
2315
+ const html = String((x && x.html) || '')
2316
+ const hit = html.match(/(?:data-)?tid[="'\s:]+([0-9a-fA-F]{16,64})/)
2317
+ return hit ? hit[1] : ''
2318
+ }
2319
+
2320
+ export function makeListQzoneTool(admitImages) {
2321
+ return {
2322
+ name: 'qq_list_qzone',
2323
+ description:
2324
+ '看 QQ 空间说说。默认看自己的说说;传 target_uin 看别人的;feeds=true 看好友动态(可用 cursor 翻页)。' +
2325
+ '好友动态里只保留说说(appid=311);如果 SnowLuma 没从 HTML 里解析出 tid,会尽量自己补回来,补不回来的只能看不能互动。' +
2326
+ '返回 tid、正文、时间、评论数和图片;tid 可以传给 qq_get_qzone_post / qq_manage_qzone。只读。',
2327
+ parameters: {
2328
+ type: 'object',
2329
+ properties: {
2330
+ feeds: { type: 'boolean', description: 'true = 看好友动态(get_qzone_feeds),默认 false' },
2331
+ target_uin: { type: 'number', description: '看谁的说说;不传就是自己(feeds 模式忽略)' },
2332
+ pos: { type: 'number', description: '从第几条开始,默认 0(feeds 模式忽略)' },
2333
+ count: { type: 'number', description: '本页数量,默认 10,最多 50' },
2334
+ cursor: { type: 'string', description: '好友动态翻页游标,传上一页返回的 next_cursor' },
2335
+ include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
2336
+ },
2337
+ additionalProperties: false,
2338
+ },
2339
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2340
+ async execute(args) {
2341
+ const cfg = loadCfg()
2342
+ const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 10), 50))
2343
+ const withImages = !(args && args.include_images === false)
2344
+ try {
2345
+ if (args && args.feeds === true) {
2346
+ const body = { count }
2347
+ const cursor = String((args && args.cursor) || '').trim()
2348
+ if (cursor) body.cursor = cursor
2349
+ const data = await onebot('get_qzone_feeds', body, cfg)
2350
+ const rawFeeds = Array.isArray(data && data.feeds) ? data.feeds : []
2351
+ const feeds = rawFeeds.map((x) => {
2352
+ const post = qzonePostOf(x)
2353
+ const tid = qzoneTidOf(x)
2354
+ return {
2355
+ ...post,
2356
+ tid,
2357
+ actionable: !!tid,
2358
+ appid: Number((x && x.appid) || 0),
2359
+ uin: String((x && x.uin) || ''),
2360
+ name: clean(x && x.nickname),
2361
+ like_count: Number((x && x.like_count) || 0),
2362
+ content_complete: !!(x && x.content_complete),
2363
+ }
2364
+ }).filter((x) => x.appid === 311 || x.tid)
2365
+ const imageItems = []
2366
+ for (const f of feeds) {
2367
+ for (const url of f.images) {
2368
+ imageItems.push({
2369
+ url,
2370
+ name: `qq-qzone-${f.tid}.jpg`,
2371
+ caption: `好友动态 ${f.tid} 的图片(${f.name || f.uin || '好友'})`,
2372
+ })
2373
+ }
2374
+ }
2375
+ const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
2376
+ return {
2377
+ ok: true,
2378
+ mode: 'feeds',
2379
+ count: feeds.length,
2380
+ feeds,
2381
+ skipped: rawFeeds.length - feeds.length,
2382
+ has_more: !!(data && data.has_more),
2383
+ next_cursor: String((data && data.next_cursor) || ''),
2384
+ images,
2385
+ }
2386
+ }
2387
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2388
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2389
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2390
+ }
2391
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2392
+ const body = {
2393
+ pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)),
2394
+ num: count,
2395
+ }
2396
+ if (targetUin) body.target_uin = targetUin
2397
+ const data = await onebot('get_qzone_msg_list', body, cfg)
2398
+ const raw = listOf(data, 'msglist')
2399
+ const posts = raw.map((x) => qzonePostOf(x)).filter((x) => x.tid)
2400
+ const imageItems = []
2401
+ for (const p of posts) {
2402
+ for (const url of p.images) {
2403
+ imageItems.push({
2404
+ url,
2405
+ name: `qq-qzone-${p.tid}.jpg`,
2406
+ caption: `说说 ${p.tid} 的图片${p.content ? '(' + p.content.slice(0, 30) + ')' : ''}`,
2407
+ })
2408
+ }
2409
+ }
2410
+ const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
2411
+ return {
2412
+ ok: true,
2413
+ mode: 'list',
2414
+ target_uin: targetUin || null,
2415
+ total: Number((data && data.total) || posts.length),
2416
+ count: posts.length,
2417
+ posts,
2418
+ images,
2419
+ }
2420
+ } catch (error) {
2421
+ return { ok: false, error: String((error && error.message) || error) }
2422
+ }
2423
+ },
2424
+ render(_args, v) {
2425
+ if (!v || !v.ok) return [{ type: 'text', text: '看 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
2426
+ const blocks = []
2427
+ if (v.mode === 'feeds') {
2428
+ if (!v.feeds.length) {
2429
+ return [{ type: 'text', text: '好友动态里现在没有能识别的说说。' + (v.skipped ? `(跳过 ${v.skipped} 条非说说动态)` : '') }]
2430
+ }
2431
+ const lines = [`好友动态(${v.feeds.length} 条${v.has_more ? ',还有更多' : ''}):`]
2432
+ for (const f of v.feeds) {
2433
+ const tag = f.tid ? `[tid=${f.tid}]` : '[没有 tid,只能看]'
2434
+ lines.push(`- ${f.name || f.uin || '好友'}(${f.uin || '?'}) ${tag} ${f.t} 赞 ${f.like_count} 评论 ${f.comment_num}`)
2435
+ if (f.content) lines.push(' ' + f.content.split('\n').join('\n '))
2436
+ }
2437
+ if (v.next_cursor) lines.push(`翻下一页:feeds=true,cursor=${v.next_cursor}`)
2438
+ if (v.skipped) lines.push(`另有 ${v.skipped} 条非说说动态(相册、广告等)没有列出。`)
2439
+ if (v.feeds.some((f) => !f.tid)) lines.push('没有 tid 的动态不能直接点赞/评论,可以配合 qq_get_qzone_post 或去 QQ 客户端看。')
2440
+ blocks.push({ type: 'text', text: lines.join('\n') })
2441
+ } else {
2442
+ if (!v.posts.length) return [{ type: 'text', text: '没有看到说说。' }]
2443
+ const where = v.target_uin ? `QQ ${v.target_uin} 的空间` : '你的空间'
2444
+ const lines = [`${where}(共 ${v.total} 条,本页 ${v.count} 条):`]
2445
+ for (const p of v.posts) {
2446
+ lines.push(`- [tid=${p.tid}] ${p.t} 评论 ${p.comment_num}${p.is_private ? '(仅自己可见)' : ''}`)
2447
+ if (p.content) lines.push(' ' + p.content.split('\n').join('\n '))
2448
+ }
2449
+ lines.push('看评论详情用 qq_get_qzone_post;发新说说用 qq_send_qzone_post。')
2450
+ blocks.push({ type: 'text', text: lines.join('\n') })
2451
+ }
2452
+ for (const img of Array.isArray(v.images) ? v.images : []) {
2453
+ if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
2454
+ if (img && img.block) blocks.push(img.block)
2455
+ }
2456
+ return blocks
2457
+ },
2458
+ }
2459
+ }
2460
+
2461
+ export function makeGetQzonePostTool(admitImages) {
2462
+ return {
2463
+ name: 'qq_get_qzone_post',
2464
+ description:
2465
+ '看一条说说的详情和评论(SnowLuma get_qzone_msg_detail)。tid 从 qq_list_qzone 拿。' +
2466
+ '返回评论的 comment_id 和 uin;回复时传给 qq_manage_qzone:' +
2467
+ '回复一级评论传 comment_id + comment_uin,回复楼中楼再传 reply_uin + reply_name。只读。',
2468
+ parameters: {
2469
+ type: 'object',
2470
+ properties: {
2471
+ tid: { type: 'string', description: '说说 tid' },
2472
+ target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
2473
+ pos: { type: 'number', description: '从第几条评论开始,默认 0' },
2474
+ count: { type: 'number', description: '看多少条一级评论,默认 20,最多 50' },
2475
+ include_images: { type: 'boolean', description: '详情里带图片时要不要看图,默认 true' },
2476
+ },
2477
+ required: ['tid'],
2478
+ additionalProperties: false,
2479
+ },
2480
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2481
+ async execute(args) {
2482
+ const cfg = loadCfg()
2483
+ const tid = args && args.tid != null ? String(args.tid).trim() : ''
2484
+ if (!tid) return { ok: false, error: 'tid 不能为空' }
2485
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2486
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2487
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2488
+ }
2489
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2490
+ const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 20), 50))
2491
+ try {
2492
+ const body = { tid, pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)), num: count }
2493
+ if (targetUin) body.target_uin = targetUin
2494
+ const data = await onebot('get_qzone_msg_detail', body, cfg)
2495
+ const comments = (Array.isArray(data && data.comments) ? data.comments : []).map((c) => ({
2496
+ uin: String((c && (c.uin || c.user_id)) || ''),
2497
+ name: clean(c && (c.name || c.nickname)),
2498
+ time: Number((c && c.time) || 0),
2499
+ t: fmtTime(c && c.time),
2500
+ content: cleanMultiline(c && c.content, 1000),
2501
+ comment_id: c && c.comment_id != null ? String(c.comment_id) : '',
2502
+ replies: (Array.isArray(c && c.replies) ? c.replies : []).map((r) => ({
2503
+ uin: String((r && (r.uin || r.user_id)) || ''),
2504
+ name: clean(r && (r.name || r.nickname)),
2505
+ time: Number((r && r.time) || 0),
2506
+ t: fmtTime(r && r.time),
2507
+ content: cleanMultiline(r && r.content, 1000),
2508
+ comment_id: r && r.comment_id != null ? String(r.comment_id) : '',
2509
+ parent_comment_id: r && r.parent_comment_id != null ? String(r.parent_comment_id) : '',
2510
+ parent_comment_uin: r && r.parent_comment_uin != null ? String(r.parent_comment_uin) : '',
2511
+ })),
2512
+ }))
2513
+ const post = {
2514
+ tid: String((data && data.tid) || tid),
2515
+ content: cleanMultiline(data && data.content, 4000),
2516
+ comment_num: Number((data && data.comment_num) || 0),
2517
+ comments,
2518
+ images: qzoneImagesOf(data && data.images),
2519
+ }
2520
+ let images = []
2521
+ if (!(args && args.include_images === false) && post.images.length) {
2522
+ images = await admitQzoneImages(admitImages, cfg, post.images.map((url) => ({
2523
+ url,
2524
+ name: `qq-qzone-${post.tid}.jpg`,
2525
+ caption: `说说 ${post.tid} 的图片`,
2526
+ })))
2527
+ }
2528
+ return { ok: true, post, images }
2529
+ } catch (error) {
2530
+ return { ok: false, error: String((error && error.message) || error) }
2531
+ }
2532
+ },
2533
+ render(_args, v) {
2534
+ if (!v || !v.ok) return [{ type: 'text', text: '看说说详情失败:' + String((v && v.error) || '未知错误') }]
2535
+ const p = v.post || {}
2536
+ const lines = [`说说 ${p.tid}:`, p.content || '(没有正文)']
2537
+ lines.push(`评论 ${p.comment_num || 0} 条:`)
2538
+ if (!p.comments.length) lines.push('(还没有评论)')
2539
+ for (const c of p.comments) {
2540
+ lines.push(`- ${c.name || c.uin}(${c.uin}) [comment_id=${c.comment_id}] ${c.t}:${c.content || '(空)'}`)
2541
+ for (const r of c.replies) {
2542
+ lines.push(` ↳ ${r.name || r.uin}(${r.uin}) [comment_id=${r.comment_id} parent=${r.parent_comment_id}] ${r.t}:${r.content || '(空)'}`)
2543
+ }
2544
+ }
2545
+ if (p.comments.length) {
2546
+ lines.push('回复一级评论:qq_manage_qzone action=comment,传 comment_id + comment_uin=该评论 uin;' +
2547
+ '回复楼中楼再加 reply_uin + reply_name,comment_id 仍用一级评论的 id(不要用二级回复自己的 id)。')
2548
+ }
2549
+ const blocks = [{ type: 'text', text: lines.join('\n') }]
2550
+ for (const img of Array.isArray(v.images) ? v.images : []) {
2551
+ if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
2552
+ if (img && img.block) blocks.push(img.block)
2553
+ }
2554
+ return blocks
2555
+ },
2556
+ }
2557
+ }
2558
+
2559
+ export function makeSendQzonePostTool() {
2560
+ return {
2561
+ name: 'qq_send_qzone_post',
2562
+ description:
2563
+ '发一条 QQ 空间说说(SnowLuma send_qzone_msg)。content 是正文;' +
2564
+ 'images 是图片(http(s)、base64://、本机路径,最多 9 张),也可以用 message_id 从消息里取图。' +
2565
+ 'visibility:public=所有人,friends=好友,partial=指定好友可见,private=仅自己,exclude=指定好友不可见;' +
2566
+ 'partial/exclude 要传 target_uins。发说说对外可见,只有用户明确要求时才用。',
2567
+ parameters: {
2568
+ type: 'object',
2569
+ properties: {
2570
+ content: { type: 'string', description: '说说正文,不要 Markdown' },
2571
+ images: { type: 'array', items: { type: 'string' }, description: '图片列表,最多 9 张' },
2572
+ message_id: { type: 'string', description: '从这条消息里取图片一起发' },
2573
+ image_indexes: { type: 'array', items: { type: 'number' }, description: 'message_id 里要哪几张图片(从 0 开始);不传就全取' },
2574
+ visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: '可见范围,默认 public' },
2575
+ target_uins: { type: 'array', items: { type: 'number' }, description: 'partial/exclude 时的 QQ 号列表' },
2576
+ },
2577
+ required: ['content'],
2578
+ additionalProperties: false,
2579
+ },
2580
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2581
+ async execute(args) {
2582
+ const cfg = loadCfg()
2583
+ const content = cleanMultiline(args && args.content, 2000)
2584
+ if (!content) return { ok: false, error: '说说正文是空的' }
2585
+ const visibilityKey = String((args && args.visibility) || 'public')
2586
+ const ugcRight = qzoneRightOf(visibilityKey)
2587
+ if (!ugcRight) return { ok: false, error: 'visibility 只能是 public/friends/partial/private/exclude' }
2588
+ const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
2589
+ .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
2590
+ if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
2591
+ return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
2592
+ }
2593
+ try {
2594
+ const images = (Array.isArray(args && args.images) ? args.images : [])
2595
+ .map(imageSourceOf).filter(Boolean)
2596
+ const messageId = args && args.message_id
2597
+ if (messageId != null && messageId !== '') {
2598
+ if (images.length >= 9) return { ok: false, error: '最多 9 张图片' }
2599
+ const got = await imageSourcesFromMessage(messageId, args.image_indexes, cfg, 9 - images.length)
2600
+ if (!got.sources.length) {
2601
+ return {
2602
+ ok: false,
2603
+ error: got.total
2604
+ ? `选中的图片序号没有匹配到(这条消息共 ${got.total} 张)`
2605
+ : '这条消息里没有图片',
2606
+ }
2607
+ }
2608
+ images.push(...got.sources)
2609
+ }
2610
+ if (images.length > 9) return { ok: false, error: '最多 9 张图片' }
2611
+ const body = { content, ugc_right: ugcRight }
2612
+ if (images.length) body.images = images
2613
+ if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
2614
+ const data = await onebot('send_qzone_msg', body, cfg)
2615
+ return {
2616
+ ok: true,
2617
+ tid: String((data && data.tid) || ''),
2618
+ time: Number((data && data.time) || 0),
2619
+ visibility: QZONE_VISIBILITY_NAMES[ugcRight],
2620
+ image_count: images.length,
2621
+ }
2622
+ } catch (error) {
2623
+ return { ok: false, error: String((error && error.message) || error) }
2624
+ }
2625
+ },
2626
+ render(_args, v) {
2627
+ if (!v || !v.ok) return [{ type: 'text', text: '发说说失败:' + String((v && v.error) || '未知错误') }]
2628
+ const extra = v.image_count ? `,${v.image_count} 张图` : ''
2629
+ return [{ type: 'text', text: `说说已发布(tid ${v.tid || '?'}${extra},${v.visibility || '所有人可见'})。` }]
2630
+ },
2631
+ }
2632
+ }
2633
+
2634
+ export function makeManageQzoneTool() {
2635
+ return {
2636
+ name: 'qq_manage_qzone',
2637
+ description:
2638
+ '管理 QQ 空间说说。action:\n' +
2639
+ '- like / unlike:点赞或取消赞,需要 tid(abstime 传列表里的 time 更可靠)\n' +
2640
+ '- comment:评论或回复,需要 tid + content;回复一级评论再传 comment_id + comment_uin,' +
2641
+ '回复楼中楼再传 reply_uin + reply_name;只有普通评论能带图\n' +
2642
+ '- delete:删除自己空间的一条说说,需要 tid + confirm=true,不可撤销\n' +
2643
+ '- set_right:改自己空间一条说说的可见范围,需要 tid + visibility(partial/exclude 再传 target_uins)\n' +
2644
+ '- ban / unban:拉黑或解除拉黑某人的空间访问,需要 user_id',
2645
+ parameters: {
2646
+ type: 'object',
2647
+ properties: {
2648
+ action: {
2649
+ type: 'string',
2650
+ enum: ['like', 'unlike', 'comment', 'delete', 'set_right', 'ban', 'unban'],
2651
+ description: '要做什么',
2652
+ },
2653
+ tid: { type: 'string', description: '说说 tid;like/unlike/comment/delete/set_right 需要' },
2654
+ target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
2655
+ abstime: { type: 'number', description: '说说发表时间(unix 秒),点赞时传更可靠' },
2656
+ content: { type: 'string', description: '评论内容(action=comment)' },
2657
+ images: { type: 'array', items: { type: 'string' }, description: '评论图片(普通评论可选,最多 9 张)' },
2658
+ comment_id: { type: 'string', description: '要回复的一级评论 ID(评论详情里的 comment_id)' },
2659
+ comment_uin: { type: 'number', description: '一级评论作者 QQ 号' },
2660
+ reply_uin: { type: 'number', description: '楼中楼里要回复的用户 QQ 号' },
2661
+ reply_name: { type: 'string', description: '楼中楼里要回复的用户昵称' },
2662
+ visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: 'set_right 的新可见范围' },
2663
+ target_uins: { type: 'array', items: { type: 'number' }, description: 'set_right 时 partial/exclude 的 QQ 号列表' },
2664
+ user_id: { type: 'number', description: 'ban/unban 的目标 QQ 号' },
2665
+ confirm: { type: 'boolean', description: 'delete 时必须显式传 true' },
2666
+ },
2667
+ required: ['action'],
2668
+ additionalProperties: false,
2669
+ },
2670
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
2671
+ async execute(args) {
2672
+ const cfg = loadCfg()
2673
+ const action = String((args && args.action) || '')
2674
+ const tid = args && args.tid != null ? String(args.tid).trim() : ''
2675
+ const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
2676
+ if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
2677
+ return { ok: false, error: 'target_uin 不是有效 QQ 号' }
2678
+ }
2679
+ if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2680
+ const needTid = ['like', 'unlike', 'comment', 'delete', 'set_right']
2681
+ if (needTid.includes(action) && !tid) return { ok: false, error: action + ' 需要 tid' }
2682
+ try {
2683
+ if (action === 'like' || action === 'unlike') {
2684
+ const body = { tid }
2685
+ if (targetUin) body.target_uin = targetUin
2686
+ const abstime = Number(args && args.abstime)
2687
+ if (Number.isFinite(abstime) && abstime > 0) body.abstime = Math.floor(abstime)
2688
+ await onebot(action === 'like' ? 'like_qzone' : 'unlike_qzone', body, cfg)
2689
+ return { ok: true, action, tid, liked: action === 'like' }
2690
+ }
2691
+ if (action === 'comment') {
2692
+ const content = cleanMultiline(args && args.content, 1000)
2693
+ if (!content) return { ok: false, error: '评论内容是空的' }
2694
+ const images = (Array.isArray(args && args.images) ? args.images : [])
2695
+ .map(imageSourceOf).filter(Boolean)
2696
+ if (images.length > 9) return { ok: false, error: '评论最多 9 张图片' }
2697
+ const parentId = args && args.comment_id != null ? String(args.comment_id).trim() : ''
2698
+ const hasParentUin = args && args.comment_uin != null
2699
+ const parentUin = hasParentUin ? Number(args.comment_uin) : 0
2700
+ const hasReplyUin = args && args.reply_uin != null
2701
+ const replyUin = hasReplyUin ? Number(args.reply_uin) : 0
2702
+ const replyName = clean(args && args.reply_name)
2703
+ if (parentId && (!hasParentUin || !Number.isInteger(parentUin) || parentUin <= 0)) {
2704
+ return { ok: false, error: 'comment_id 和 comment_uin 要一起传,comment_uin 是正整数 QQ 号' }
2705
+ }
2706
+ if (hasParentUin && !parentId) {
2707
+ return { ok: false, error: 'comment_uin 要配合 comment_id 一起传' }
2708
+ }
2709
+ if (hasReplyUin && (!Number.isInteger(replyUin) || replyUin <= 0)) {
2710
+ return { ok: false, error: 'reply_uin 不是有效 QQ 号' }
2711
+ }
2712
+ if (hasReplyUin && (!parentId || !replyName)) {
2713
+ return { ok: false, error: '回复楼中楼需要 comment_id + comment_uin + reply_uin + reply_name' }
2714
+ }
2715
+ if (parentId && images.length) return { ok: false, error: '回复评论只能发文字,不能带图' }
2716
+ const body = { tid, content }
2717
+ if (targetUin) body.target_uin = targetUin
2718
+ if (images.length) body.images = images
2719
+ if (parentId) {
2720
+ body.comment_id = parentId
2721
+ body.comment_uin = parentUin
2722
+ }
2723
+ if (replyUin) {
2724
+ body.reply_uin = replyUin
2725
+ body.reply_name = replyName
2726
+ }
2727
+ const data = await onebot('comment_qzone', body, cfg)
2728
+ return {
2729
+ ok: true,
2730
+ action,
2731
+ tid,
2732
+ comment_id: data && data.comment_id != null ? String(data.comment_id) : '',
2733
+ parent_comment_id: data && data.parent_comment_id != null ? String(data.parent_comment_id) : '',
2734
+ parent_comment_uin: Number((data && data.parent_comment_uin) || 0),
2735
+ }
2736
+ }
2737
+ if (action === 'delete') {
2738
+ if (!args || args.confirm !== true) {
2739
+ return { ok: false, error: '删说说不可撤销,需要 confirm=true 才会执行' }
2740
+ }
2741
+ await onebot('delete_qzone_msg', { tid }, cfg)
2742
+ return { ok: true, action, tid, deleted: true }
2743
+ }
2744
+ if (action === 'set_right') {
2745
+ const ugcRight = qzoneRightOf(args && args.visibility)
2746
+ if (!ugcRight) return { ok: false, error: 'set_right 需要 visibility(public/friends/partial/private/exclude)' }
2747
+ const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
2748
+ .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
2749
+ if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
2750
+ return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
2751
+ }
2752
+ const body = { tid, ugc_right: ugcRight }
2753
+ if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
2754
+ const data = await onebot('set_qzone_msg_right', body, cfg)
2755
+ return {
2756
+ ok: true,
2757
+ action,
2758
+ tid,
2759
+ ugc_right: Number((data && data.ugc_right) || ugcRight),
2760
+ visibility: QZONE_VISIBILITY_NAMES[Number((data && data.ugc_right) || ugcRight)],
2761
+ }
2762
+ }
2763
+ if (action === 'ban' || action === 'unban') {
2764
+ const userId = Number(args && args.user_id)
2765
+ if (!Number.isFinite(userId) || !userId) return { ok: false, error: 'ban/unban 需要 user_id' }
2766
+ if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
2767
+ await onebot('set_qzone_ban', { user_id: userId, enable: action === 'ban' }, cfg)
2768
+ return { ok: true, action, user_id: userId, banned: action === 'ban' }
2769
+ }
2770
+ return { ok: false, error: '不认识的 action:' + action }
2771
+ } catch (error) {
2772
+ return { ok: false, error: String((error && error.message) || error) }
2773
+ }
2774
+ },
2775
+ render(_args, v) {
2776
+ if (!v || !v.ok) return [{ type: 'text', text: '操作 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
2777
+ if (v.action === 'like' || v.action === 'unlike') {
2778
+ return [{ type: 'text', text: `${v.liked ? '已点赞' : '已取消赞'}(tid ${v.tid})。` }]
2779
+ }
2780
+ if (v.action === 'comment') {
2781
+ return [{ type: 'text', text: `评论已发(tid ${v.tid},comment_id ${v.comment_id || '?'})。` }]
2782
+ }
2783
+ if (v.action === 'delete') {
2784
+ return [{ type: 'text', text: `已删除说说 ${v.tid}。` }]
2785
+ }
2786
+ if (v.action === 'set_right') {
2787
+ return [{ type: 'text', text: `说说 ${v.tid} 的可见范围已改为「${v.visibility || v.ugc_right}」。` }]
2788
+ }
2789
+ if (v.action === 'ban' || v.action === 'unban') {
2790
+ return [{ type: 'text', text: `已${v.banned ? '拉黑' : '解除拉黑'} QQ ${v.user_id} 的空间访问。` }]
2791
+ }
2792
+ return [{ type: 'text', text: 'QQ 空间操作完成。' }]
2793
+ },
2794
+ }
2795
+ }
2796
+
2797
+
1537
2798
  // ---------- 当前活着的工具组件 ----------
1538
2799
  // @ 提醒的提示词只提还开着的工具,免得模型去调一个被关掉的组件。
1539
2800
  const activeCounts = new Map()
@@ -2004,14 +3265,32 @@ export function mountAll(ctx) {
2004
3265
  const faceList = mountOneTool(ctx, makeListFacesTool(admitImages))
2005
3266
  const faceFav = mountOneTool(ctx, makeFavoriteFaceTool())
2006
3267
  const poke = mountOneTool(ctx, makePokeTool(startObservation))
3268
+ const avatar = mountOneTool(ctx, makeSetAvatarTool())
2007
3269
  const friends = mountOneTool(ctx, makeListFriendsTool())
2008
3270
  const privateHistory = mountOneTool(ctx, makePrivateHistoryTool(admitImages))
2009
3271
  const privateSend = mountOneTool(ctx, makeSendPrivateTool())
3272
+ const friendInfo = mountOneTool(ctx, makeGetFriendInfoTool())
3273
+ const friendCategories = mountOneTool(ctx, makeListFriendCategoriesTool())
3274
+ const friendUpdate = mountOneTool(ctx, makeUpdateFriendTool())
3275
+ const friendDelete = mountOneTool(ctx, makeDeleteFriendTool())
3276
+ const friendLike = mountOneTool(ctx, makeSendLikeTool())
3277
+ const friendRequests = mountOneTool(ctx, makeListFriendRequestsTool())
3278
+ const friendHandle = mountOneTool(ctx, makeHandleFriendRequestTool())
3279
+ const qzoneList = mountOneTool(ctx, makeListQzoneTool(admitImages))
3280
+ const qzoneDetail = mountOneTool(ctx, makeGetQzonePostTool(admitImages))
3281
+ const qzoneSend = mountOneTool(ctx, makeSendQzonePostTool())
3282
+ const qzoneManage = mountOneTool(ctx, makeManageQzoneTool())
2010
3283
  startMentionPolling(ctx)
2011
3284
  startPokeWatcher(ctx)
2012
3285
  startPrivateWatcher(ctx, admitImages)
3286
+ startFriendRequestWatcher(ctx)
2013
3287
  return {
2014
- tools: [list, history, members, send, face, faceList, faceFav, poke, friends, privateHistory, privateSend],
3288
+ tools: [
3289
+ list, history, members, send, face, faceList, faceFav, poke, avatar,
3290
+ friends, privateHistory, privateSend,
3291
+ friendInfo, friendCategories, friendUpdate, friendDelete, friendLike, friendRequests, friendHandle,
3292
+ qzoneList, qzoneDetail, qzoneSend, qzoneManage,
3293
+ ],
2015
3294
  startObservation,
2016
3295
  }
2017
3296
  }