dsh-qq-tools 0.9.0 → 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 CHANGED
@@ -9,7 +9,7 @@
9
9
  | 列出 QQ 群 | `qq_list_groups` | 群号、群名、人数 |
10
10
  | 看群聊天记录 | `qq_get_group_history` | 最近消息 + 图片,只读;消息带 `#message_id` |
11
11
  | 看群成员 | `qq_get_group_members` | 昵称、群名片、QQ 号、身份 |
12
- | 发群消息 | `qq_send_group_message` | 发群消息;同时负责 @ 提醒和群后续轮询 |
12
+ | 发群消息 | `qq_send_group_message` | 发群消息,可引用消息、@人、@全体;同时负责 @ 提醒和群后续轮询 |
13
13
  | 发表情 | `qq_send_face` | 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情,可带一句话 |
14
14
  | 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看/管理 QQ 账号收藏夹(SnowLuma):收藏图片、取消收藏、改备注、移到最前 |
15
15
  | 私聊 | `qq_list_friends` / `qq_get_private_history` / `qq_send_private_message` | 好友列表、私聊记录、发私聊消息;同时负责私聊消息唤醒 |
@@ -102,12 +102,22 @@ test/smoke.mjs 离线冒烟测试:假 OneBot HTTP 服务 +
102
102
 
103
103
  这些自定义表情接口是 SnowLuma 扩展,NapCat 没有。用 NapCat 时系统表情照常能发,收藏表情工具会提示接口不可用。
104
104
 
105
+ ## 群消息:引用和 @
106
+
107
+ `qq_send_group_message` 除了 `group_id` 和 `message`,还支持:
108
+
109
+ - `reply_to`:消息前面的 `#编号`,引用那条消息;
110
+ - `at_user_ids`:QQ 号数组,@ 这些人;
111
+ - `at_all`:`true` 表示 @全体成员(这时 `at_user_ids` 会被忽略)。
112
+
113
+ `message` 可以只写文字,也可以留空只发 @;引用和 @ 可以同时用。群 @ 提醒和群后续里的每条消息是 `昵称(QQ号):内容(消息 #编号)`,可以直接把 `#编号` 传给 `reply_to`,把里面括号中的 QQ 号传给 `at_user_ids`。戳一戳和私聊唤醒也是 `昵称(QQ号)` 的形式。
114
+
105
115
  ## 私聊
106
116
 
107
117
  私聊和群聊在程序里是分开的两条路径:群聊靠轮询 @,私聊靠 WebSocket 的 `message_type=private` 事件。私聊没有观察窗,每收到一条私聊消息都会直接唤醒目标会话。
108
118
 
109
- - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(有 sharp 时 GIF 会拆成首/中/尾最多 3 帧;没有 sharp 时交给 DSH 显示首帧,和群聊记录的处理一致)。
110
- - 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字,用 `qq_send_face` 回表情。
119
+ - 唤醒提示里的消息行是 `昵称(QQ号):内容(消息 #编号)`;如果消息里有图片,也会把图片一起给模型看(有 sharp 时 GIF 会拆成首/中/尾最多 3 帧;没有 sharp 时交给 DSH 显示首帧,和群聊记录的处理一致)。
120
+ - 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字(传 `reply_to=#编号` 可以引用),用 `qq_send_face` 回表情。
111
121
  - `qq_list_friends` 列好友,可以按昵称、备注或 QQ 号搜;`privateUsers` 非空时只列白名单里的好友。
112
122
  - `qq_get_private_history` 的消息带 `#message_id`,可以传给 `qq_send_private_message` / `qq_send_face` 的 `reply_to` 做引用回复。
113
123
  - 私聊唤醒的 `source.kind` 是 `qq-private`,和群聊的 `qq-mention` / `qq-followup` / `qq-poke` 区分开。
@@ -179,7 +189,7 @@ dsh plugin --profile <profile> add dsh-qq-tools
179
189
  需要固定版本时:
180
190
 
