dsh-qq-tools 0.8.2 → 0.9.1
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 +77 -14
- package/cordis.patch.yml +10 -1
- package/lib/shared.js +1333 -39
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +14 -2
- package/tools/qq-friend-management/index.js +28 -0
- package/tools/qq-friend-management/locale/en.json +6 -0
- package/tools/qq-friend-management/locale/zh.json +6 -0
- package/tools/qq-qzone/index.js +22 -0
- package/tools/qq-qzone/locale/en.json +6 -0
- package/tools/qq-qzone/locale/zh.json +6 -0
- package/tools/qq-send-group-message/locale/en.json +1 -1
- package/tools/qq-send-group-message/locale/zh.json +1 -1
- package/tools/qq-set-avatar/index.js +8 -0
- package/tools/qq-set-avatar/locale/en.json +6 -0
- package/tools/qq-set-avatar/locale/zh.json +6 -0
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 里的
|
|
5
|
-
//
|
|
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
|
// · 群聊不逐条转发、不自动回;由「主动搭话」或「有人 @」触发,
|
|
@@ -154,6 +157,15 @@ function clean(s) {
|
|
|
154
157
|
return String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
155
158
|
}
|
|
156
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
|
+
|
|
157
169
|
function ensureCfg() {
|
|
158
170
|
try {
|
|
159
171
|
if (existsSync(CFG_PATH)) return
|
|
@@ -430,6 +442,14 @@ function senderId(m) {
|
|
|
430
442
|
return clean(s.user_id || (m && m.user_id) || '')
|
|
431
443
|
}
|
|
432
444
|
|
|
445
|
+
// 唤醒提示里的消息行:和戳一戳一致,固定带上昵称和 QQ 号。
|
|
446
|
+
// 有 message_id 时附在末尾,模型可以把它传给 reply_to 做引用。
|
|
447
|
+
export function formatIncomingLine(name, userId, text, messageId) {
|
|
448
|
+
const who = clean(name) || 'QQ'
|
|
449
|
+
const qq = String(userId || '?')
|
|
450
|
+
return `${who}(${qq}):${text || '(空)'}` + (messageId != null ? `(消息 #${messageId})` : '')
|
|
451
|
+
}
|
|
452
|
+
|
|
433
453
|
|
|
434
454
|
function renderTool(_args, v) {
|
|
435
455
|
if (!v || !v.ok) return [{ type: 'text', text: 'QQ 工具失败:' + String((v && v.error) || '未知错误') }]
|
|
@@ -905,15 +925,19 @@ export function makeSendTool(onSent) {
|
|
|
905
925
|
name: 'qq_send_group_message',
|
|
906
926
|
description:
|
|
907
927
|
'在指定群里发一条消息。发之前先确认群号;只发到允许的群。' +
|
|
928
|
+
'reply_to 传消息前面的 #编号 可以引用那条消息;at_user_ids 传 QQ 号可以 @ 人,at_all=true 是 @全体成员。' +
|
|
908
929
|
'普通闲聊不要用 Markdown;一句话短一点,像群友随手打的。' +
|
|
909
930
|
'不想说话就不要调用这个工具;调用后不要再重复说自己发了什么。',
|
|
910
931
|
parameters: {
|
|
911
932
|
type: 'object',
|
|
912
933
|
properties: {
|
|
913
934
|
group_id: { type: 'number', description: '群号' },
|
|
914
|
-
message: { type: 'string', description: '
|
|
935
|
+
message: { type: 'string', description: '要发到群里的文字;只 @ 不说话时可以留空' },
|
|
936
|
+
reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
|
|
937
|
+
at_user_ids: { type: 'array', items: { type: 'number' }, description: '要 @ 的 QQ 号列表,可选' },
|
|
938
|
+
at_all: { type: 'boolean', description: 'true = @全体成员,可选' },
|
|
915
939
|
},
|
|
916
|
-
required: ['group_id'
|
|
940
|
+
required: ['group_id'],
|
|
917
941
|
additionalProperties: false,
|
|
918
942
|
},
|
|
919
943
|
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
@@ -921,24 +945,42 @@ export function makeSendTool(onSent) {
|
|
|
921
945
|
const cfg = loadCfg()
|
|
922
946
|
const groupId = Number(args && args.group_id)
|
|
923
947
|
const text = String((args && args.message) || '').trim()
|
|
948
|
+
const atIds = [...new Set(
|
|
949
|
+
(Array.isArray(args && args.at_user_ids) ? args.at_user_ids : [])
|
|
950
|
+
.map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0),
|
|
951
|
+
)].slice(0, 20)
|
|
952
|
+
const atAll = args && args.at_all === true
|
|
924
953
|
if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
|
|
925
|
-
if (!text) return { ok: false, error: '消息是空的' }
|
|
954
|
+
if (!text && !atIds.length && !atAll) return { ok: false, error: '消息是空的' }
|
|
926
955
|
if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
|
|
927
956
|
try {
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
)
|
|
957
|
+
const message = []
|
|
958
|
+
const replyTo = args && args.reply_to
|
|
959
|
+
if (replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
|
|
960
|
+
if (atAll) message.push({ type: 'at', data: { qq: 'all' } })
|
|
961
|
+
else for (const qq of atIds) message.push({ type: 'at', data: { qq: String(qq) } })
|
|
962
|
+
if (text) message.push({ type: 'text', data: { text: text.slice(0, 1000) } })
|
|
963
|
+
const data = await onebot('send_group_msg', { group_id: groupId, message }, cfg)
|
|
933
964
|
try { if (typeof onSent === 'function') onSent(groupId, '发群消息') } catch (error) {}
|
|
934
|
-
return {
|
|
965
|
+
return {
|
|
966
|
+
ok: true,
|
|
967
|
+
group_id: groupId,
|
|
968
|
+
message_id: data && data.message_id,
|
|
969
|
+
reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
|
|
970
|
+
at_user_ids: atAll ? [] : atIds,
|
|
971
|
+
at_all: atAll,
|
|
972
|
+
}
|
|
935
973
|
} catch (error) {
|
|
936
974
|
return { ok: false, error: String((error && error.message) || error) }
|
|
937
975
|
}
|
|
938
976
|
},
|
|
939
977
|
render(_args, v) {
|
|
940
978
|
if (!v || !v.ok) return [{ type: 'text', text: '发群消息失败:' + String((v && v.error) || '未知错误') }]
|
|
941
|
-
|
|
979
|
+
const extra = []
|
|
980
|
+
if (v.reply_to) extra.push('引用了 #' + v.reply_to)
|
|
981
|
+
if (v.at_all) extra.push('@全体成员')
|
|
982
|
+
else if (Array.isArray(v.at_user_ids) && v.at_user_ids.length) extra.push('@了 ' + v.at_user_ids.join('、'))
|
|
983
|
+
return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'}${extra.length ? ',' + extra.join(',') : ''})。` }]
|
|
942
984
|
},
|
|
943
985
|
}
|
|
944
986
|
}
|
|
@@ -1084,6 +1126,7 @@ export function makeSendPrivateTool() {
|
|
|
1084
1126
|
name: 'qq_send_private_message',
|
|
1085
1127
|
description:
|
|
1086
1128
|
'给某个 QQ 好友发一条私聊消息。user_id 从 qq_list_friends 或私聊唤醒提示里拿。' +
|
|
1129
|
+
'reply_to 传消息前面的 #编号 可以引用那条消息。' +
|
|
1087
1130
|
'普通闲聊不要用 Markdown;一句话短一点,像聊天一样。' +
|
|
1088
1131
|
'不想说话就不要调用;调用后不要再重复说自己发了什么。',
|
|
1089
1132
|
parameters: {
|
|
@@ -1091,7 +1134,7 @@ export function makeSendPrivateTool() {
|
|
|
1091
1134
|
properties: {
|
|
1092
1135
|
user_id: { type: 'number', description: '对方的 QQ 号' },
|
|
1093
1136
|
message: { type: 'string', description: '要发的文字,不要 Markdown' },
|
|
1094
|
-
reply_to: { type: 'string', description: '
|
|
1137
|
+
reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
|
|
1095
1138
|
group_id: { type: 'number', description: '临时会话的来源群号,可选;普通好友私聊不用传' },
|
|
1096
1139
|
},
|
|
1097
1140
|
required: ['user_id', 'message'],
|
|
@@ -1114,14 +1157,20 @@ export function makeSendPrivateTool() {
|
|
|
1114
1157
|
const body = { user_id: userId, message }
|
|
1115
1158
|
if (groupId) body.group_id = groupId
|
|
1116
1159
|
const data = await onebot('send_private_msg', body, cfg)
|
|
1117
|
-
return {
|
|
1160
|
+
return {
|
|
1161
|
+
ok: true,
|
|
1162
|
+
user_id: userId,
|
|
1163
|
+
message_id: data && data.message_id,
|
|
1164
|
+
reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
|
|
1165
|
+
}
|
|
1118
1166
|
} catch (error) {
|
|
1119
1167
|
return { ok: false, error: String((error && error.message) || error) }
|
|
1120
1168
|
}
|
|
1121
1169
|
},
|
|
1122
1170
|
render(_args, v) {
|
|
1123
1171
|
if (!v || !v.ok) return [{ type: 'text', text: '发私聊消息失败:' + String((v && v.error) || '未知错误') }]
|
|
1124
|
-
|
|
1172
|
+
const quote = v.reply_to ? ',引用了 #' + v.reply_to : ''
|
|
1173
|
+
return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'}${quote})。` }]
|
|
1125
1174
|
},
|
|
1126
1175
|
}
|
|
1127
1176
|
}
|
|
@@ -1204,6 +1253,54 @@ function imageSourceOf(raw) {
|
|
|
1204
1253
|
return s
|
|
1205
1254
|
}
|
|
1206
1255
|
|
|
1256
|
+
// 从聊天消息里取图片:get_msg → 图片段 → 可直接交给 SnowLuma 的 file 来源。
|
|
1257
|
+
// 收藏表情、换头像、发说说都用这一条路径,索引从 0 开始。
|
|
1258
|
+
async function imageSourceFromMessage(messageId, index, cfg) {
|
|
1259
|
+
const msg = await onebot('get_msg', { message_id: messageId }, cfg)
|
|
1260
|
+
const segs = Array.isArray(msg && msg.message) ? msg.message : []
|
|
1261
|
+
const images = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
|
|
1262
|
+
const idx = Number.isInteger(Number(index)) ? Math.max(0, Number(index)) : 0
|
|
1263
|
+
const pick = images[idx]
|
|
1264
|
+
if (!pick) return { error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
|
|
1265
|
+
const source = imageSourceOf(pick.data.url || pick.data.file)
|
|
1266
|
+
if (!source) return { error: '拿不到这条消息里的图片地址' }
|
|
1267
|
+
return { source, total: images.length }
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async function imageSourcesFromMessage(messageId, indexes, cfg, max = 9) {
|
|
1271
|
+
const msg = await onebot('get_msg', { message_id: messageId }, cfg)
|
|
1272
|
+
const segs = Array.isArray(msg && msg.message) ? msg.message : []
|
|
1273
|
+
const picks = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
|
|
1274
|
+
const wanted = Array.isArray(indexes) && indexes.length
|
|
1275
|
+
? indexes.map((x) => Number(x)).filter((x) => Number.isInteger(x))
|
|
1276
|
+
: picks.map((_, i) => i)
|
|
1277
|
+
const sources = []
|
|
1278
|
+
for (const i of wanted) {
|
|
1279
|
+
if (sources.length >= max) break
|
|
1280
|
+
const pick = picks[i]
|
|
1281
|
+
if (!pick) continue
|
|
1282
|
+
const source = imageSourceOf(pick.data.url || pick.data.file)
|
|
1283
|
+
if (source) sources.push(source)
|
|
1284
|
+
}
|
|
1285
|
+
return { sources, total: picks.length }
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// 工具回执里不要把 base64 原样吐回去,不然一次换头像就能塞满上下文。
|
|
1289
|
+
function describeSource(source) {
|
|
1290
|
+
const s = String(source || '')
|
|
1291
|
+
if (/^base64:\/\//i.test(s)) return 'base64://…'
|
|
1292
|
+
if (/^file:\/\//i.test(s)) {
|
|
1293
|
+
try {
|
|
1294
|
+
return decodeURIComponent(new URL(s).pathname).split('/').pop() || '本机图片'
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
return '本机图片'
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (/^https?:/i.test(s)) return s.length > 200 ? s.slice(0, 200) + '…' : s
|
|
1300
|
+
return s.slice(0, 200)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
|
|
1207
1304
|
export function makeSendFaceTool(onSent) {
|
|
1208
1305
|
return {
|
|
1209
1306
|
name: 'qq_send_face',
|
|
@@ -1493,16 +1590,9 @@ export function makeFavoriteFaceTool() {
|
|
|
1493
1590
|
}
|
|
1494
1591
|
let source = imageSourceOf(args && args.file)
|
|
1495
1592
|
if (!source && args && args.message_id != null && args.message_id !== '') {
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
const idx = Number.isInteger(Number(args.index)) ? Math.max(0, Number(args.index)) : 0
|
|
1500
|
-
const pick = images[idx]
|
|
1501
|
-
if (!pick) {
|
|
1502
|
-
return { ok: false, error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
|
|
1503
|
-
}
|
|
1504
|
-
source = imageSourceOf(pick.data.url || pick.data.file)
|
|
1505
|
-
if (!source) return { ok: false, error: '拿不到这条消息里的图片地址' }
|
|
1593
|
+
const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
|
|
1594
|
+
if (got.error) return { ok: false, error: got.error }
|
|
1595
|
+
source = got.source
|
|
1506
1596
|
}
|
|
1507
1597
|
if (!source) return { ok: false, error: '要收藏就传 file 或 message_id' }
|
|
1508
1598
|
const data = await onebot('add_custom_face', { file: source }, cfg)
|
|
@@ -1577,6 +1667,1171 @@ export function makePokeTool(onSent) {
|
|
|
1577
1667
|
}
|
|
1578
1668
|
|
|
1579
1669
|
|
|
1670
|
+
// ---------- 头像 ----------
|
|
1671
|
+
// set_qq_avatar 是 SnowLuma 扩展,file 和图片接口一样吃 http(s)/base64:///file://。
|
|
1672
|
+
// 换头像会立刻对所有好友和群友可见,描述里明确要求只在用户提出时调用。
|
|
1673
|
+
export function makeSetAvatarTool() {
|
|
1674
|
+
return {
|
|
1675
|
+
name: 'qq_set_avatar',
|
|
1676
|
+
description:
|
|
1677
|
+
'改自己的 QQ 头像(SnowLuma set_qq_avatar)。' +
|
|
1678
|
+
'file 传 http(s) 链接、base64://、data:image/... 或本机文件路径;' +
|
|
1679
|
+
'也可以用 message_id + index 从聊天记录里的某张图片取图。' +
|
|
1680
|
+
'只有用户明确要求换头像时才用;换完不用再重复说明。',
|
|
1681
|
+
parameters: {
|
|
1682
|
+
type: 'object',
|
|
1683
|
+
properties: {
|
|
1684
|
+
file: { type: 'string', description: '图片:http(s) 链接、base64://、data:image/... 或本机路径;和 message_id 二选一' },
|
|
1685
|
+
message_id: { type: 'string', description: '从这条消息里取图当头像;和 file 二选一' },
|
|
1686
|
+
index: { type: 'number', description: 'message_id 里第几张图片,从 0 开始,默认 0' },
|
|
1687
|
+
},
|
|
1688
|
+
additionalProperties: false,
|
|
1689
|
+
},
|
|
1690
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
1691
|
+
async execute(args) {
|
|
1692
|
+
const cfg = loadCfg()
|
|
1693
|
+
const fileRaw = String((args && args.file) || '').trim()
|
|
1694
|
+
const hasMessage = args && args.message_id != null && args.message_id !== ''
|
|
1695
|
+
if (fileRaw && hasMessage) return { ok: false, error: 'file 和 message_id 只能选一个' }
|
|
1696
|
+
if (!fileRaw && !hasMessage) return { ok: false, error: '要换头像就传 file 或 message_id' }
|
|
1697
|
+
try {
|
|
1698
|
+
let source = imageSourceOf(fileRaw)
|
|
1699
|
+
if (!source && hasMessage) {
|
|
1700
|
+
const got = await imageSourceFromMessage(args.message_id, args.index, cfg)
|
|
1701
|
+
if (got.error) return { ok: false, error: got.error }
|
|
1702
|
+
source = got.source
|
|
1703
|
+
}
|
|
1704
|
+
if (!source) return { ok: false, error: '拿不到头像图片' }
|
|
1705
|
+
await onebot('set_qq_avatar', { file: source }, cfg)
|
|
1706
|
+
return { ok: true, avatar: true, source: describeSource(source) }
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
1709
|
+
}
|
|
1710
|
+
},
|
|
1711
|
+
render(_args, v) {
|
|
1712
|
+
if (!v || !v.ok) return [{ type: 'text', text: '换头像失败:' + String((v && v.error) || '未知错误') }]
|
|
1713
|
+
return [{ type: 'text', text: 'QQ 头像已更新(' + (v.source || '图片') + ')。' }]
|
|
1714
|
+
},
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
// ---------- 好友管理 ----------
|
|
1720
|
+
// 读写好友资料、备注、分组、名片赞,以及好友申请。
|
|
1721
|
+
// 普通好友申请只有 request 事件,SnowLuma 没有「列出历史好友申请」的 HTTP 接口,
|
|
1722
|
+
// 所以这里一边接 WebSocket 事件进收件箱,一边用 get_doubt_friends_add_request
|
|
1723
|
+
// 拿服务端保存的可疑申请;历史普通申请只能去 QQ 客户端看。
|
|
1724
|
+
const FRIEND_REQUEST_LIMIT = 30
|
|
1725
|
+
const friendRequestInbox = new Map()
|
|
1726
|
+
|
|
1727
|
+
function rememberFriendRequest(ev) {
|
|
1728
|
+
const flag = clean(ev && ev.flag)
|
|
1729
|
+
if (!flag) return
|
|
1730
|
+
const userId = String((ev && ev.user_id) || '')
|
|
1731
|
+
const time = Number(ev && ev.time) || Math.floor(Date.now() / 1000)
|
|
1732
|
+
friendRequestInbox.set(flag, {
|
|
1733
|
+
flag,
|
|
1734
|
+
user_id: userId,
|
|
1735
|
+
nickname: '',
|
|
1736
|
+
comment: String((ev && ev.comment) || '').slice(0, 500),
|
|
1737
|
+
time,
|
|
1738
|
+
at: Date.now(),
|
|
1739
|
+
})
|
|
1740
|
+
if (friendRequestInbox.size > FRIEND_REQUEST_LIMIT) {
|
|
1741
|
+
const oldest = [...friendRequestInbox.entries()].sort((a, b) => (a[1].at || 0) - (b[1].at || 0))[0]
|
|
1742
|
+
if (oldest) friendRequestInbox.delete(oldest[0])
|
|
1743
|
+
}
|
|
1744
|
+
diag('好友申请 → 收件箱:' + userId + ' ' + clean(ev && ev.comment).slice(0, 120))
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
async function friendEntryOf(cfg, userId) {
|
|
1748
|
+
try {
|
|
1749
|
+
const groups = await onebot('get_friends_with_category', {}, cfg)
|
|
1750
|
+
if (Array.isArray(groups) && groups.length) {
|
|
1751
|
+
for (const g of groups) {
|
|
1752
|
+
const list = Array.isArray(g && g.buddyList) ? g.buddyList : []
|
|
1753
|
+
const hit = list.find((b) => String(b && b.user_id) === String(userId))
|
|
1754
|
+
if (hit) {
|
|
1755
|
+
const categoryId = Number(g.categoryId)
|
|
1756
|
+
return {
|
|
1757
|
+
entry: hit,
|
|
1758
|
+
category: { id: Number.isFinite(categoryId) ? categoryId : 0, name: clean(g.categoryName) },
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
} catch (error) {}
|
|
1764
|
+
// 分组接口不可用(或好友不在分组结果里)时退回普通好友列表:
|
|
1765
|
+
// 至少能判断是不是好友、拿到备注。
|
|
1766
|
+
try {
|
|
1767
|
+
const data = await onebot('get_friend_list', {}, cfg)
|
|
1768
|
+
const hit = (Array.isArray(data) ? data : []).find((f) => String(f && f.user_id) === String(userId))
|
|
1769
|
+
if (hit) return { entry: hit, category: null }
|
|
1770
|
+
} catch (error) {}
|
|
1771
|
+
return null
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// 接口不可用时返回 null(未知),不要谎报「不是单向好友」。
|
|
1775
|
+
async function oneWayOf(cfg, userId) {
|
|
1776
|
+
try {
|
|
1777
|
+
const list = await onebot('get_unidirectional_friend_list', {}, cfg)
|
|
1778
|
+
return (Array.isArray(list) ? list : []).some((x) => {
|
|
1779
|
+
const id = x && typeof x === 'object'
|
|
1780
|
+
? String(x.user_id || x.uin || x.qq || '')
|
|
1781
|
+
: String(x || '')
|
|
1782
|
+
return !!id && id === String(userId)
|
|
1783
|
+
})
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
return null
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function sexText(value) {
|
|
1790
|
+
const s = String(value == null ? '' : value).toLowerCase()
|
|
1791
|
+
if (s === 'male' || s === '1' || s === '男') return '男'
|
|
1792
|
+
if (s === 'female' || s === '2' || s === '女') return '女'
|
|
1793
|
+
if (!s || s === '0' || s === 'unknown' || s === '未知') return '未知'
|
|
1794
|
+
return clean(value)
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
export function makeGetFriendInfoTool() {
|
|
1798
|
+
return {
|
|
1799
|
+
name: 'qq_get_friend_info',
|
|
1800
|
+
description:
|
|
1801
|
+
'看某个 QQ 的资料和好友关系:昵称、备注、分组、性别年龄、等级、VIP、是否单向好友。' +
|
|
1802
|
+
'user_id 从 qq_list_friends、聊天记录或好友申请里拿。只读。',
|
|
1803
|
+
parameters: {
|
|
1804
|
+
type: 'object',
|
|
1805
|
+
properties: {
|
|
1806
|
+
user_id: { type: 'number', description: '对方 QQ 号' },
|
|
1807
|
+
},
|
|
1808
|
+
required: ['user_id'],
|
|
1809
|
+
additionalProperties: false,
|
|
1810
|
+
},
|
|
1811
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
1812
|
+
async execute(args) {
|
|
1813
|
+
const cfg = loadCfg()
|
|
1814
|
+
const userId = Number(args && args.user_id)
|
|
1815
|
+
if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
|
|
1816
|
+
if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
1817
|
+
let info = null
|
|
1818
|
+
let infoError = ''
|
|
1819
|
+
try {
|
|
1820
|
+
info = await onebot('get_stranger_info', { user_id: userId }, cfg)
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
infoError = String((error && error.message) || error)
|
|
1823
|
+
}
|
|
1824
|
+
const found = await friendEntryOf(cfg, userId)
|
|
1825
|
+
if (!info && !found) {
|
|
1826
|
+
return { ok: false, error: infoError || '查不到这个 QQ 的资料' }
|
|
1827
|
+
}
|
|
1828
|
+
const entry = (found && found.entry) || {}
|
|
1829
|
+
const oneWay = await oneWayOf(cfg, userId)
|
|
1830
|
+
const rawLevel = info
|
|
1831
|
+
? (info.qq_level != null ? info.qq_level : info.qqLevel != null ? info.qqLevel : info.level)
|
|
1832
|
+
: 0
|
|
1833
|
+
const friend = {
|
|
1834
|
+
user_id: String(userId),
|
|
1835
|
+
nickname: clean((info && info.nickname) || entry.nickname),
|
|
1836
|
+
remark: clean(entry.remark || (info && info.remark)),
|
|
1837
|
+
category: found ? found.category : null,
|
|
1838
|
+
sex: sexText(info && info.sex),
|
|
1839
|
+
age: Number((info && info.age) || 0),
|
|
1840
|
+
qid: clean(info && info.qid),
|
|
1841
|
+
level: Number(rawLevel || 0),
|
|
1842
|
+
vip: !!(info && info.is_vip),
|
|
1843
|
+
vip_level: Number((info && info.vip_level) || 0),
|
|
1844
|
+
is_friend: !!found,
|
|
1845
|
+
one_way: oneWay,
|
|
1846
|
+
}
|
|
1847
|
+
return { ok: true, friend }
|
|
1848
|
+
},
|
|
1849
|
+
render(_args, v) {
|
|
1850
|
+
if (!v || !v.ok) return [{ type: 'text', text: '看好友资料失败:' + String((v && v.error) || '未知错误') }]
|
|
1851
|
+
const f = v.friend || {}
|
|
1852
|
+
const lines = [
|
|
1853
|
+
`QQ ${f.user_id}:`,
|
|
1854
|
+
'- 昵称:' + (f.nickname || '未知'),
|
|
1855
|
+
'- 备注:' + (f.remark || '(无)'),
|
|
1856
|
+
'- 分组:' + (f.category ? `${f.category.name || '未命名'}(id ${f.category.id})` : (f.is_friend ? '未知(分组接口没返回)' : '不是好友')),
|
|
1857
|
+
'- 性别年龄:' + (f.sex || '未知') + (f.age ? ',' + f.age + ' 岁' : ''),
|
|
1858
|
+
'- 等级:' + (f.level || '未知'),
|
|
1859
|
+
'- VIP:' + (f.vip ? '是' + (f.vip_level ? '(' + f.vip_level + ' 级)' : '') : '否'),
|
|
1860
|
+
'- 单向好友:' + (f.one_way === true ? '是' : f.one_way === false ? '否' : '未知(接口不可用)'),
|
|
1861
|
+
]
|
|
1862
|
+
if (f.qid) lines.splice(2, 0, '- QID:' + f.qid)
|
|
1863
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1864
|
+
},
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
export function makeListFriendCategoriesTool() {
|
|
1869
|
+
return {
|
|
1870
|
+
name: 'qq_list_friend_categories',
|
|
1871
|
+
description:
|
|
1872
|
+
'看好友分组和每个分组里的好友(SnowLuma get_friends_with_category)。' +
|
|
1873
|
+
'移动好友分组时,category_id / category_name 从这里拿。' +
|
|
1874
|
+
'分组接口漏掉的好友会补进「未分组(接口未返回)」里。只读。',
|
|
1875
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
1876
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
1877
|
+
async execute() {
|
|
1878
|
+
const cfg = loadCfg()
|
|
1879
|
+
try {
|
|
1880
|
+
let categories = []
|
|
1881
|
+
let usedFriendListFallback = false
|
|
1882
|
+
try {
|
|
1883
|
+
const data = await onebot('get_friends_with_category', {}, cfg)
|
|
1884
|
+
if (!Array.isArray(data)) throw new Error('好友分组接口返回的不是数组')
|
|
1885
|
+
categories = data.map((g) => ({
|
|
1886
|
+
id: Number.isFinite(Number(g && g.categoryId)) ? Number(g.categoryId) : 0,
|
|
1887
|
+
name: clean(g && g.categoryName),
|
|
1888
|
+
count: Number((g && g.categoryMbCount) || 0),
|
|
1889
|
+
friends: (Array.isArray(g && g.buddyList) ? g.buddyList : []).map((b) => ({
|
|
1890
|
+
user_id: String((b && b.user_id) || ''),
|
|
1891
|
+
nickname: clean(b && b.nickname),
|
|
1892
|
+
remark: clean(b && b.remark),
|
|
1893
|
+
})),
|
|
1894
|
+
}))
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
// 老版本或 NapCat 没有分组接口时,退回普通好友列表,至少还能看到人。
|
|
1897
|
+
const data = await onebot('get_friend_list', {}, cfg)
|
|
1898
|
+
const friends = (Array.isArray(data) ? data : []).map((f) => ({
|
|
1899
|
+
user_id: String((f && f.user_id) || ''),
|
|
1900
|
+
nickname: clean(f && f.nickname),
|
|
1901
|
+
remark: clean(f && f.remark),
|
|
1902
|
+
}))
|
|
1903
|
+
categories = [{ id: 0, name: '我的好友', count: friends.length, friends }]
|
|
1904
|
+
usedFriendListFallback = true
|
|
1905
|
+
}
|
|
1906
|
+
// SnowLuma 的分组接口可能漏人(真机见过 86 个好友只返回 83 个),
|
|
1907
|
+
// 用普通好友列表补齐,免得模型以为这些人不存在。
|
|
1908
|
+
if (!usedFriendListFallback) {
|
|
1909
|
+
try {
|
|
1910
|
+
const data = await onebot('get_friend_list', {}, cfg)
|
|
1911
|
+
const known = new Set()
|
|
1912
|
+
for (const c of categories) for (const f of c.friends) known.add(f.user_id)
|
|
1913
|
+
const missing = (Array.isArray(data) ? data : []).map((f) => ({
|
|
1914
|
+
user_id: String((f && f.user_id) || ''),
|
|
1915
|
+
nickname: clean(f && f.nickname),
|
|
1916
|
+
remark: clean(f && f.remark),
|
|
1917
|
+
})).filter((f) => f.user_id && !known.has(f.user_id))
|
|
1918
|
+
if (missing.length) {
|
|
1919
|
+
categories.push({ id: -1, name: '未分组(接口未返回)', count: missing.length, friends: missing })
|
|
1920
|
+
}
|
|
1921
|
+
} catch (error) {}
|
|
1922
|
+
}
|
|
1923
|
+
for (const c of categories) {
|
|
1924
|
+
c.friends = c.friends.filter((f) => f.user_id && allowedPrivate(f.user_id, cfg))
|
|
1925
|
+
c.count = c.friends.length
|
|
1926
|
+
}
|
|
1927
|
+
const total = categories.reduce((n, c) => n + c.friends.length, 0)
|
|
1928
|
+
return { ok: true, total, categories }
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
1931
|
+
}
|
|
1932
|
+
},
|
|
1933
|
+
render(_args, v) {
|
|
1934
|
+
if (!v || !v.ok) return [{ type: 'text', text: '看好友分组失败:' + String((v && v.error) || '未知错误') }]
|
|
1935
|
+
if (!v.categories.length) return [{ type: 'text', text: '没有好友分组。' }]
|
|
1936
|
+
const lines = [`好友分组(${v.categories.length} 个,共 ${v.total} 人):`]
|
|
1937
|
+
for (const c of v.categories) {
|
|
1938
|
+
const names = c.friends.slice(0, 20).map((f) => f.remark || f.nickname || f.user_id)
|
|
1939
|
+
const idText = c.id >= 0 ? `id ${c.id},` : ''
|
|
1940
|
+
lines.push(`- ${c.name || '未命名'}(${idText}${c.count} 人)${names.length ? ':' + names.join('、') : ''}${c.friends.length > names.length ? ' …' : ''}`)
|
|
1941
|
+
}
|
|
1942
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1943
|
+
},
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
export function makeUpdateFriendTool() {
|
|
1948
|
+
return {
|
|
1949
|
+
name: 'qq_update_friend',
|
|
1950
|
+
description:
|
|
1951
|
+
'改好友备注或把好友移到别的分组。' +
|
|
1952
|
+
'remark 传空字符串表示清空备注;分组用 category_id 或 category_name(名称必须唯一且完全匹配),' +
|
|
1953
|
+
'分组 id/名称从 qq_list_friend_categories 拿。两件事至少做一件。',
|
|
1954
|
+
parameters: {
|
|
1955
|
+
type: 'object',
|
|
1956
|
+
properties: {
|
|
1957
|
+
user_id: { type: 'number', description: '好友 QQ 号' },
|
|
1958
|
+
remark: { type: 'string', description: '新备注;传空字符串清空;不传就不改备注' },
|
|
1959
|
+
category_id: { type: 'number', description: '目标分组 ID' },
|
|
1960
|
+
category_name: { type: 'string', description: '目标分组名称(完全匹配)' },
|
|
1961
|
+
},
|
|
1962
|
+
required: ['user_id'],
|
|
1963
|
+
additionalProperties: false,
|
|
1964
|
+
},
|
|
1965
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
1966
|
+
async execute(args) {
|
|
1967
|
+
const cfg = loadCfg()
|
|
1968
|
+
const userId = Number(args && args.user_id)
|
|
1969
|
+
if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
|
|
1970
|
+
if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
1971
|
+
const hasRemark = args && args.remark != null
|
|
1972
|
+
const remark = clean(args && args.remark)
|
|
1973
|
+
const hasCategoryId = args && args.category_id != null && args.category_id !== ''
|
|
1974
|
+
const categoryId = hasCategoryId ? Number(args.category_id) : null
|
|
1975
|
+
const categoryName = clean(args && args.category_name)
|
|
1976
|
+
if (!hasRemark && !hasCategoryId && !categoryName) {
|
|
1977
|
+
return { ok: false, error: '要改什么?传 remark,或 category_id / category_name' }
|
|
1978
|
+
}
|
|
1979
|
+
if (hasCategoryId && !Number.isInteger(categoryId)) return { ok: false, error: 'category_id 不是有效整数' }
|
|
1980
|
+
try {
|
|
1981
|
+
const changes = []
|
|
1982
|
+
if (hasRemark) {
|
|
1983
|
+
await onebot('set_friend_remark', { user_id: userId, remark }, cfg)
|
|
1984
|
+
changes.push(remark ? '备注改成「' + remark + '」' : '备注已清空')
|
|
1985
|
+
}
|
|
1986
|
+
if (hasCategoryId || categoryName) {
|
|
1987
|
+
let targetId = categoryId
|
|
1988
|
+
let targetName = categoryName
|
|
1989
|
+
if (!hasCategoryId) {
|
|
1990
|
+
try {
|
|
1991
|
+
const data = await onebot('get_friends_with_category', {}, cfg)
|
|
1992
|
+
const hits = (Array.isArray(data) ? data : []).filter((g) => clean(g && g.categoryName) === categoryName)
|
|
1993
|
+
if (hits.length !== 1) {
|
|
1994
|
+
return {
|
|
1995
|
+
ok: false,
|
|
1996
|
+
error: (hits.length
|
|
1997
|
+
? `有 ${hits.length} 个分组都叫「${categoryName}」,请改用 category_id`
|
|
1998
|
+
: `没找到叫「${categoryName}」的分组,先用 qq_list_friend_categories 看`)
|
|
1999
|
+
+ (changes.length ? '(已' + changes.join(';') + ')' : ''),
|
|
2000
|
+
changed: changes,
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
targetId = Number(hits[0].categoryId)
|
|
2004
|
+
if (!Number.isInteger(targetId)) targetId = null
|
|
2005
|
+
targetName = clean(hits[0].categoryName)
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
// 分组列表接口不可用时直接让 SnowLuma 按名称处理。
|
|
2008
|
+
targetId = null
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const body = { uin: userId }
|
|
2012
|
+
if (targetId != null) body.categoryId = targetId
|
|
2013
|
+
else if (targetName) body.categoryName = targetName
|
|
2014
|
+
await onebot('set_friends_category', body, cfg)
|
|
2015
|
+
changes.push('移到分组「' + (targetName || targetId) + '」')
|
|
2016
|
+
}
|
|
2017
|
+
return { ok: true, user_id: userId, remark: hasRemark ? remark : undefined, changes }
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
render(_args, v) {
|
|
2023
|
+
if (!v || !v.ok) return [{ type: 'text', text: '改好友资料失败:' + String((v && v.error) || '未知错误') }]
|
|
2024
|
+
return [{ type: 'text', text: `QQ ${v.user_id} 已更新:${(v.changes || []).join(';')}。` }]
|
|
2025
|
+
},
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
export function makeDeleteFriendTool() {
|
|
2030
|
+
return {
|
|
2031
|
+
name: 'qq_delete_friend',
|
|
2032
|
+
description:
|
|
2033
|
+
'删除一个好友。不可撤销:只有用户明确要求删好友时才用,并且必须传 confirm=true。' +
|
|
2034
|
+
'block=true 会在删除的同时屏蔽对方。',
|
|
2035
|
+
parameters: {
|
|
2036
|
+
type: 'object',
|
|
2037
|
+
properties: {
|
|
2038
|
+
user_id: { type: 'number', description: '好友 QQ 号' },
|
|
2039
|
+
block: { type: 'boolean', description: 'true = 删除并屏蔽,默认 false' },
|
|
2040
|
+
confirm: { type: 'boolean', description: '必须显式传 true,防止误删' },
|
|
2041
|
+
},
|
|
2042
|
+
required: ['user_id', 'confirm'],
|
|
2043
|
+
additionalProperties: false,
|
|
2044
|
+
},
|
|
2045
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2046
|
+
async execute(args) {
|
|
2047
|
+
const cfg = loadCfg()
|
|
2048
|
+
const userId = Number(args && args.user_id)
|
|
2049
|
+
if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
|
|
2050
|
+
if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2051
|
+
if (!args || args.confirm !== true) return { ok: false, error: '删好友不可撤销,需要 confirm=true 才会执行' }
|
|
2052
|
+
try {
|
|
2053
|
+
const block = args.block === true
|
|
2054
|
+
await onebot('delete_friend', { user_id: userId, block }, cfg)
|
|
2055
|
+
return { ok: true, removed: true, user_id: userId, blocked: block }
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2058
|
+
}
|
|
2059
|
+
},
|
|
2060
|
+
render(_args, v) {
|
|
2061
|
+
if (!v || !v.ok) return [{ type: 'text', text: '删好友失败:' + String((v && v.error) || '未知错误') }]
|
|
2062
|
+
return [{ type: 'text', text: `已删除好友 QQ ${v.user_id}${v.blocked ? ',并已屏蔽' : ''}。` }]
|
|
2063
|
+
},
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
export function makeSendLikeTool() {
|
|
2068
|
+
return {
|
|
2069
|
+
name: 'qq_send_like',
|
|
2070
|
+
description:
|
|
2071
|
+
'给好友的名片点赞(QQ 资料卡赞)。times 是次数,默认 1,最多 20。别频繁调用。',
|
|
2072
|
+
parameters: {
|
|
2073
|
+
type: 'object',
|
|
2074
|
+
properties: {
|
|
2075
|
+
user_id: { type: 'number', description: '好友 QQ 号' },
|
|
2076
|
+
times: { type: 'number', description: '点赞次数,默认 1,最多 20' },
|
|
2077
|
+
},
|
|
2078
|
+
required: ['user_id'],
|
|
2079
|
+
additionalProperties: false,
|
|
2080
|
+
},
|
|
2081
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2082
|
+
async execute(args) {
|
|
2083
|
+
const cfg = loadCfg()
|
|
2084
|
+
const userId = Number(args && args.user_id)
|
|
2085
|
+
if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
|
|
2086
|
+
if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2087
|
+
const times = Math.max(1, Math.min(Math.floor(Number(args && args.times) || 1), 20))
|
|
2088
|
+
try {
|
|
2089
|
+
await onebot('send_like', { user_id: userId, times }, cfg)
|
|
2090
|
+
return { ok: true, user_id: userId, times }
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
render(_args, v) {
|
|
2096
|
+
if (!v || !v.ok) return [{ type: 'text', text: '点赞失败:' + String((v && v.error) || '未知错误') }]
|
|
2097
|
+
return [{ type: 'text', text: `已给 QQ ${v.user_id} 点了 ${v.times} 个赞。` }]
|
|
2098
|
+
},
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export function makeListFriendRequestsTool() {
|
|
2103
|
+
return {
|
|
2104
|
+
name: 'qq_list_friend_requests',
|
|
2105
|
+
description:
|
|
2106
|
+
'看待处理的好友申请:插件运行期间通过 WebSocket 收到的普通申请 + QQ 账号里保存的可疑好友申请。' +
|
|
2107
|
+
'每项带 flag,用 qq_handle_friend_request 同意/拒绝。' +
|
|
2108
|
+
'普通申请只在插件启动后收到才在这里,历史申请要去 QQ 客户端看。只读。',
|
|
2109
|
+
parameters: {
|
|
2110
|
+
type: 'object',
|
|
2111
|
+
properties: {
|
|
2112
|
+
include_doubt: { type: 'boolean', description: '要不要一起查可疑好友申请,默认 true' },
|
|
2113
|
+
},
|
|
2114
|
+
additionalProperties: false,
|
|
2115
|
+
},
|
|
2116
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2117
|
+
async execute(args) {
|
|
2118
|
+
const cfg = loadCfg()
|
|
2119
|
+
const includeDoubt = !(args && args.include_doubt === false)
|
|
2120
|
+
const requests = [...friendRequestInbox.values()]
|
|
2121
|
+
.sort((a, b) => (b.time || 0) - (a.time || 0) || (b.at || 0) - (a.at || 0))
|
|
2122
|
+
.slice(0, FRIEND_REQUEST_LIMIT)
|
|
2123
|
+
if (requests.length) {
|
|
2124
|
+
await Promise.all(requests.slice(0, 5).map(async (r) => {
|
|
2125
|
+
if (r.nickname || !r.user_id) return
|
|
2126
|
+
try {
|
|
2127
|
+
const info = await onebot('get_stranger_info', { user_id: Number(r.user_id) }, cfg)
|
|
2128
|
+
r.nickname = clean(info && info.nickname)
|
|
2129
|
+
} catch (error) {}
|
|
2130
|
+
}))
|
|
2131
|
+
}
|
|
2132
|
+
let doubt = []
|
|
2133
|
+
let doubtError = ''
|
|
2134
|
+
if (includeDoubt) {
|
|
2135
|
+
try {
|
|
2136
|
+
const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
|
|
2137
|
+
doubt = (Array.isArray(data) ? data : []).map((x) => ({
|
|
2138
|
+
kind: 'doubt',
|
|
2139
|
+
flag: String((x && x.uid) || ''),
|
|
2140
|
+
user_id: String((x && x.user_id) || ''),
|
|
2141
|
+
nickname: clean(x && x.nick),
|
|
2142
|
+
source: clean(x && x.source),
|
|
2143
|
+
reason: clean(x && x.reason),
|
|
2144
|
+
comment: String((x && x.msg) || '').slice(0, 500),
|
|
2145
|
+
group_code: String((x && x.group_code) || ''),
|
|
2146
|
+
time: Number((x && x.reqTime) || 0),
|
|
2147
|
+
})).filter((x) => x.flag)
|
|
2148
|
+
} catch (error) {
|
|
2149
|
+
doubtError = String((error && error.message) || error)
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
return {
|
|
2153
|
+
ok: true,
|
|
2154
|
+
requests: requests.map(({ at, ...r }) => ({ kind: 'friend', ...r })),
|
|
2155
|
+
doubt,
|
|
2156
|
+
doubt_error: doubtError,
|
|
2157
|
+
inbox_note: '普通申请从插件启动后开始收,历史申请不在收件箱里',
|
|
2158
|
+
}
|
|
2159
|
+
},
|
|
2160
|
+
render(_args, v) {
|
|
2161
|
+
if (!v || !v.ok) return [{ type: 'text', text: '看好友申请失败:' + String((v && v.error) || '未知错误') }]
|
|
2162
|
+
const lines = []
|
|
2163
|
+
if (v.requests.length) {
|
|
2164
|
+
lines.push(`普通好友申请(${v.requests.length} 个):`)
|
|
2165
|
+
for (const r of v.requests) {
|
|
2166
|
+
const who = r.nickname ? `${r.nickname}(${r.user_id})` : `QQ ${r.user_id}`
|
|
2167
|
+
lines.push(`- ${who}${r.comment ? ':' + r.comment : ''} flag=${r.flag}`)
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (v.doubt.length) {
|
|
2171
|
+
lines.push(`可疑好友申请(${v.doubt.length} 个):`)
|
|
2172
|
+
for (const d of v.doubt) {
|
|
2173
|
+
const who = d.nickname ? `${d.nickname}(${d.user_id})` : `QQ ${d.user_id}`
|
|
2174
|
+
const extra = [d.source && '来源 ' + d.source, d.reason && '原因 ' + d.reason].filter(Boolean).join(',')
|
|
2175
|
+
lines.push(`- ${who}${extra ? '(' + extra + ')' : ''}${d.comment ? ':' + d.comment : ''} flag=${d.flag}`)
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (!v.requests.length && !v.doubt.length) {
|
|
2179
|
+
lines.push(v.doubt_error
|
|
2180
|
+
? '现在没有普通好友申请;可疑申请接口不可用:' + v.doubt_error.slice(0, 200)
|
|
2181
|
+
: '现在没有待处理的好友申请。')
|
|
2182
|
+
} else if (v.doubt_error) {
|
|
2183
|
+
lines.push('可疑申请接口不可用:' + v.doubt_error.slice(0, 200))
|
|
2184
|
+
}
|
|
2185
|
+
if (v.requests.length || v.doubt.length) {
|
|
2186
|
+
lines.push('用 qq_handle_friend_request 处理:传 flag、kind(friend/doubt)、approve=true/false。')
|
|
2187
|
+
}
|
|
2188
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
2189
|
+
},
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
export function makeHandleFriendRequestTool() {
|
|
2194
|
+
return {
|
|
2195
|
+
name: 'qq_handle_friend_request',
|
|
2196
|
+
description:
|
|
2197
|
+
'同意或拒绝好友申请。flag 从 qq_list_friend_requests 拿,也可以传 user_id 自动在待处理列表里找。' +
|
|
2198
|
+
'kind=friend 是普通申请,kind=doubt 是可疑申请。approve 必须明确传 true(同意)或 false(拒绝)。',
|
|
2199
|
+
parameters: {
|
|
2200
|
+
type: 'object',
|
|
2201
|
+
properties: {
|
|
2202
|
+
flag: { type: 'string', description: '申请的处理标识,来自 qq_list_friend_requests' },
|
|
2203
|
+
user_id: { type: 'number', description: '申请人 QQ 号;不传 flag 时用它查收件箱' },
|
|
2204
|
+
kind: { type: 'string', enum: ['friend', 'doubt'], description: '申请类型,默认 friend' },
|
|
2205
|
+
approve: { type: 'boolean', description: 'true = 同意,false = 拒绝' },
|
|
2206
|
+
},
|
|
2207
|
+
required: ['approve'],
|
|
2208
|
+
additionalProperties: false,
|
|
2209
|
+
},
|
|
2210
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2211
|
+
async execute(args) {
|
|
2212
|
+
const cfg = loadCfg()
|
|
2213
|
+
const kind = args && args.kind === 'doubt' ? 'doubt' : 'friend'
|
|
2214
|
+
const userId = args && args.user_id != null ? Number(args.user_id) : 0
|
|
2215
|
+
if (args && args.user_id != null && !Number.isFinite(userId)) {
|
|
2216
|
+
return { ok: false, error: 'user_id 不是有效 QQ 号' }
|
|
2217
|
+
}
|
|
2218
|
+
if (!args || typeof args.approve !== 'boolean') return { ok: false, error: 'approve 必须明确传 true 或 false' }
|
|
2219
|
+
const approve = args.approve
|
|
2220
|
+
let flag = clean(args && args.flag)
|
|
2221
|
+
let nickname = ''
|
|
2222
|
+
if (!flag && userId) {
|
|
2223
|
+
if (kind === 'friend') {
|
|
2224
|
+
const hit = [...friendRequestInbox.values()]
|
|
2225
|
+
.filter((r) => String(r.user_id) === String(userId))
|
|
2226
|
+
.sort((a, b) => (b.at || 0) - (a.at || 0))[0]
|
|
2227
|
+
if (hit) {
|
|
2228
|
+
flag = hit.flag
|
|
2229
|
+
nickname = hit.nickname
|
|
2230
|
+
}
|
|
2231
|
+
} else {
|
|
2232
|
+
try {
|
|
2233
|
+
const data = await onebot('get_doubt_friends_add_request', { count: 50 }, cfg)
|
|
2234
|
+
const hit = (Array.isArray(data) ? data : []).find((x) => String((x && x.user_id) || '') === String(userId))
|
|
2235
|
+
if (hit) {
|
|
2236
|
+
flag = String((hit && hit.uid) || '')
|
|
2237
|
+
nickname = clean(hit && hit.nick)
|
|
2238
|
+
}
|
|
2239
|
+
} catch (error) {}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
if (!flag) {
|
|
2243
|
+
return { ok: false, error: '没有找到要处理的申请;先用 qq_list_friend_requests 拿 flag' }
|
|
2244
|
+
}
|
|
2245
|
+
try {
|
|
2246
|
+
if (kind === 'doubt') {
|
|
2247
|
+
await onebot('set_doubt_friends_add_request', { flag, approve }, cfg)
|
|
2248
|
+
} else {
|
|
2249
|
+
await onebot('set_friend_add_request', { flag, approve }, cfg)
|
|
2250
|
+
friendRequestInbox.delete(flag)
|
|
2251
|
+
}
|
|
2252
|
+
return { ok: true, kind, approved: approve, flag, user_id: userId || null, nickname }
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2255
|
+
}
|
|
2256
|
+
},
|
|
2257
|
+
render(_args, v) {
|
|
2258
|
+
if (!v || !v.ok) return [{ type: 'text', text: '处理好友申请失败:' + String((v && v.error) || '未知错误') }]
|
|
2259
|
+
const who = v.nickname ? `${v.nickname}(${v.user_id || ''})` : (v.user_id ? 'QQ ' + v.user_id : '这条申请')
|
|
2260
|
+
return [{ type: 'text', text: `已${v.approved ? '同意' : '拒绝'}${who}的好友申请。` }]
|
|
2261
|
+
},
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
export function startFriendRequestWatcher(ctx) {
|
|
2266
|
+
startWsWatcher(ctx, '好友申请', () => true, (ev) => {
|
|
2267
|
+
if (ev && ev.post_type === 'request' && ev.request_type === 'friend') rememberFriendRequest(ev)
|
|
2268
|
+
})
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
// ---------- QQ 空间(说说) ----------
|
|
2273
|
+
// SnowLuma 的「空间」扩展:send_qzone_msg / get_qzone_msg_list / get_qzone_feeds /
|
|
2274
|
+
// get_qzone_msg_detail / like_qzone / unlike_qzone / comment_qzone /
|
|
2275
|
+
// delete_qzone_msg / set_qzone_msg_right / set_qzone_ban。
|
|
2276
|
+
// 图片统一走 imageSourceOf:http(s)、base64://、file://(本机路径会转成 file://)。
|
|
2277
|
+
const QZONE_VISIBILITY = { public: 1, friends: 4, partial: 16, private: 64, exclude: 128 }
|
|
2278
|
+
const QZONE_VISIBILITY_NAMES = {
|
|
2279
|
+
1: '所有人可见',
|
|
2280
|
+
4: '好友可见',
|
|
2281
|
+
16: '部分好友可见',
|
|
2282
|
+
64: '仅自己可见',
|
|
2283
|
+
128: '部分好友不可见',
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function qzoneRightOf(value) {
|
|
2287
|
+
const key = String(value == null ? '' : value).trim()
|
|
2288
|
+
if (QZONE_VISIBILITY[key] != null) return QZONE_VISIBILITY[key]
|
|
2289
|
+
const n = Number(key)
|
|
2290
|
+
if (Number.isInteger(n) && QZONE_VISIBILITY_NAMES[n]) return n
|
|
2291
|
+
return 0
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function plainText(s) {
|
|
2295
|
+
return clean(String(s || '')
|
|
2296
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
2297
|
+
.replace(/<[^>]*>/g, ' ')
|
|
2298
|
+
.replace(/ /gi, ' ')
|
|
2299
|
+
.replace(/&/gi, '&')
|
|
2300
|
+
.replace(/</gi, '<')
|
|
2301
|
+
.replace(/>/gi, '>')
|
|
2302
|
+
.replace(/"/gi, '"')
|
|
2303
|
+
.replace(/'/gi, "'"))
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function qzoneImagesOf(list) {
|
|
2307
|
+
return (Array.isArray(list) ? list : [])
|
|
2308
|
+
.map((x) => {
|
|
2309
|
+
const url = String(x || '').trim()
|
|
2310
|
+
return url.startsWith('//') ? 'https:' + url : url
|
|
2311
|
+
})
|
|
2312
|
+
.filter((x) => /^https?:/i.test(x))
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
async function admitQzoneImages(admitImages, cfg, items) {
|
|
2316
|
+
if (typeof admitImages !== 'function') return []
|
|
2317
|
+
const picked = []
|
|
2318
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
2319
|
+
if (picked.length >= 4) break
|
|
2320
|
+
if (!item || !/^https?:/i.test(String(item.url || ''))) continue
|
|
2321
|
+
picked.push(item)
|
|
2322
|
+
}
|
|
2323
|
+
if (!picked.length) return []
|
|
2324
|
+
return admitImages(picked.map((item) => ({
|
|
2325
|
+
data: { url: item.url },
|
|
2326
|
+
cfg,
|
|
2327
|
+
name: item.name || 'qq-qzone.jpg',
|
|
2328
|
+
caption: item.caption || 'QQ 空间图片',
|
|
2329
|
+
})))
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function qzonePostOf(x, max = 500) {
|
|
2333
|
+
const time = Number((x && x.time) || 0)
|
|
2334
|
+
return {
|
|
2335
|
+
tid: String((x && x.tid) || ''),
|
|
2336
|
+
content: clean((x && x.content) || plainText(x && x.html)).slice(0, max),
|
|
2337
|
+
time,
|
|
2338
|
+
t: fmtTime(time),
|
|
2339
|
+
comment_num: Number((x && (x.comment_num != null ? x.comment_num : x.comment_count)) || 0),
|
|
2340
|
+
is_private: !!(x && x.is_private),
|
|
2341
|
+
images: qzoneImagesOf(x && x.images),
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// 部分 SnowLuma 版本只会把 feed 的 HTML 透传回来,tid/content 仍是 null。
|
|
2346
|
+
// 说说(appid=311)的 tid 在 HTML 的 tid="..." / data-tid="..." 里,
|
|
2347
|
+
// 取出来就能继续用详情、点赞和评论;补不回来的 feed 保持只读。
|
|
2348
|
+
function qzoneTidOf(x) {
|
|
2349
|
+
const direct = x && x.tid != null ? String(x.tid).trim() : ''
|
|
2350
|
+
if (direct) return direct
|
|
2351
|
+
if (Number(x && x.appid) !== 311) return ''
|
|
2352
|
+
const html = String((x && x.html) || '')
|
|
2353
|
+
const hit = html.match(/(?:data-)?tid[="'\s:]+([0-9a-fA-F]{16,64})/)
|
|
2354
|
+
return hit ? hit[1] : ''
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
export function makeListQzoneTool(admitImages) {
|
|
2358
|
+
return {
|
|
2359
|
+
name: 'qq_list_qzone',
|
|
2360
|
+
description:
|
|
2361
|
+
'看 QQ 空间说说。默认看自己的说说;传 target_uin 看别人的;feeds=true 看好友动态(可用 cursor 翻页)。' +
|
|
2362
|
+
'好友动态里只保留说说(appid=311);如果 SnowLuma 没从 HTML 里解析出 tid,会尽量自己补回来,补不回来的只能看不能互动。' +
|
|
2363
|
+
'返回 tid、正文、时间、评论数和图片;tid 可以传给 qq_get_qzone_post / qq_manage_qzone。只读。',
|
|
2364
|
+
parameters: {
|
|
2365
|
+
type: 'object',
|
|
2366
|
+
properties: {
|
|
2367
|
+
feeds: { type: 'boolean', description: 'true = 看好友动态(get_qzone_feeds),默认 false' },
|
|
2368
|
+
target_uin: { type: 'number', description: '看谁的说说;不传就是自己(feeds 模式忽略)' },
|
|
2369
|
+
pos: { type: 'number', description: '从第几条开始,默认 0(feeds 模式忽略)' },
|
|
2370
|
+
count: { type: 'number', description: '本页数量,默认 10,最多 50' },
|
|
2371
|
+
cursor: { type: 'string', description: '好友动态翻页游标,传上一页返回的 next_cursor' },
|
|
2372
|
+
include_images: { type: 'boolean', description: '要不要把最近的图片也给你看(最多 4 张,默认 true)' },
|
|
2373
|
+
},
|
|
2374
|
+
additionalProperties: false,
|
|
2375
|
+
},
|
|
2376
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2377
|
+
async execute(args) {
|
|
2378
|
+
const cfg = loadCfg()
|
|
2379
|
+
const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 10), 50))
|
|
2380
|
+
const withImages = !(args && args.include_images === false)
|
|
2381
|
+
try {
|
|
2382
|
+
if (args && args.feeds === true) {
|
|
2383
|
+
const body = { count }
|
|
2384
|
+
const cursor = String((args && args.cursor) || '').trim()
|
|
2385
|
+
if (cursor) body.cursor = cursor
|
|
2386
|
+
const data = await onebot('get_qzone_feeds', body, cfg)
|
|
2387
|
+
const rawFeeds = Array.isArray(data && data.feeds) ? data.feeds : []
|
|
2388
|
+
const feeds = rawFeeds.map((x) => {
|
|
2389
|
+
const post = qzonePostOf(x)
|
|
2390
|
+
const tid = qzoneTidOf(x)
|
|
2391
|
+
return {
|
|
2392
|
+
...post,
|
|
2393
|
+
tid,
|
|
2394
|
+
actionable: !!tid,
|
|
2395
|
+
appid: Number((x && x.appid) || 0),
|
|
2396
|
+
uin: String((x && x.uin) || ''),
|
|
2397
|
+
name: clean(x && x.nickname),
|
|
2398
|
+
like_count: Number((x && x.like_count) || 0),
|
|
2399
|
+
content_complete: !!(x && x.content_complete),
|
|
2400
|
+
}
|
|
2401
|
+
}).filter((x) => x.appid === 311 || x.tid)
|
|
2402
|
+
const imageItems = []
|
|
2403
|
+
for (const f of feeds) {
|
|
2404
|
+
for (const url of f.images) {
|
|
2405
|
+
imageItems.push({
|
|
2406
|
+
url,
|
|
2407
|
+
name: `qq-qzone-${f.tid}.jpg`,
|
|
2408
|
+
caption: `好友动态 ${f.tid} 的图片(${f.name || f.uin || '好友'})`,
|
|
2409
|
+
})
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
|
|
2413
|
+
return {
|
|
2414
|
+
ok: true,
|
|
2415
|
+
mode: 'feeds',
|
|
2416
|
+
count: feeds.length,
|
|
2417
|
+
feeds,
|
|
2418
|
+
skipped: rawFeeds.length - feeds.length,
|
|
2419
|
+
has_more: !!(data && data.has_more),
|
|
2420
|
+
next_cursor: String((data && data.next_cursor) || ''),
|
|
2421
|
+
images,
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
|
|
2425
|
+
if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
|
|
2426
|
+
return { ok: false, error: 'target_uin 不是有效 QQ 号' }
|
|
2427
|
+
}
|
|
2428
|
+
if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2429
|
+
const body = {
|
|
2430
|
+
pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)),
|
|
2431
|
+
num: count,
|
|
2432
|
+
}
|
|
2433
|
+
if (targetUin) body.target_uin = targetUin
|
|
2434
|
+
const data = await onebot('get_qzone_msg_list', body, cfg)
|
|
2435
|
+
const raw = listOf(data, 'msglist')
|
|
2436
|
+
const posts = raw.map((x) => qzonePostOf(x)).filter((x) => x.tid)
|
|
2437
|
+
const imageItems = []
|
|
2438
|
+
for (const p of posts) {
|
|
2439
|
+
for (const url of p.images) {
|
|
2440
|
+
imageItems.push({
|
|
2441
|
+
url,
|
|
2442
|
+
name: `qq-qzone-${p.tid}.jpg`,
|
|
2443
|
+
caption: `说说 ${p.tid} 的图片${p.content ? '(' + p.content.slice(0, 30) + ')' : ''}`,
|
|
2444
|
+
})
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
const images = withImages ? await admitQzoneImages(admitImages, cfg, imageItems) : []
|
|
2448
|
+
return {
|
|
2449
|
+
ok: true,
|
|
2450
|
+
mode: 'list',
|
|
2451
|
+
target_uin: targetUin || null,
|
|
2452
|
+
total: Number((data && data.total) || posts.length),
|
|
2453
|
+
count: posts.length,
|
|
2454
|
+
posts,
|
|
2455
|
+
images,
|
|
2456
|
+
}
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2459
|
+
}
|
|
2460
|
+
},
|
|
2461
|
+
render(_args, v) {
|
|
2462
|
+
if (!v || !v.ok) return [{ type: 'text', text: '看 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
|
|
2463
|
+
const blocks = []
|
|
2464
|
+
if (v.mode === 'feeds') {
|
|
2465
|
+
if (!v.feeds.length) {
|
|
2466
|
+
return [{ type: 'text', text: '好友动态里现在没有能识别的说说。' + (v.skipped ? `(跳过 ${v.skipped} 条非说说动态)` : '') }]
|
|
2467
|
+
}
|
|
2468
|
+
const lines = [`好友动态(${v.feeds.length} 条${v.has_more ? ',还有更多' : ''}):`]
|
|
2469
|
+
for (const f of v.feeds) {
|
|
2470
|
+
const tag = f.tid ? `[tid=${f.tid}]` : '[没有 tid,只能看]'
|
|
2471
|
+
lines.push(`- ${f.name || f.uin || '好友'}(${f.uin || '?'}) ${tag} ${f.t} 赞 ${f.like_count} 评论 ${f.comment_num}`)
|
|
2472
|
+
if (f.content) lines.push(' ' + f.content.split('\n').join('\n '))
|
|
2473
|
+
}
|
|
2474
|
+
if (v.next_cursor) lines.push(`翻下一页:feeds=true,cursor=${v.next_cursor}`)
|
|
2475
|
+
if (v.skipped) lines.push(`另有 ${v.skipped} 条非说说动态(相册、广告等)没有列出。`)
|
|
2476
|
+
if (v.feeds.some((f) => !f.tid)) lines.push('没有 tid 的动态不能直接点赞/评论,可以配合 qq_get_qzone_post 或去 QQ 客户端看。')
|
|
2477
|
+
blocks.push({ type: 'text', text: lines.join('\n') })
|
|
2478
|
+
} else {
|
|
2479
|
+
if (!v.posts.length) return [{ type: 'text', text: '没有看到说说。' }]
|
|
2480
|
+
const where = v.target_uin ? `QQ ${v.target_uin} 的空间` : '你的空间'
|
|
2481
|
+
const lines = [`${where}(共 ${v.total} 条,本页 ${v.count} 条):`]
|
|
2482
|
+
for (const p of v.posts) {
|
|
2483
|
+
lines.push(`- [tid=${p.tid}] ${p.t} 评论 ${p.comment_num}${p.is_private ? '(仅自己可见)' : ''}`)
|
|
2484
|
+
if (p.content) lines.push(' ' + p.content.split('\n').join('\n '))
|
|
2485
|
+
}
|
|
2486
|
+
lines.push('看评论详情用 qq_get_qzone_post;发新说说用 qq_send_qzone_post。')
|
|
2487
|
+
blocks.push({ type: 'text', text: lines.join('\n') })
|
|
2488
|
+
}
|
|
2489
|
+
for (const img of Array.isArray(v.images) ? v.images : []) {
|
|
2490
|
+
if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
|
|
2491
|
+
if (img && img.block) blocks.push(img.block)
|
|
2492
|
+
}
|
|
2493
|
+
return blocks
|
|
2494
|
+
},
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
export function makeGetQzonePostTool(admitImages) {
|
|
2499
|
+
return {
|
|
2500
|
+
name: 'qq_get_qzone_post',
|
|
2501
|
+
description:
|
|
2502
|
+
'看一条说说的详情和评论(SnowLuma get_qzone_msg_detail)。tid 从 qq_list_qzone 拿。' +
|
|
2503
|
+
'返回评论的 comment_id 和 uin;回复时传给 qq_manage_qzone:' +
|
|
2504
|
+
'回复一级评论传 comment_id + comment_uin,回复楼中楼再传 reply_uin + reply_name。只读。',
|
|
2505
|
+
parameters: {
|
|
2506
|
+
type: 'object',
|
|
2507
|
+
properties: {
|
|
2508
|
+
tid: { type: 'string', description: '说说 tid' },
|
|
2509
|
+
target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
|
|
2510
|
+
pos: { type: 'number', description: '从第几条评论开始,默认 0' },
|
|
2511
|
+
count: { type: 'number', description: '看多少条一级评论,默认 20,最多 50' },
|
|
2512
|
+
include_images: { type: 'boolean', description: '详情里带图片时要不要看图,默认 true' },
|
|
2513
|
+
},
|
|
2514
|
+
required: ['tid'],
|
|
2515
|
+
additionalProperties: false,
|
|
2516
|
+
},
|
|
2517
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2518
|
+
async execute(args) {
|
|
2519
|
+
const cfg = loadCfg()
|
|
2520
|
+
const tid = args && args.tid != null ? String(args.tid).trim() : ''
|
|
2521
|
+
if (!tid) return { ok: false, error: 'tid 不能为空' }
|
|
2522
|
+
const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
|
|
2523
|
+
if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
|
|
2524
|
+
return { ok: false, error: 'target_uin 不是有效 QQ 号' }
|
|
2525
|
+
}
|
|
2526
|
+
if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2527
|
+
const count = Math.max(1, Math.min(Math.floor(Number(args && args.count) || 20), 50))
|
|
2528
|
+
try {
|
|
2529
|
+
const body = { tid, pos: Math.max(0, Math.floor(Number(args && args.pos) || 0)), num: count }
|
|
2530
|
+
if (targetUin) body.target_uin = targetUin
|
|
2531
|
+
const data = await onebot('get_qzone_msg_detail', body, cfg)
|
|
2532
|
+
const comments = (Array.isArray(data && data.comments) ? data.comments : []).map((c) => ({
|
|
2533
|
+
uin: String((c && (c.uin || c.user_id)) || ''),
|
|
2534
|
+
name: clean(c && (c.name || c.nickname)),
|
|
2535
|
+
time: Number((c && c.time) || 0),
|
|
2536
|
+
t: fmtTime(c && c.time),
|
|
2537
|
+
content: cleanMultiline(c && c.content, 1000),
|
|
2538
|
+
comment_id: c && c.comment_id != null ? String(c.comment_id) : '',
|
|
2539
|
+
replies: (Array.isArray(c && c.replies) ? c.replies : []).map((r) => ({
|
|
2540
|
+
uin: String((r && (r.uin || r.user_id)) || ''),
|
|
2541
|
+
name: clean(r && (r.name || r.nickname)),
|
|
2542
|
+
time: Number((r && r.time) || 0),
|
|
2543
|
+
t: fmtTime(r && r.time),
|
|
2544
|
+
content: cleanMultiline(r && r.content, 1000),
|
|
2545
|
+
comment_id: r && r.comment_id != null ? String(r.comment_id) : '',
|
|
2546
|
+
parent_comment_id: r && r.parent_comment_id != null ? String(r.parent_comment_id) : '',
|
|
2547
|
+
parent_comment_uin: r && r.parent_comment_uin != null ? String(r.parent_comment_uin) : '',
|
|
2548
|
+
})),
|
|
2549
|
+
}))
|
|
2550
|
+
const post = {
|
|
2551
|
+
tid: String((data && data.tid) || tid),
|
|
2552
|
+
content: cleanMultiline(data && data.content, 4000),
|
|
2553
|
+
comment_num: Number((data && data.comment_num) || 0),
|
|
2554
|
+
comments,
|
|
2555
|
+
images: qzoneImagesOf(data && data.images),
|
|
2556
|
+
}
|
|
2557
|
+
let images = []
|
|
2558
|
+
if (!(args && args.include_images === false) && post.images.length) {
|
|
2559
|
+
images = await admitQzoneImages(admitImages, cfg, post.images.map((url) => ({
|
|
2560
|
+
url,
|
|
2561
|
+
name: `qq-qzone-${post.tid}.jpg`,
|
|
2562
|
+
caption: `说说 ${post.tid} 的图片`,
|
|
2563
|
+
})))
|
|
2564
|
+
}
|
|
2565
|
+
return { ok: true, post, images }
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2568
|
+
}
|
|
2569
|
+
},
|
|
2570
|
+
render(_args, v) {
|
|
2571
|
+
if (!v || !v.ok) return [{ type: 'text', text: '看说说详情失败:' + String((v && v.error) || '未知错误') }]
|
|
2572
|
+
const p = v.post || {}
|
|
2573
|
+
const lines = [`说说 ${p.tid}:`, p.content || '(没有正文)']
|
|
2574
|
+
lines.push(`评论 ${p.comment_num || 0} 条:`)
|
|
2575
|
+
if (!p.comments.length) lines.push('(还没有评论)')
|
|
2576
|
+
for (const c of p.comments) {
|
|
2577
|
+
lines.push(`- ${c.name || c.uin}(${c.uin}) [comment_id=${c.comment_id}] ${c.t}:${c.content || '(空)'}`)
|
|
2578
|
+
for (const r of c.replies) {
|
|
2579
|
+
lines.push(` ↳ ${r.name || r.uin}(${r.uin}) [comment_id=${r.comment_id} parent=${r.parent_comment_id}] ${r.t}:${r.content || '(空)'}`)
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
if (p.comments.length) {
|
|
2583
|
+
lines.push('回复一级评论:qq_manage_qzone action=comment,传 comment_id + comment_uin=该评论 uin;' +
|
|
2584
|
+
'回复楼中楼再加 reply_uin + reply_name,comment_id 仍用一级评论的 id(不要用二级回复自己的 id)。')
|
|
2585
|
+
}
|
|
2586
|
+
const blocks = [{ type: 'text', text: lines.join('\n') }]
|
|
2587
|
+
for (const img of Array.isArray(v.images) ? v.images : []) {
|
|
2588
|
+
if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
|
|
2589
|
+
if (img && img.block) blocks.push(img.block)
|
|
2590
|
+
}
|
|
2591
|
+
return blocks
|
|
2592
|
+
},
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
export function makeSendQzonePostTool() {
|
|
2597
|
+
return {
|
|
2598
|
+
name: 'qq_send_qzone_post',
|
|
2599
|
+
description:
|
|
2600
|
+
'发一条 QQ 空间说说(SnowLuma send_qzone_msg)。content 是正文;' +
|
|
2601
|
+
'images 是图片(http(s)、base64://、本机路径,最多 9 张),也可以用 message_id 从消息里取图。' +
|
|
2602
|
+
'visibility:public=所有人,friends=好友,partial=指定好友可见,private=仅自己,exclude=指定好友不可见;' +
|
|
2603
|
+
'partial/exclude 要传 target_uins。发说说对外可见,只有用户明确要求时才用。',
|
|
2604
|
+
parameters: {
|
|
2605
|
+
type: 'object',
|
|
2606
|
+
properties: {
|
|
2607
|
+
content: { type: 'string', description: '说说正文,不要 Markdown' },
|
|
2608
|
+
images: { type: 'array', items: { type: 'string' }, description: '图片列表,最多 9 张' },
|
|
2609
|
+
message_id: { type: 'string', description: '从这条消息里取图片一起发' },
|
|
2610
|
+
image_indexes: { type: 'array', items: { type: 'number' }, description: 'message_id 里要哪几张图片(从 0 开始);不传就全取' },
|
|
2611
|
+
visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: '可见范围,默认 public' },
|
|
2612
|
+
target_uins: { type: 'array', items: { type: 'number' }, description: 'partial/exclude 时的 QQ 号列表' },
|
|
2613
|
+
},
|
|
2614
|
+
required: ['content'],
|
|
2615
|
+
additionalProperties: false,
|
|
2616
|
+
},
|
|
2617
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2618
|
+
async execute(args) {
|
|
2619
|
+
const cfg = loadCfg()
|
|
2620
|
+
const content = cleanMultiline(args && args.content, 2000)
|
|
2621
|
+
if (!content) return { ok: false, error: '说说正文是空的' }
|
|
2622
|
+
const visibilityKey = String((args && args.visibility) || 'public')
|
|
2623
|
+
const ugcRight = qzoneRightOf(visibilityKey)
|
|
2624
|
+
if (!ugcRight) return { ok: false, error: 'visibility 只能是 public/friends/partial/private/exclude' }
|
|
2625
|
+
const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
|
|
2626
|
+
.map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
|
|
2627
|
+
if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
|
|
2628
|
+
return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
|
|
2629
|
+
}
|
|
2630
|
+
try {
|
|
2631
|
+
const images = (Array.isArray(args && args.images) ? args.images : [])
|
|
2632
|
+
.map(imageSourceOf).filter(Boolean)
|
|
2633
|
+
const messageId = args && args.message_id
|
|
2634
|
+
if (messageId != null && messageId !== '') {
|
|
2635
|
+
if (images.length >= 9) return { ok: false, error: '最多 9 张图片' }
|
|
2636
|
+
const got = await imageSourcesFromMessage(messageId, args.image_indexes, cfg, 9 - images.length)
|
|
2637
|
+
if (!got.sources.length) {
|
|
2638
|
+
return {
|
|
2639
|
+
ok: false,
|
|
2640
|
+
error: got.total
|
|
2641
|
+
? `选中的图片序号没有匹配到(这条消息共 ${got.total} 张)`
|
|
2642
|
+
: '这条消息里没有图片',
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
images.push(...got.sources)
|
|
2646
|
+
}
|
|
2647
|
+
if (images.length > 9) return { ok: false, error: '最多 9 张图片' }
|
|
2648
|
+
const body = { content, ugc_right: ugcRight }
|
|
2649
|
+
if (images.length) body.images = images
|
|
2650
|
+
if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
|
|
2651
|
+
const data = await onebot('send_qzone_msg', body, cfg)
|
|
2652
|
+
return {
|
|
2653
|
+
ok: true,
|
|
2654
|
+
tid: String((data && data.tid) || ''),
|
|
2655
|
+
time: Number((data && data.time) || 0),
|
|
2656
|
+
visibility: QZONE_VISIBILITY_NAMES[ugcRight],
|
|
2657
|
+
image_count: images.length,
|
|
2658
|
+
}
|
|
2659
|
+
} catch (error) {
|
|
2660
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2661
|
+
}
|
|
2662
|
+
},
|
|
2663
|
+
render(_args, v) {
|
|
2664
|
+
if (!v || !v.ok) return [{ type: 'text', text: '发说说失败:' + String((v && v.error) || '未知错误') }]
|
|
2665
|
+
const extra = v.image_count ? `,${v.image_count} 张图` : ''
|
|
2666
|
+
return [{ type: 'text', text: `说说已发布(tid ${v.tid || '?'}${extra},${v.visibility || '所有人可见'})。` }]
|
|
2667
|
+
},
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
export function makeManageQzoneTool() {
|
|
2672
|
+
return {
|
|
2673
|
+
name: 'qq_manage_qzone',
|
|
2674
|
+
description:
|
|
2675
|
+
'管理 QQ 空间说说。action:\n' +
|
|
2676
|
+
'- like / unlike:点赞或取消赞,需要 tid(abstime 传列表里的 time 更可靠)\n' +
|
|
2677
|
+
'- comment:评论或回复,需要 tid + content;回复一级评论再传 comment_id + comment_uin,' +
|
|
2678
|
+
'回复楼中楼再传 reply_uin + reply_name;只有普通评论能带图\n' +
|
|
2679
|
+
'- delete:删除自己空间的一条说说,需要 tid + confirm=true,不可撤销\n' +
|
|
2680
|
+
'- set_right:改自己空间一条说说的可见范围,需要 tid + visibility(partial/exclude 再传 target_uins)\n' +
|
|
2681
|
+
'- ban / unban:拉黑或解除拉黑某人的空间访问,需要 user_id',
|
|
2682
|
+
parameters: {
|
|
2683
|
+
type: 'object',
|
|
2684
|
+
properties: {
|
|
2685
|
+
action: {
|
|
2686
|
+
type: 'string',
|
|
2687
|
+
enum: ['like', 'unlike', 'comment', 'delete', 'set_right', 'ban', 'unban'],
|
|
2688
|
+
description: '要做什么',
|
|
2689
|
+
},
|
|
2690
|
+
tid: { type: 'string', description: '说说 tid;like/unlike/comment/delete/set_right 需要' },
|
|
2691
|
+
target_uin: { type: 'number', description: '说说所属 QQ 号,默认自己' },
|
|
2692
|
+
abstime: { type: 'number', description: '说说发表时间(unix 秒),点赞时传更可靠' },
|
|
2693
|
+
content: { type: 'string', description: '评论内容(action=comment)' },
|
|
2694
|
+
images: { type: 'array', items: { type: 'string' }, description: '评论图片(普通评论可选,最多 9 张)' },
|
|
2695
|
+
comment_id: { type: 'string', description: '要回复的一级评论 ID(评论详情里的 comment_id)' },
|
|
2696
|
+
comment_uin: { type: 'number', description: '一级评论作者 QQ 号' },
|
|
2697
|
+
reply_uin: { type: 'number', description: '楼中楼里要回复的用户 QQ 号' },
|
|
2698
|
+
reply_name: { type: 'string', description: '楼中楼里要回复的用户昵称' },
|
|
2699
|
+
visibility: { type: 'string', enum: ['public', 'friends', 'partial', 'private', 'exclude'], description: 'set_right 的新可见范围' },
|
|
2700
|
+
target_uins: { type: 'array', items: { type: 'number' }, description: 'set_right 时 partial/exclude 的 QQ 号列表' },
|
|
2701
|
+
user_id: { type: 'number', description: 'ban/unban 的目标 QQ 号' },
|
|
2702
|
+
confirm: { type: 'boolean', description: 'delete 时必须显式传 true' },
|
|
2703
|
+
},
|
|
2704
|
+
required: ['action'],
|
|
2705
|
+
additionalProperties: false,
|
|
2706
|
+
},
|
|
2707
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
|
|
2708
|
+
async execute(args) {
|
|
2709
|
+
const cfg = loadCfg()
|
|
2710
|
+
const action = String((args && args.action) || '')
|
|
2711
|
+
const tid = args && args.tid != null ? String(args.tid).trim() : ''
|
|
2712
|
+
const targetUin = args && args.target_uin != null ? Number(args.target_uin) : 0
|
|
2713
|
+
if (args && args.target_uin != null && !Number.isFinite(targetUin)) {
|
|
2714
|
+
return { ok: false, error: 'target_uin 不是有效 QQ 号' }
|
|
2715
|
+
}
|
|
2716
|
+
if (targetUin && !allowedPrivate(targetUin, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2717
|
+
const needTid = ['like', 'unlike', 'comment', 'delete', 'set_right']
|
|
2718
|
+
if (needTid.includes(action) && !tid) return { ok: false, error: action + ' 需要 tid' }
|
|
2719
|
+
try {
|
|
2720
|
+
if (action === 'like' || action === 'unlike') {
|
|
2721
|
+
const body = { tid }
|
|
2722
|
+
if (targetUin) body.target_uin = targetUin
|
|
2723
|
+
const abstime = Number(args && args.abstime)
|
|
2724
|
+
if (Number.isFinite(abstime) && abstime > 0) body.abstime = Math.floor(abstime)
|
|
2725
|
+
await onebot(action === 'like' ? 'like_qzone' : 'unlike_qzone', body, cfg)
|
|
2726
|
+
return { ok: true, action, tid, liked: action === 'like' }
|
|
2727
|
+
}
|
|
2728
|
+
if (action === 'comment') {
|
|
2729
|
+
const content = cleanMultiline(args && args.content, 1000)
|
|
2730
|
+
if (!content) return { ok: false, error: '评论内容是空的' }
|
|
2731
|
+
const images = (Array.isArray(args && args.images) ? args.images : [])
|
|
2732
|
+
.map(imageSourceOf).filter(Boolean)
|
|
2733
|
+
if (images.length > 9) return { ok: false, error: '评论最多 9 张图片' }
|
|
2734
|
+
const parentId = args && args.comment_id != null ? String(args.comment_id).trim() : ''
|
|
2735
|
+
const hasParentUin = args && args.comment_uin != null
|
|
2736
|
+
const parentUin = hasParentUin ? Number(args.comment_uin) : 0
|
|
2737
|
+
const hasReplyUin = args && args.reply_uin != null
|
|
2738
|
+
const replyUin = hasReplyUin ? Number(args.reply_uin) : 0
|
|
2739
|
+
const replyName = clean(args && args.reply_name)
|
|
2740
|
+
if (parentId && (!hasParentUin || !Number.isInteger(parentUin) || parentUin <= 0)) {
|
|
2741
|
+
return { ok: false, error: 'comment_id 和 comment_uin 要一起传,comment_uin 是正整数 QQ 号' }
|
|
2742
|
+
}
|
|
2743
|
+
if (hasParentUin && !parentId) {
|
|
2744
|
+
return { ok: false, error: 'comment_uin 要配合 comment_id 一起传' }
|
|
2745
|
+
}
|
|
2746
|
+
if (hasReplyUin && (!Number.isInteger(replyUin) || replyUin <= 0)) {
|
|
2747
|
+
return { ok: false, error: 'reply_uin 不是有效 QQ 号' }
|
|
2748
|
+
}
|
|
2749
|
+
if (hasReplyUin && (!parentId || !replyName)) {
|
|
2750
|
+
return { ok: false, error: '回复楼中楼需要 comment_id + comment_uin + reply_uin + reply_name' }
|
|
2751
|
+
}
|
|
2752
|
+
if (parentId && images.length) return { ok: false, error: '回复评论只能发文字,不能带图' }
|
|
2753
|
+
const body = { tid, content }
|
|
2754
|
+
if (targetUin) body.target_uin = targetUin
|
|
2755
|
+
if (images.length) body.images = images
|
|
2756
|
+
if (parentId) {
|
|
2757
|
+
body.comment_id = parentId
|
|
2758
|
+
body.comment_uin = parentUin
|
|
2759
|
+
}
|
|
2760
|
+
if (replyUin) {
|
|
2761
|
+
body.reply_uin = replyUin
|
|
2762
|
+
body.reply_name = replyName
|
|
2763
|
+
}
|
|
2764
|
+
const data = await onebot('comment_qzone', body, cfg)
|
|
2765
|
+
return {
|
|
2766
|
+
ok: true,
|
|
2767
|
+
action,
|
|
2768
|
+
tid,
|
|
2769
|
+
comment_id: data && data.comment_id != null ? String(data.comment_id) : '',
|
|
2770
|
+
parent_comment_id: data && data.parent_comment_id != null ? String(data.parent_comment_id) : '',
|
|
2771
|
+
parent_comment_uin: Number((data && data.parent_comment_uin) || 0),
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
if (action === 'delete') {
|
|
2775
|
+
if (!args || args.confirm !== true) {
|
|
2776
|
+
return { ok: false, error: '删说说不可撤销,需要 confirm=true 才会执行' }
|
|
2777
|
+
}
|
|
2778
|
+
await onebot('delete_qzone_msg', { tid }, cfg)
|
|
2779
|
+
return { ok: true, action, tid, deleted: true }
|
|
2780
|
+
}
|
|
2781
|
+
if (action === 'set_right') {
|
|
2782
|
+
const ugcRight = qzoneRightOf(args && args.visibility)
|
|
2783
|
+
if (!ugcRight) return { ok: false, error: 'set_right 需要 visibility(public/friends/partial/private/exclude)' }
|
|
2784
|
+
const targetUins = (Array.isArray(args && args.target_uins) ? args.target_uins : [])
|
|
2785
|
+
.map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0)
|
|
2786
|
+
if ((ugcRight === 16 || ugcRight === 128) && !targetUins.length) {
|
|
2787
|
+
return { ok: false, error: 'partial/exclude 需要 target_uins 指定名单' }
|
|
2788
|
+
}
|
|
2789
|
+
const body = { tid, ugc_right: ugcRight }
|
|
2790
|
+
if (ugcRight === 16 || ugcRight === 128) body.target_uins = targetUins
|
|
2791
|
+
const data = await onebot('set_qzone_msg_right', body, cfg)
|
|
2792
|
+
return {
|
|
2793
|
+
ok: true,
|
|
2794
|
+
action,
|
|
2795
|
+
tid,
|
|
2796
|
+
ugc_right: Number((data && data.ugc_right) || ugcRight),
|
|
2797
|
+
visibility: QZONE_VISIBILITY_NAMES[Number((data && data.ugc_right) || ugcRight)],
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (action === 'ban' || action === 'unban') {
|
|
2801
|
+
const userId = Number(args && args.user_id)
|
|
2802
|
+
if (!Number.isFinite(userId) || !userId) return { ok: false, error: 'ban/unban 需要 user_id' }
|
|
2803
|
+
if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
|
|
2804
|
+
await onebot('set_qzone_ban', { user_id: userId, enable: action === 'ban' }, cfg)
|
|
2805
|
+
return { ok: true, action, user_id: userId, banned: action === 'ban' }
|
|
2806
|
+
}
|
|
2807
|
+
return { ok: false, error: '不认识的 action:' + action }
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
2810
|
+
}
|
|
2811
|
+
},
|
|
2812
|
+
render(_args, v) {
|
|
2813
|
+
if (!v || !v.ok) return [{ type: 'text', text: '操作 QQ 空间失败:' + String((v && v.error) || '未知错误') }]
|
|
2814
|
+
if (v.action === 'like' || v.action === 'unlike') {
|
|
2815
|
+
return [{ type: 'text', text: `${v.liked ? '已点赞' : '已取消赞'}(tid ${v.tid})。` }]
|
|
2816
|
+
}
|
|
2817
|
+
if (v.action === 'comment') {
|
|
2818
|
+
return [{ type: 'text', text: `评论已发(tid ${v.tid},comment_id ${v.comment_id || '?'})。` }]
|
|
2819
|
+
}
|
|
2820
|
+
if (v.action === 'delete') {
|
|
2821
|
+
return [{ type: 'text', text: `已删除说说 ${v.tid}。` }]
|
|
2822
|
+
}
|
|
2823
|
+
if (v.action === 'set_right') {
|
|
2824
|
+
return [{ type: 'text', text: `说说 ${v.tid} 的可见范围已改为「${v.visibility || v.ugc_right}」。` }]
|
|
2825
|
+
}
|
|
2826
|
+
if (v.action === 'ban' || v.action === 'unban') {
|
|
2827
|
+
return [{ type: 'text', text: `已${v.banned ? '拉黑' : '解除拉黑'} QQ ${v.user_id} 的空间访问。` }]
|
|
2828
|
+
}
|
|
2829
|
+
return [{ type: 'text', text: 'QQ 空间操作完成。' }]
|
|
2830
|
+
},
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
|
|
1580
2835
|
// ---------- 当前活着的工具组件 ----------
|
|
1581
2836
|
// @ 提醒的提示词只提还开着的工具,免得模型去调一个被关掉的组件。
|
|
1582
2837
|
const activeCounts = new Map()
|
|
@@ -1741,13 +2996,30 @@ async function pollMentions(ctx) {
|
|
|
1741
2996
|
)
|
|
1742
2997
|
const text = segText(segs) || '(没有文字)'
|
|
1743
2998
|
const name = clean((m.sender && (m.sender.card || m.sender.nickname)) || String(uid))
|
|
2999
|
+
const senderQQ = String(uid || (m.sender && m.sender.user_id) || '?')
|
|
1744
3000
|
const winUntil = windows.get(String(gid)) || 0
|
|
1745
3001
|
if (Date.now() < winUntil) {
|
|
1746
3002
|
const arr = followups.get(String(gid)) || []
|
|
1747
|
-
arr.push({
|
|
3003
|
+
arr.push({
|
|
3004
|
+
gid: String(gid),
|
|
3005
|
+
group: names.get(String(gid)) || String(gid),
|
|
3006
|
+
sender: name,
|
|
3007
|
+
user_id: senderQQ,
|
|
3008
|
+
message_id: mid,
|
|
3009
|
+
text,
|
|
3010
|
+
time: t,
|
|
3011
|
+
})
|
|
1748
3012
|
followups.set(String(gid), arr)
|
|
1749
3013
|
} else if (atMe) {
|
|
1750
|
-
mentions.push({
|
|
3014
|
+
mentions.push({
|
|
3015
|
+
gid: String(gid),
|
|
3016
|
+
group: names.get(String(gid)) || String(gid),
|
|
3017
|
+
sender: name,
|
|
3018
|
+
user_id: senderQQ,
|
|
3019
|
+
message_id: mid,
|
|
3020
|
+
text,
|
|
3021
|
+
time: t,
|
|
3022
|
+
})
|
|
1751
3023
|
}
|
|
1752
3024
|
}
|
|
1753
3025
|
lastSeen.set(String(gid), maxTime)
|
|
@@ -1755,15 +3027,19 @@ async function pollMentions(ctx) {
|
|
|
1755
3027
|
if (seen.size > 500) seen.clear()
|
|
1756
3028
|
for (const [gid, until] of windows) if (Date.now() >= until) windows.delete(gid)
|
|
1757
3029
|
if (!mentions.length && followups.size === 0) return
|
|
1758
|
-
const
|
|
3030
|
+
const fmtLine = (x) => formatIncomingLine(x.sender, x.user_id, x.text, x.message_id)
|
|
3031
|
+
const lines = mentions.slice(-5).map((x) => `群「${x.group}」${fmtLine(x)}`)
|
|
1759
3032
|
const sendTools = []
|
|
1760
3033
|
if (hasActiveTool('qq_send_group_message')) sendTools.push('qq_send_group_message')
|
|
1761
3034
|
if (hasActiveTool('qq_send_face')) sendTools.push('qq_send_face')
|
|
1762
3035
|
const sendHint = sendTools.length ? '想说话可以用 ' + sendTools.join(' / ') + ' 回复;' : ''
|
|
3036
|
+
const quoteHint = hasActiveTool('qq_send_group_message')
|
|
3037
|
+
? '想引用某条消息就传 reply_to=消息编号;想 @ 人就传 at_user_ids=[QQ号],@全体传 at_all=true;'
|
|
3038
|
+
: ''
|
|
1763
3039
|
const prompt =
|
|
1764
3040
|
'【群聊 @ 提醒】有人 @ 了你:\n' +
|
|
1765
3041
|
lines.join('\n') +
|
|
1766
|
-
'\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + '不想说话就安静待着;' +
|
|
3042
|
+
'\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + quoteHint + '不想说话就安静待着;' +
|
|
1767
3043
|
'不要用 Markdown,不要提工具和私聊内容,短一点。'
|
|
1768
3044
|
const message = {
|
|
1769
3045
|
id: randomUUID(),
|
|
@@ -1777,12 +3053,12 @@ async function pollMentions(ctx) {
|
|
|
1777
3053
|
}
|
|
1778
3054
|
for (const [gid, list] of followups) {
|
|
1779
3055
|
const group = list[0] && list[0].group ? list[0].group : gid
|
|
1780
|
-
const lines2 = list.slice(-10).map(
|
|
3056
|
+
const lines2 = list.slice(-10).map(fmtLine)
|
|
1781
3057
|
const prompt2 =
|
|
1782
3058
|
'【群聊后续】你刚才在群「' + group + '」里说过话,现在又有人说话:\n' +
|
|
1783
3059
|
lines2.join('\n') +
|
|
1784
3060
|
'\n想接就接;' +
|
|
1785
|
-
(hasActiveTool('qq_send_group_message') ? '想说话用 qq_send_group_message
|
|
3061
|
+
(hasActiveTool('qq_send_group_message') ? '想说话用 qq_send_group_message 发到这个群,引用某条传 reply_to=消息编号,@人传 at_user_ids=[QQ号],@全体传 at_all=true;' : '') +
|
|
1786
3062
|
(hasActiveTool('qq_send_face') ? '想回表情用 qq_send_face;' : '') +
|
|
1787
3063
|
'不想接就安静待着。不要用 Markdown,短一点。'
|
|
1788
3064
|
await agent.followup({
|
|
@@ -1909,20 +3185,20 @@ async function handlePrivateMessage(ctx, ev, admitImages) {
|
|
|
1909
3185
|
const segs = Array.isArray(ev.message) ? ev.message : []
|
|
1910
3186
|
const text = segText(segs) || clean(ev.raw_message) || '(没有文字)'
|
|
1911
3187
|
const groupId = ev.group_id ? String(ev.group_id) : ''
|
|
3188
|
+
const midHint = ev.message_id != null ? ',引用传 reply_to=' + ev.message_id : ''
|
|
1912
3189
|
const actions = []
|
|
1913
3190
|
if (hasActiveTool('qq_get_private_history')) {
|
|
1914
3191
|
actions.push('想看上下文用 qq_get_private_history(user_id=' + userId + ')')
|
|
1915
3192
|
}
|
|
1916
3193
|
if (hasActiveTool('qq_send_private_message')) {
|
|
1917
|
-
actions.push('想回就用 qq_send_private_message(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + ')')
|
|
3194
|
+
actions.push('想回就用 qq_send_private_message(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + midHint + ')')
|
|
1918
3195
|
}
|
|
1919
3196
|
if (hasActiveTool('qq_send_face')) {
|
|
1920
|
-
actions.push('想回表情用 qq_send_face(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + ')')
|
|
3197
|
+
actions.push('想回表情用 qq_send_face(user_id=' + userId + (groupId ? ', group_id=' + groupId : '') + midHint + ')')
|
|
1921
3198
|
}
|
|
1922
3199
|
const where = groupId ? '群 ' + groupId + ' 的临时会话' : '私聊'
|
|
1923
3200
|
const prompt =
|
|
1924
|
-
'【QQ ' + where + '】' + name
|
|
1925
|
-
(ev.message_id != null ? '\n(消息 #' + ev.message_id + ')' : '') +
|
|
3201
|
+
'【QQ ' + where + '】' + formatIncomingLine(name, userId, text, ev.message_id) +
|
|
1926
3202
|
'\n' + (actions.length ? actions.join(';') + '。' : '') +
|
|
1927
3203
|
'不想回就安静待着;不要用 Markdown,不要提工具,短一点。'
|
|
1928
3204
|
const content = [{ type: 'text', text: prompt }]
|
|
@@ -2047,14 +3323,32 @@ export function mountAll(ctx) {
|
|
|
2047
3323
|
const faceList = mountOneTool(ctx, makeListFacesTool(admitImages))
|
|
2048
3324
|
const faceFav = mountOneTool(ctx, makeFavoriteFaceTool())
|
|
2049
3325
|
const poke = mountOneTool(ctx, makePokeTool(startObservation))
|
|
3326
|
+
const avatar = mountOneTool(ctx, makeSetAvatarTool())
|
|
2050
3327
|
const friends = mountOneTool(ctx, makeListFriendsTool())
|
|
2051
3328
|
const privateHistory = mountOneTool(ctx, makePrivateHistoryTool(admitImages))
|
|
2052
3329
|
const privateSend = mountOneTool(ctx, makeSendPrivateTool())
|
|
3330
|
+
const friendInfo = mountOneTool(ctx, makeGetFriendInfoTool())
|
|
3331
|
+
const friendCategories = mountOneTool(ctx, makeListFriendCategoriesTool())
|
|
3332
|
+
const friendUpdate = mountOneTool(ctx, makeUpdateFriendTool())
|
|
3333
|
+
const friendDelete = mountOneTool(ctx, makeDeleteFriendTool())
|
|
3334
|
+
const friendLike = mountOneTool(ctx, makeSendLikeTool())
|
|
3335
|
+
const friendRequests = mountOneTool(ctx, makeListFriendRequestsTool())
|
|
3336
|
+
const friendHandle = mountOneTool(ctx, makeHandleFriendRequestTool())
|
|
3337
|
+
const qzoneList = mountOneTool(ctx, makeListQzoneTool(admitImages))
|
|
3338
|
+
const qzoneDetail = mountOneTool(ctx, makeGetQzonePostTool(admitImages))
|
|
3339
|
+
const qzoneSend = mountOneTool(ctx, makeSendQzonePostTool())
|
|
3340
|
+
const qzoneManage = mountOneTool(ctx, makeManageQzoneTool())
|
|
2053
3341
|
startMentionPolling(ctx)
|
|
2054
3342
|
startPokeWatcher(ctx)
|
|
2055
3343
|
startPrivateWatcher(ctx, admitImages)
|
|
3344
|
+
startFriendRequestWatcher(ctx)
|
|
2056
3345
|
return {
|
|
2057
|
-
tools: [
|
|
3346
|
+
tools: [
|
|
3347
|
+
list, history, members, send, face, faceList, faceFav, poke, avatar,
|
|
3348
|
+
friends, privateHistory, privateSend,
|
|
3349
|
+
friendInfo, friendCategories, friendUpdate, friendDelete, friendLike, friendRequests, friendHandle,
|
|
3350
|
+
qzoneList, qzoneDetail, qzoneSend, qzoneManage,
|
|
3351
|
+
],
|
|
2058
3352
|
startObservation,
|
|
2059
3353
|
}
|
|
2060
3354
|
}
|