dsh-qq-tools 0.9.1 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -3
  2. package/lib/shared.js +88 -36
  3. package/package.json +9 -1
package/README.md CHANGED
@@ -189,7 +189,7 @@ dsh plugin --profile <profile> add dsh-qq-tools
189
189
  需要固定版本时:
190
190
 
191
191
  ```sh
192
- dsh plugin --profile <profile> add dsh-qq-tools@0.9.1
192
+ dsh plugin --profile <profile> add dsh-qq-tools@0.9.2
193
193
  ```
194
194
 
195
195
  ### 本地开发 / 自己改代码
@@ -202,7 +202,7 @@ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
202
202
 
203
203
  ```sh
204
204
  pnpm pack
205
- dsh plugin --profile <profile> add ./dsh-qq-tools-0.9.1.tgz
205
+ dsh plugin --profile <profile> add ./dsh-qq-tools-0.9.2.tgz
206
206
  ```
207
207
 
208
208
  `dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
@@ -211,6 +211,14 @@ dsh plugin --profile <profile> add ./dsh-qq-tools-0.9.1.tgz
211
211
  dsh plugin --profile <profile> remove dsh-qq-tools
212
212
  ```
213
213
 
214
+ ## 兼容性、权限与商城状态
215
+
216
+ Node.js 要求为 `>=22`,DSH 支持范围为 `0.2.x`,与原有依赖说明一致。机器可读版本范围声明位于 `package.json` 的 `dsh.compatibility`。
217
+
218
+ 本次声明依据、已获得的安装证据及未验证项见 [整改记录](docs/verification/README.md)。用户已自行测试现有功能;本次不重复功能测试,也不将手工使用情况冒充完整的商城生命周期验收。
219
+
220
+ 本插件需要文件、网络和 OneBot token 权限。配置/诊断日志、本地图片读取、外部服务、账号写操作及失败边界详见 [SECURITY.md](SECURITY.md)。这些能力是 QQ 桥接的实际需要;补齐声明和验收证据不保证 DSH STORE 自动批准,商城可能仍要求 `user-reviewed` 专项审阅或保持 `blocked`。本仓库的验收记录不构成独立安全审计。
221
+
214
222
  ## 依赖
215
223
 
216
224
  - DSH 0.2.x(cordis 插件体系)
@@ -222,4 +230,4 @@ dsh plugin --profile <profile> remove dsh-qq-tools
222
230
 
223
231
  ---
224
232
 
225
- 版本:0.9.1
233
+ 版本:0.9.2
package/lib/shared.js CHANGED
@@ -450,6 +450,33 @@ export function formatIncomingLine(name, userId, text, messageId) {
450
450
  return `${who}(${qq}):${text || '(空)'}` + (messageId != null ? `(消息 #${messageId})` : '')
451
451
  }
452
452
 
453
+ // 回复风格:群聊要像随手打字,短、分条、不刷屏地 @ 和引用。
454
+ export const CHAT_STYLE = '回复像群友随手打字:想连发几条就传 messages,每条一句短话(十几二十个字),最多三条;'
455
+ + '不要句号收尾,不要写长段落,不要 Markdown,不要把昵称或 QQ 号写进正文,不要每句都 @ 或引用——'
456
+ + '只有确实要指代某条消息时才传 reply_to,只有必须提醒某个人时才传 at_user_ids。'
457
+
458
+ const CHAT_BUBBLE_CHARS = 80
459
+ const CHAT_BUBBLE_MAX = 5
460
+
461
+ // 一条气泡的清理:压掉复制来的对齐空格和多行,去掉正文开头重复的 @昵称。
462
+ function cleanBubble(value) {
463
+ return String(value || '')
464
+ .replace(/\r/g, '')
465
+ .replace(/[\u3000\t ]+/g, ' ')
466
+ .replace(/\s*\n\s*/g, ' ')
467
+ .trim()
468
+ .replace(/^@[^\s@]{1,40}\s+/, '')
469
+ .slice(0, CHAT_BUBBLE_CHARS)
470
+ }
471
+
472
+ // 模型自己决定发几条:messages 数组每条一个气泡;只给 message 就是一条,不做机械切分。
473
+ export function chatMessages(args) {
474
+ const list = Array.isArray(args && args.messages) ? args.messages : []
475
+ const single = args && args.message
476
+ const source = list.length ? list : (single ? [single] : [])
477
+ return source.map(cleanBubble).filter(Boolean).slice(0, CHAT_BUBBLE_MAX)
478
+ }
479
+
453
480
 