181
191
  ```sh
182
- dsh plugin --profile <profile> add dsh-qq-tools@0.9.0
192
+ dsh plugin --profile <profile> add dsh-qq-tools@0.9.1
183
193
  ```
184
194
 
185
195
  ### 本地开发 / 自己改代码
@@ -192,7 +202,7 @@ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
192
202
 
193
203
  ```sh
194
204
  pnpm pack
195
- dsh plugin --profile <profile> add ./dsh-qq-tools-0.9.0.tgz
205
+ dsh plugin --profile <profile> add ./dsh-qq-tools-0.9.1.tgz
196
206
  ```
197
207
 
198
208
  `dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
@@ -212,4 +222,4 @@ dsh plugin --profile <profile> remove dsh-qq-tools
212
222
 
213
223
  ---
214
224
 
215
- 版本:0.9.0
225
+ 版本:0.9.1
package/lib/shared.js CHANGED
@@ -442,6 +442,14 @@ function senderId(m) {
442
442
  return clean(s.user_id || (m && m.user_id) || '')
443
443
  }
444
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
+
445
453
 
446
454
  function renderTool(_args, v) {
447
455
  if (!v || !v.ok) return [{ type: 'text', text: 'QQ 工具失败:' + String((v && v.error) || '未知错误') }]
@@ -917,15 +925,19 @@ export function makeSendTool(onSent) {
917
925
  name: 'qq_send_group_message',
918
926
  description:
919
927
  '在指定群里发一条消息。发之前先确认群号;只发到允许的群。' +
928
+ 'reply_to 传消息前面的 #编号 可以引用那条消息;at_user_ids 传 QQ 号可以 @ 人,at_all=true 是 @全体成员。' +
920
929
  '普通闲聊不要用 Markdown;一句话短一点,像群友随手打的。' +
921
930
  '不想说话就不要调用这个工具;调用后不要再重复说自己发了什么。',
922
931
  parameters: {
923
932
  type: 'object',
924
933
  properties: {
925
934
  group_id: { type: 'number', description: '群号' },
926
- message: { type: 'string', description: '要发到群里的文字,不要 Markdown' },
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 = @全体成员,可选' },
927
939
  },
928
- required: ['group_id', 'message'],
940
+ required: ['group_id'],
929
941
  additionalProperties: false,
930
942
  },
931
943
  output: { schema: { type: 'object', additionalProperties: true }, render: renderTool },
@@ -933,24 +945,42 @@ export function makeSendTool(onSent) {
933
945
  const cfg = loadCfg()
934
946
  const groupId = Number(args && args.group_id)
935
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
936
953
  if (!Number.isFinite(groupId)) return { ok: false, error: 'group_id 不是有效群号' }
937
- if (!text) return { ok: false, error: '消息是空的' }
954
+ if (!text && !atIds.length && !atAll) return { ok: false, error: '消息是空的' }
938
955
  if (!allowedGroup(groupId, cfg)) return { ok: false, error: '这个群不在允许列表里' }
939
956
  try {
940
- const data = await onebot(
941
- 'send_group_msg',
942
- { group_id: groupId, message: [{ type: 'text', data: { text: text.slice(0, 1000) } }] },
943
- cfg,
944
- )
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)
945
964
  try { if (typeof onSent === 'function') onSent(groupId, '发群消息') } catch (error) {}
946
- return { ok: true, group_id: groupId, message_id: data && data.message_id }
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
+ }
947
973
  } catch (error) {
948
974
  return { ok: false, error: String((error && error.message) || error) }
949
975
  }
950
976
  },
951
977
  render(_args, v) {
952
978
  if (!v || !v.ok) return [{ type: 'text', text: '发群消息失败:' + String((v && v.error) || '未知错误') }]
953
- return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'})。` }]
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(',') : ''})。` }]
954
984
  },
955
985
  }
956
986
  }
@@ -1096,6 +1126,7 @@ export function makeSendPrivateTool() {
1096
1126
  name: 'qq_send_private_message',
1097
1127
  description:
1098
1128
  '给某个 QQ 好友发一条私聊消息。user_id 从 qq_list_friends 或私聊唤醒提示里拿。' +
1129
+ 'reply_to 传消息前面的 #编号 可以引用那条消息。' +
1099
1130
  '普通闲聊不要用 Markdown;一句话短一点,像聊天一样。' +
1100
1131
  '不想说话就不要调用;调用后不要再重复说自己发了什么。',
1101
1132
  parameters: {
@@ -1103,7 +1134,7 @@ export function makeSendPrivateTool() {
1103
1134
  properties: {
1104
1135
  user_id: { type: 'number', description: '对方的 QQ 号' },
1105
1136
  message: { type: 'string', description: '要发的文字,不要 Markdown' },
1106
- reply_to: { type: 'string', description: '要回复的消息 ID,可选' },
1137
+ reply_to: { type: 'string', description: '要引用的消息 ID(消息前面的 #编号),可选' },
1107
1138
  group_id: { type: 'number', description: '临时会话的来源群号,可选;普通好友私聊不用传' },
1108
1139
  },
1109
1140
  required: ['user_id', 'message'],
@@ -1126,14 +1157,20 @@ export function makeSendPrivateTool() {
1126
1157
  const body = { user_id: userId, message }
1127
1158
  if (groupId) body.group_id = groupId
1128
1159
  const data = await onebot('send_private_msg', body, cfg)
1129
- return { ok: true, user_id: userId, message_id: data && data.message_id }
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
+ }
1130
1166
  } catch (error) {
1131
1167
  return { ok: false, error: String((error && error.message) || error) }
1132
1168
  }
1133
1169
  },
1134
1170
  render(_args, v) {
1135
1171
  if (!v || !v.ok) return [{ type: 'text', text: '发私聊消息失败:' + String((v && v.error) || '未知错误') }]
1136
- return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'})。` }]
1172
+ const quote = v.reply_to ? ',引用了 #' + v.reply_to : ''
1173
+ return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'}${quote})。` }]
1137
1174
  },