454
481
  function renderTool(_args, v) {
455
482
  if (!v || !v.ok) return [{ type: 'text', text: 'QQ 工具失败:' + String((v && v.error) || '未知错误') }]
@@ -924,17 +951,21 @@ export function makeSendTool(onSent) {
924
951
  return {
925
952
  name: 'qq_send_group_message',
926
953
  description:
927
- '在指定群里发一条消息。发之前先确认群号;只发到允许的群。' +
954
+ '在指定群里发消息。发之前先确认群号;只发到允许的群。' +
928
955
  'reply_to 传消息前面的 #编号 可以引用那条消息;at_user_ids 传 QQ 号可以 @ 人,at_all=true 是 @全体成员。' +
929
- '普通闲聊不要用 Markdown;一句话短一点,像群友随手打的。' +
956
+ CHAT_STYLE +
930
957
  '不想说话就不要调用这个工具;调用后不要再重复说自己发了什么。',
931
958
  parameters: {
932
959
  type: 'object',
933
960
  properties: {
934
961
  group_id: { type: 'number', description: '群号' },
935
- message: { type: 'string', description: '要发到群里的文字;只 @ 不说话时可以留空' },
936
- reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
937
- at_user_ids: { type: 'array', items: { type: 'number' }, description: '要 @ 的 QQ 号列表,可选' },
962
+ messages: {
963
+ type: 'array', items: { type: 'string' },
964
+ description: '想连发几条就放这里,每条一个气泡、一句短话;最多 5 条',
965
+ },
966
+ message: { type: 'string', description: '只发一条时的文字;要发多条用 messages;只 @ 不说话时可以留空' },
967
+ reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选', },
968
+ at_user_ids: { type: 'array', items: { type: 'number' }, description: '要 @ 的 QQ 号列表,可选', },
938
969
  at_all: { type: 'boolean', description: 'true = @全体成员,可选' },
939
970
  },
940
971
  required: ['group_id'],
@@ -944,28 +975,37 @@ export function makeSendTool(onSent) {
944
975
  async execute(args) {
945
976
  const cfg = loadCfg()
946
977
  const groupId = Number(args && args.group_id)
947
- const text = String((args && args.message) || '').trim()
978
+ const bubbles = chatMessages(args)
948
979
  const atIds = [...new Set(
949
980
  (Array.isArray(args && args.at_user_ids) ? args.at_user_ids : [])
950
981
  .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0),
951
982
  )].slice(0, 20)
952
983
  const atAll = args && args.at_all === true
953
984
  if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
954
- if (!text && !atIds.length && !atAll) return { ok: false, error: '消息是空的' }
985
+ if (!bubbles.length && !atIds.length && !atAll) return { ok: false, error: '消息是空的' }
955
986
  if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
956
987
  try {
957
- const message = []
958
988
  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)
989
+ const messageIds = []
990
+ for (const [index, segment] of (bubbles.length ? bubbles : ['']).entries()) {
991
+ const message = []
992
+ // 引用和 @ 只挂在第一条上,避免每条都刷一遍。
993
+ if (index === 0 && replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
994
+ if (index === 0) {
995
+ if (atAll) message.push({ type: 'at', data: { qq: 'all' } })
996
+ else for (const qq of atIds) message.push({ type: 'at', data: { qq: String(qq) } })
997
+ }
998
+ if (segment) message.push({ type: 'text', data: { text: segment } })
999
+ const data = await onebot('send_group_msg', { group_id: groupId, message }, cfg)
1000
+ messageIds.push(data && data.message_id)
1001
+ }
964
1002
  try { if (typeof onSent === 'function') onSent(groupId, '发群消息') } catch (error) {}
965
1003
  return {
966
1004
  ok: true,
967
1005
  group_id: groupId,
968
- message_id: data && data.message_id,
1006
+ message_id: messageIds[0],
1007
+ message_ids: messageIds,
1008
+ segments: bubbles.length,
969
1009
  reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
970
1010
  at_user_ids: atAll ? [] : atIds,
971
1011
  at_all: atAll,
@@ -980,7 +1020,8 @@ export function makeSendTool(onSent) {
980
1020
  if (v.reply_to) extra.push('引用了 #' + v.reply_to)
981
1021
  if (v.at_all) extra.push('@全体成员')
982
1022
  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(',') : ''})。` }]
1023
+ const count = Array.isArray(v.message_ids) && v.message_ids.length > 1 ? `,分 ${v.message_ids.length} 条` : ''
1024
+ return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'}${count}${extra.length ? ',' + extra.join(',') : ''})。` }]
984
1025
  },
985
1026
  }
986
1027
  }
@@ -1125,42 +1166,52 @@ export function makeSendPrivateTool() {
1125
1166
  return {
1126
1167
  name: 'qq_send_private_message',
1127
1168
  description:
1128
- '给某个 QQ 好友发一条私聊消息。user_id 从 qq_list_friends 或私聊唤醒提示里拿。' +
1169
+ '给某个 QQ 好友发私聊消息。user_id 从 qq_list_friends 或私聊唤醒提示里拿。' +
1129
1170
  'reply_to 传消息前面的 #编号 可以引用那条消息。' +
1130
- '普通闲聊不要用 Markdown;一句话短一点,像聊天一样。' +
1171
+ CHAT_STYLE +
1131
1172
  '不想说话就不要调用;调用后不要再重复说自己发了什么。',
1132
1173
  parameters: {
1133
1174
  type: 'object',
1134
1175
  properties: {
1135
1176
  user_id: { type: 'number', description: '对方的 QQ 号' },
1136
- message: { type: 'string', description: '要发的文字,不要 Markdown' },
1177
+ messages: {
1178
+ type: 'array', items: { type: 'string' },
1179
+ description: '想连发几条就放这里,每条一个气泡、一句短话;最多 5 条',
1180
+ },
1181
+ message: { type: 'string', description: '只发一条时的文字;要发多条用 messages' },
1137
1182
  reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
1138
1183
  group_id: { type: 'number', description: '临时会话的来源群号,可选;普通好友私聊不用传' },
1139
1184
  },
1140
- required: ['user_id', 'message'],
1185
+ required: ['user_id'],
1141
1186
  additionalProperties: false,
1142
1187
  },
1143
1188
  output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
1144
1189
  async execute(args) {
1145
1190
  const cfg = loadCfg()
1146
1191
  const userId = Number(args && args.user_id)
1147
- const text = String((args && args.message) || '').trim()
1192
+ const bubbles = chatMessages(args)
1148
1193
  const groupId = args && args.group_id != null ? Number(args.group_id) : 0
1149
1194
  if (!Number.isFinite(userId)) return { ok: false, error: 'user_id 不是有效 QQ 号' }
1150
- if (!text) return { ok: false, error: '消息是空的' }
1195
+ if (!bubbles.length) return { ok: false, error: '消息是空的' }
1151
1196
  if (!allowedPrivate(userId, cfg)) return { ok: false, error: '这个 QQ 不在允许列表里' }
1152
1197
  try {
1153
- const message = []
1154
1198
  const replyTo = args && args.reply_to
1155
- if (replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
1156
- message.push({ type: 'text', data: { text: text.slice(0, 1000) } })
1157
- const body = { user_id: userId, message }
1158
- if (groupId) body.group_id = groupId
1159
- const data = await onebot('send_private_msg', body, cfg)
1199
+ const messageIds = []
1200
+ for (const [index, segment] of bubbles.entries()) {
1201
+ const message = []
1202
+ if (index === 0 && replyTo != null && replyTo !== '') message.push({ type: 'reply', data: { id: String(replyTo) } })
1203
+ message.push({ type: 'text', data: { text: segment } })
1204
+ const body = { user_id: userId, message }
1205
+ if (groupId) body.group_id = groupId
1206
+ const data = await onebot('send_private_msg', body, cfg)
1207
+ messageIds.push(data && data.message_id)
1208
+ }
1160
1209
  return {
1161
1210
  ok: true,
1162
1211
  user_id: userId,
1163
- message_id: data && data.message_id,
1212
+ message_id: messageIds[0],
1213
+ message_ids: messageIds,
1214
+ segments: bubbles.length,
1164
1215
  reply_to: replyTo != null && replyTo !== '' ? String(replyTo) : '',
1165
1216
  }
1166
1217
  } catch (error) {
@@ -1170,7 +1221,8 @@ export function makeSendPrivateTool() {
1170
1221
  render(_args, v) {
1171
1222
  if (!v || !v.ok) return [{ type: 'text', text: '发私聊消息失败:' + String((v && v.error) || '未知错误') }]
1172
1223
  const quote = v.reply_to ? ',引用了 #' + v.reply_to : ''
1173
- return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'}${quote})。` }]
1224
+ const count = Array.isArray(v.message_ids) && v.message_ids.length > 1 ? `,分 ${v.message_ids.length} 条` : ''
1225
+ return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'}${count}${quote})。` }]
1174
1226
  },
1175
1227
  }
1176
1228
  }
@@ -3034,13 +3086,13 @@ async function pollMentions(ctx) {
3034
3086
  if (hasActiveTool('qq_send_face')) sendTools.push('qq_send_face')
3035
3087
  const sendHint = sendTools.length ? '想说话可以用 ' + sendTools.join(' / ') + ' 回复;' : ''
3036
3088
  const quoteHint = hasActiveTool('qq_send_group_message')
3037
- ? '想引用某条消息就传 reply_to=消息编号;想 @ 人就传 at_user_ids=[QQ号],@全体传 at_all=true;'
3089
+ ? '确实要指代某条消息时才传 reply_to=消息编号;必须提醒某个人时才传 at_user_ids=[QQ号];'
3038
3090
  : ''
3039
3091
  const prompt =
3040
3092
  '【群聊 @ 提醒】有人 @ 了你:\n' +
3041
3093
  lines.join('\n') +
3042
3094
  '\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + quoteHint + '不想说话就安静待着;' +
3043
- '不要用 Markdown,不要提工具和私聊内容,短一点。'
3095
+ '不要提工具和私聊内容。' + CHAT_STYLE
3044
3096
  const message = {
3045
3097
  id: randomUUID(),
3046
3098
  role: 'user',
@@ -3058,9 +3110,9 @@ async function pollMentions(ctx) {
3058
3110
  '【群聊后续】你刚才在群「' + group + '」里说过话,现在又有人说话:\n' +
3059
3111
  lines2.join('\n') +
3060
3112
  '\n想接就接;' +
3061
- (hasActiveTool('qq_send_group_message') ? '想说话用 qq_send_group_message 发到这个群,引用某条传 reply_to=消息编号,@人传 at_user_ids=[QQ号],@全体传 at_all=true;' : '') +
3113
+ (hasActiveTool('qq_send_group_message') ? '想说话用 qq_send_group_message 发到这个群,确实要指代某条才传 reply_to=消息编号,必须提醒某人才传 at_user_ids=[QQ号];' : '') +
3062
3114
  (hasActiveTool('qq_send_face') ? '想回表情用 qq_send_face;' : '') +
3063
- '不想接就安静待着。不要用 Markdown,短一点。'
3115
+ '不想接就安静待着。' + CHAT_STYLE
3064
3116
  await agent.followup({
3065
3117
  id: randomUUID(),
3066
3118
  role: 'user',
@@ -3136,7 +3188,7 @@ async function handlePoke(ctx, ev) {
3136
3188
  if (hasActiveTool('qq_send_poke')) actions.push(`想戳回去用 qq_send_poke(group_id=${groupId}, user_id=${sender})`)
3137
3189
  prompt = `【戳一戳】群「${groupName}」里 ${name}(${sender}) 戳了你一下。`
3138
3190
  + (actions.length ? actions.join(';') + '。不想回就安静待着。' : '不用回复。')
3139
- + '不要用 Markdown,短一点。'
3191
+ + CHAT_STYLE
3140
3192
  } else {
3141
3193
  const actions = []
3142
3194
  if (hasActiveTool('qq_send_private_message')) actions.push(`想回就用 qq_send_private_message(user_id=${sender})`)
@@ -3144,7 +3196,7 @@ async function handlePoke(ctx, ev) {
3144
3196
  if (hasActiveTool('qq_send_poke')) actions.push(`想戳回去用 qq_send_poke(user_id=${sender})`)
3145
3197
  prompt = `【戳一戳】${sender} 在私聊里戳了你一下。`
3146
3198
  + (actions.length ? actions.join(';') + '。不想回就安静待着。' : '不用回复。')
3147
- + '不要用 Markdown,短一点。'
3199
+ + CHAT_STYLE
3148
3200
  }
3149
3201
  const sourceKind = groupId ? 'qq-poke' : 'qq-private'
3150
3202
  await agent.followup({
@@ -3200,7 +3252,7 @@ async function handlePrivateMessage(ctx, ev, admitImages) {
3200
3252
  const prompt =
3201
3253
  '【QQ ' + where + '】' + formatIncomingLine(name, userId, text, ev.message_id) +
3202
3254
  '\n' + (actions.length ? actions.join(';') + '。' : '') +
3203
- '不想回就安静待着;不要用 Markdown,不要提工具,短一点。'
3255
+ '不想回就安静待着;不要提工具。' + CHAT_STYLE
3204
3256
  const content = [{ type: 'text', text: prompt }]
3205
3257
  if (typeof admitImages === 'function') {
3206
3258
  // 和群/私聊记录一样:先保证最近两张动图能进来,剩下的名额给静图。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qq-tools",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息(可引用/@)、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、更改头像、好友管理(资料/备注/分组/删好友/名片赞/好友申请)、QQ 空间说说(列表/详情/发表/评论/点赞/权限)、挂载会话选择。11 个工具组加 1 个设置组件,可在插件页逐个开关。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,6 +29,8 @@
29
29
  "locale",
30
30
  "cordis.patch.yml",
31
31
  "README.md",
32
+ "SECURITY.md",
33
+ "docs/verification",
32
34
  "LICENSE"
33
35
  ],
34
36
  "engines": {
@@ -78,6 +80,10 @@
78
80
  "./package.json": "./package.json"
79
81
  },
80
82
  "dsh": {
83
+ "compatibility": {
84
+ "dsh": "0.2.x",
85
+ "node": ">=22"
86
+ },
81
87
  "bundle": {
82
88
  "patch": "./cordis.patch.yml"
83
89
  },
@@ -92,3 +98,5 @@
92
98
  }
93
99
  }
94
100
  }
101
+
102
+