1138
1175
  }
1139
1176
  }
@@ -2959,13 +2996,30 @@ async function pollMentions(ctx) {
2959
2996
  )
2960
2997
  const text = segText(segs) || '(没有文字)'
2961
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) || '?')
2962
3000
  const winUntil = windows.get(String(gid)) || 0
2963
3001
  if (Date.now() < winUntil) {
2964
3002
  const arr = followups.get(String(gid)) || []
2965
- arr.push({ gid: String(gid), group: names.get(String(gid)) || String(gid), sender: name, text, time: t })
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
+ })
2966
3012
  followups.set(String(gid), arr)
2967
3013
  } else if (atMe) {
2968
- mentions.push({ gid: String(gid), group: names.get(String(gid)) || String(gid), sender: name, text, time: t })
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
+ })
2969
3023
  }
2970
3024
  }
2971
3025
  lastSeen.set(String(gid), maxTime)
@@ -2973,15 +3027,19 @@ async function pollMentions(ctx) {
2973
3027
  if (seen.size > 500) seen.clear()
2974
3028
  for (const [gid, until] of windows) if (Date.now() >= until) windows.delete(gid)
2975
3029
  if (!mentions.length && followups.size === 0) return
2976
- const lines = mentions.slice(-5).map((x) => `群「${x.group}」${x.sender}:${x.text}`)
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)}`)
2977
3032
  const sendTools = []
2978
3033
  if (hasActiveTool('qq_send_group_message')) sendTools.push('qq_send_group_message')
2979
3034
  if (hasActiveTool('qq_send_face')) sendTools.push('qq_send_face')
2980
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
+ : ''
2981
3039
  const prompt =
2982
3040
  '【群聊 @ 提醒】有人 @ 了你:\n' +
2983
3041
  lines.join('\n') +
2984
- '\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + '不想说话就安静待着;' +
3042
+ '\n' + lookHint() + '再决定要不要回复、回哪个群。' + sendHint + quoteHint + '不想说话就安静待着;' +
2985
3043
  '不要用 Markdown,不要提工具和私聊内容,短一点。'
2986
3044
  const message = {
2987
3045
  id: randomUUID(),
@@ -2995,12 +3053,12 @@ async function pollMentions(ctx) {
2995
3053
  }
2996
3054
  for (const [gid, list] of followups) {
2997
3055
  const group = list[0] && list[0].group ? list[0].group : gid
2998
- const lines2 = list.slice(-10).map((x) => `${x.sender}:${x.text}`)
3056
+ const lines2 = list.slice(-10).map(fmtLine)
2999
3057
  const prompt2 =
3000
3058
  '【群聊后续】你刚才在群「' + group + '」里说过话,现在又有人说话:\n' +
3001
3059
  lines2.join('\n') +
3002
3060
  '\n想接就接;' +
3003
- (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;' : '') +
3004
3062
  (hasActiveTool('qq_send_face') ? '想回表情用 qq_send_face;' : '') +
3005
3063
  '不想接就安静待着。不要用 Markdown,短一点。'
3006
3064
  await agent.followup({
@@ -3127,20 +3185,20 @@ async function handlePrivateMessage(ctx, ev, admitImages) {
3127
3185
  const segs = Array.isArray(ev.message) ? ev.message : []
3128
3186
  const text = segText(segs) || clean(ev.raw_message) || '(没有文字)'
3129
3187
  const groupId = ev.group_id ? String(ev.group_id) : ''
3188
+ const midHint = ev.message_id != null ? ',引用传 reply_to=' + ev.message_id : ''
3130
3189
  const actions = []
3131
3190
  if (hasActiveTool('qq_get_private_history')) {
3132
3191
  actions.push('想看上下文用 qq_get_private_history(user_id=' + userId + ')')
3133
3192
  }
3134
3193
  if (hasActiveTool('qq_send_private_message')) {
3135
- 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 + ')')
3136
3195
  }
3137
3196
  if (hasActiveTool('qq_send_face')) {
3138
- 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 + ')')
3139
3198
  }
3140
3199
  const where = groupId ? '群 ' + groupId + ' 的临时会话' : '私聊'
3141
3200
  const prompt =
3142
- '【QQ ' + where + '】' + name + '(' + userId + ') 给你发消息:\n' + text +
3143
- (ev.message_id != null ? '\n(消息 #' + ev.message_id + ')' : '') +
3201
+ '【QQ ' + where + '】' + formatIncomingLine(name, userId, text, ev.message_id) +
3144
3202
  '\n' + (actions.length ? actions.join(';') + '。' : '') +
3145
3203
  '不想回就安静待着;不要用 Markdown,不要提工具,短一点。'
3146
3204
  const content = [{ type: 'text', text: prompt }]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-qq-tools",
3
- "version": "0.9.0",
4
- "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、更改头像、好友管理(资料/备注/分组/删好友/名片赞/好友申请)、QQ 空间说说(列表/详情/发表/评论/点赞/权限)、挂载会话选择。11 个工具组加 1 个设置组件,可在插件页逐个开关。",
3
+ "version": "0.9.1",
4
+ "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息(可引用/@)、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、更改头像、好友管理(资料/备注/分组/删好友/名片赞/好友申请)、QQ 空间说说(列表/详情/发表/评论/点赞/权限)、挂载会话选择。11 个工具组加 1 个设置组件,可在插件页逐个开关。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Send group message",
4
- "description": "Send a message to a QQ group; also polls @ mentions and follow-up chatter."
4
+ "description": "Send group messages with quotes, @mentions and @all; also polls @ mentions and follow-up chatter."
5
5
  }
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "发群消息",
4
- "description": "在指定的 QQ 群里发一条消息;也负责 @ 提醒和群后续的轮询。"
4
+ "description": "在指定的 QQ 群里发消息,可引用消息、@人和 @全体;也负责 @ 提醒和群后续的轮询。"
5
5
  }
6
6
  }