luckytri 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.en.md +44 -1
  2. package/README.md +44 -1
  3. package/docs/en/CHANGELOG.md +15 -0
  4. package/docs/zh/CHANGELOG.md +15 -0
  5. package/package.json +1 -1
  6. package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
  7. package/public/app/assets/{Empty-uSyRBXOT.js → Empty-DSurzLFZ.js} +1 -1
  8. package/public/app/assets/Field-GuXyA_aS.js +1 -0
  9. package/public/app/assets/HeartPage-BGmIrxCz.js +1 -0
  10. package/public/app/assets/LifePage-Dzln-0Y3.js +1 -0
  11. package/public/app/assets/MemoryPage-QsthmMZ9.js +1 -0
  12. package/public/app/assets/NaturePage-CIvHjgJa.js +3 -0
  13. package/public/app/assets/PeoplePage-bIoX24pj.js +1 -0
  14. package/public/app/assets/PluginsPage-EuJhCovw.js +2 -0
  15. package/public/app/assets/SystemPage-7t42B-Q3.js +1 -0
  16. package/public/app/assets/{SystemPage-B9M9g95D.css → SystemPage-BLMlD1M6.css} +1 -1
  17. package/public/app/assets/TimePage-CY-SwBq3.js +3 -0
  18. package/public/app/assets/index-Cn6TppEV.js +4 -0
  19. package/public/app/assets/{index-cDDC-1dI.css → index-JRbQoSJr.css} +1 -1
  20. package/public/app/assets/{knowledge-Ces-Bx70.js → knowledge-sUjcjbfI.js} +1 -1
  21. package/public/app/assets/media-Dh7c41PQ.js +1 -0
  22. package/public/app/assets/{mind-D3mdwFW3.js → mind-BMcoPQNd.js} +1 -1
  23. package/public/app/index.html +4 -4
  24. package/public/guide.en.html +3 -3
  25. package/public/guide.html +3 -3
  26. package/server/actions/index.js +268 -0
  27. package/server/app.js +4 -1
  28. package/server/channels/contract.js +3 -0
  29. package/server/channels/hub.js +15 -0
  30. package/server/channels/onebot/adapter.js +162 -21
  31. package/server/channels/onebot/enrich.js +132 -0
  32. package/server/channels/onebot/index.js +53 -3
  33. package/server/channels/onebot/music.js +43 -0
  34. package/server/core/conversation-grounding.js +1 -1
  35. package/server/core/dialogue-context.js +165 -15
  36. package/server/core/dialogue-contract.js +32 -33
  37. package/server/core/model-manager.js +35 -20
  38. package/server/core/model-presets.js +154 -1
  39. package/server/core/orchestrator.js +18 -0
  40. package/server/core/persona-manager.js +20 -2
  41. package/server/core/reply-delivery.js +328 -48
  42. package/server/core/response-generator.js +1 -1
  43. package/server/core/response-validator.js +85 -17
  44. package/server/core/turn-processor.js +63 -16
  45. package/server/core/turn.js +59 -1
  46. package/server/i18n/errors.js +26 -2
  47. package/server/index.js +30 -0
  48. package/server/mind/continuity.js +2 -4
  49. package/server/mind/discretion.js +16 -5
  50. package/server/mind/memory.js +7 -2
  51. package/server/mind/recall/index.js +2 -0
  52. package/server/plugins/zip-file.js +180 -0
  53. package/server/plugins/zip.js +32 -4
  54. package/server/stickers/api.js +90 -2
  55. package/server/stickers/library.js +8 -2
  56. package/server/stickers/offer.js +4 -1
  57. package/server/stickers/zip-import.js +136 -0
  58. package/server/studio/auto-update.js +224 -0
  59. package/server/studio/updates.js +154 -144
  60. package/server/voice/index.js +255 -0
  61. package/server/voice/sauc.js +152 -0
  62. package/server/web/link-reader.js +379 -0
  63. package/public/app/assets/ChatsPage-o-exjdFu.js +0 -3
  64. package/public/app/assets/Field-Bfa5dCkk.js +0 -1
  65. package/public/app/assets/HeartPage-Q5ts7Ko6.js +0 -1
  66. package/public/app/assets/LifePage-B4AsD_DM.js +0 -1
  67. package/public/app/assets/MemoryPage-ijD6lsxo.js +0 -1
  68. package/public/app/assets/NaturePage-B9-dv8vz.js +0 -3
  69. package/public/app/assets/PeoplePage-BG_XPian.js +0 -1
  70. package/public/app/assets/PluginsPage-CPYYMown.js +0 -2
  71. package/public/app/assets/SystemPage-CTduZhr3.js +0 -1
  72. package/public/app/assets/TimePage-DScn8IN-.js +0 -3
  73. package/public/app/assets/index-5FMs9YYQ.js +0 -4
  74. package/public/app/assets/media-BsG-i0Lv.js +0 -1
@@ -0,0 +1,268 @@
1
+ // What she can do besides typing: quote a message, poke someone, put a
2
+ // reaction on a message, throw a die or play rock-paper-scissors, take back
3
+ // her own last line. They belong to her the way words do: offered only when
4
+ // this channel can do them right now, chosen by her, never by a counter.
5
+
6
+ // QQ face ids accepted by set_msg_emoji_like; a short list she can
7
+ // name. Unknown names are dropped, not guessed.
8
+ export const REACTIONS = {
9
+ 赞: "76",
10
+ 爱心: "66",
11
+ 笑哭: "182",
12
+ 哈哈: "101",
13
+ 抱抱: "49",
14
+ OK: "124",
15
+ 鼓掌: "99",
16
+ 疑问: "32",
17
+ 震惊: "110",
18
+ 玫瑰: "63",
19
+ };
20
+
21
+ const NOTES = {
22
+ reply: "reply:{to:消息序号},引用那条再说(话题被刷下去、或要明确接哪句时)",
23
+ poke: "poke:{to:userId},戳一下(逗人、叫人、回戳)",
24
+ react: `react:{to:消息序号,emoji:${Object.keys(REACTIONS).join("|")}},给那条点个表情回应,可以不说话`,
25
+ dice: "dice:{},扔个骰子",
26
+ rps: "rps:{},出个猜拳",
27
+ recall: "recall:{},撤回自己刚发的上一条(只在发错了的时候)",
28
+ voice:
29
+ "voice:{},这句用说的发语音(60字内);有人请你唱歌,你此刻心情好也想唱,可以把歌词写进 say 并选 voice,不想就不唱",
30
+ music:
31
+ 'music:{query:"歌名 歌手"},分享一首歌的卡片(真想推荐、或有人要歌时)',
32
+ forward:
33
+ "forward:{ids:[消息序号,…]},把这里的几条消息打包成合并转发发出来(翻旧账、给人看前情时,最多20条)",
34
+ file: 'file:{name:"标题",text:"全文"},把你自己写的东西作为 .md 文件发出来(篇幅长、对方要文件时)',
35
+ };
36
+
37
+ // Guards against a burst, per session, in her time.
38
+ const LIMITS = {
39
+ poke: { per: 10 * 60000, max: 1, key: "to" },
40
+ react: { per: 3600000, max: 6 },
41
+ dice: { per: 3600000, max: 3 },
42
+ rps: { per: 3600000, max: 3 },
43
+ recall: { per: 3600000, max: 2 },
44
+ voice: { per: 3600000, max: 3 },
45
+ music: { per: 3600000, max: 3 },
46
+ forward: { per: 3600000, max: 2 },
47
+ file: { per: 3600000, max: 2 },
48
+ };
49
+
50
+ export class ActionLedger {
51
+ constructor(db, { now = Date.now } = {}) {
52
+ this.db = db;
53
+ this.now = now;
54
+ db?.exec?.(
55
+ "CREATE TABLE IF NOT EXISTS core_action_events (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, direction TEXT NOT NULL, type TEXT NOT NULL, target TEXT, detail TEXT, result TEXT, time INTEGER NOT NULL)",
56
+ );
57
+ }
58
+ count(session, type, since, target) {
59
+ if (!this.db) return 0;
60
+ return this.db
61
+ .prepare(
62
+ `SELECT COUNT(*) n FROM core_action_events WHERE session_id=? AND type=? AND direction='out' AND result='done' AND time>=?${target ? " AND target=?" : ""}`,
63
+ )
64
+ .get(session, type, since, ...(target ? [String(target)] : [])).n;
65
+ }
66
+ allowed(session, type, target) {
67
+ const limit = LIMITS[type];
68
+ if (!limit) return true;
69
+ return (
70
+ this.count(
71
+ session,
72
+ type,
73
+ this.now() - limit.per,
74
+ limit.key ? target : undefined,
75
+ ) < limit.max
76
+ );
77
+ }
78
+ record(
79
+ session,
80
+ { direction = "out", type, target = "", detail = "", result },
81
+ ) {
82
+ this.db
83
+ ?.prepare(
84
+ "INSERT INTO core_action_events(session_id,direction,type,target,detail,result,time) VALUES (?,?,?,?,?,?,?)",
85
+ )
86
+ .run(
87
+ session,
88
+ direction,
89
+ type,
90
+ String(target),
91
+ detail,
92
+ result,
93
+ this.now(),
94
+ );
95
+ }
96
+ }
97
+
98
+ // What reaches her this turn: only what this channel can do here now.
99
+ export function actionOffer(available, snapshot, session, ledger) {
100
+ if (!Array.isArray(available) || !available.length) return [];
101
+ if (snapshot.initiative || snapshot.stopped) return [];
102
+ return available.filter(
103
+ (type) =>
104
+ NOTES[type] &&
105
+ (type !== "recall" ||
106
+ (snapshot.messages || []).some((m) => m.role === "assistant")) &&
107
+ (!ledger || type === "poke" || ledger.allowed(session, type)),
108
+ );
109
+ }
110
+
111
+ export function actionNote(offered) {
112
+ return {
113
+ actions: offered.map((type) => NOTES[type]),
114
+ actionsNote:
115
+ '想做时返回 actions:[{"type":"…",…}],最多两个;不做就不写。和说话一样由你决定,不用为用而用。',
116
+ };
117
+ }
118
+
119
+ // What she chose, kept only when it points at something real this turn.
120
+ export function chosenActions(raw, snapshot) {
121
+ const offered = new Set(snapshot.actionsOffered || []);
122
+ if (!offered.size || !Array.isArray(raw)) return [];
123
+ const byIndex = (to) =>
124
+ (snapshot.batch || []).find((m) => String(m.id) === String(to)) ||
125
+ (snapshot.messages || []).find(
126
+ (m) => m.role === "user" && String(m.id) === String(to),
127
+ );
128
+ const people = new Map(
129
+ [
130
+ ...(snapshot.canAt || []),
131
+ ...(snapshot.batch || []).map((m) => ({
132
+ userId: String(m.speaker ?? m.userId ?? ""),
133
+ name: m.name,
134
+ })),
135
+ ]
136
+ .filter((p) => p.userId)
137
+ .map((p) => [String(p.userId), p]),
138
+ );
139
+ const out = [];
140
+ for (const item of raw) {
141
+ if (!item || typeof item !== "object" || !offered.has(item.type)) continue;
142
+ if (out.some((a) => a.type === item.type)) continue;
143
+ if (item.type === "reply" || item.type === "react") {
144
+ const m = byIndex(item.to);
145
+ if (!m?.platformId) continue;
146
+ if (item.type === "react" && !REACTIONS[item.emoji]) continue;
147
+ out.push({
148
+ type: item.type,
149
+ to: m.id,
150
+ platformId: String(m.platformId),
151
+ name: m.name || "",
152
+ ...(item.type === "react" ? { emoji: item.emoji } : {}),
153
+ });
154
+ } else if (item.type === "poke") {
155
+ const p = people.get(String(item.to ?? ""));
156
+ if (!p) continue;
157
+ out.push({ type: "poke", to: p.userId, name: p.name || "" });
158
+ } else if (item.type === "music") {
159
+ const query = String(item.query || "")
160
+ .trim()
161
+ .slice(0, 60);
162
+ if (!query) continue;
163
+ out.push({ type: "music", query });
164
+ } else if (item.type === "forward") {
165
+ const ids = (Array.isArray(item.ids) ? item.ids : [])
166
+ .map((id) =>
167
+ (snapshot.messages || []).find((m) => String(m.id) === String(id)),
168
+ )
169
+ .filter((m) => m?.platformId)
170
+ .slice(0, 20);
171
+ if (!ids.length) continue;
172
+ out.push({
173
+ type: "forward",
174
+ platformIds: [...new Set(ids.map((m) => String(m.platformId)))],
175
+ });
176
+ } else if (item.type === "file") {
177
+ const text = String(item.text || "").trim();
178
+ const name = String(item.name || "")
179
+ .replace(/[\\/:*?"<>|\n\r]/g, "")
180
+ .trim()
181
+ .slice(0, 40);
182
+ if (!text || !name || text.length > 20000) continue;
183
+ out.push({ type: "file", name: `${name}.md`, text });
184
+ } else out.push({ type: item.type });
185
+ if (out.length >= 2) break;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ // How it reads in her own history afterwards.
191
+ export function actionLine(action) {
192
+ switch (action.type) {
193
+ case "poke":
194
+ return `[你戳了戳${action.name || "对方"}]`;
195
+ case "react":
196
+ return `[你给${action.name || "那条消息"}点了「${action.emoji}」]`;
197
+ case "dice":
198
+ return "[你扔了个骰子]";
199
+ case "rps":
200
+ return "[你出了个猜拳]";
201
+ case "recall":
202
+ return "[你撤回了上一条]";
203
+ case "voice":
204
+ return "[你发了语音]";
205
+ case "music":
206
+ return `[你分享了歌:${action.query}]`;
207
+ case "forward":
208
+ return `[你转发了${action.platformIds?.length || 0}条聊天记录]`;
209
+ case "file":
210
+ return `[你发了文件:${action.name}]`;
211
+ default:
212
+ return "";
213
+ }
214
+ }
215
+
216
+ // Runs the non-text actions; a failed one is noted and dropped, the words
217
+ // still go out.
218
+ export async function performActions(owner, message, actions, trace, session) {
219
+ const done = [];
220
+ if (!owner.perform) return done;
221
+ for (const action of actions) {
222
+ if (action.type === "reply" || action.type === "voice") continue;
223
+ if (
224
+ owner.actions &&
225
+ !owner.actions.allowed(session, action.type, action.to)
226
+ ) {
227
+ trace.steps.push(`动作「${action.type}」这段时间做得够多了,这次不做`);
228
+ continue;
229
+ }
230
+ try {
231
+ const target =
232
+ action.type === "recall"
233
+ ? owner.repo.db
234
+ .prepare(
235
+ "SELECT platform_id FROM core_outbox WHERE session_id=? AND status='confirmed' AND platform_id<>'' AND time>=? ORDER BY time DESC LIMIT 1",
236
+ )
237
+ .get(session, owner.now() - 110000)?.platform_id
238
+ : undefined;
239
+ if (action.type === "recall" && !target) {
240
+ trace.steps.push("想撤回,但上一条已经超过两分钟,不撤了");
241
+ continue;
242
+ }
243
+ await owner.perform(message, {
244
+ ...action,
245
+ ...(target ? { platformId: String(target) } : {}),
246
+ ...(action.type === "react"
247
+ ? { emojiId: REACTIONS[action.emoji] }
248
+ : {}),
249
+ });
250
+ owner.actions?.record(session, {
251
+ type: action.type,
252
+ target: action.to || "",
253
+ detail: action.emoji || "",
254
+ result: "done",
255
+ });
256
+ trace.steps.push(`做了动作:${actionLine(action) || action.type}`);
257
+ done.push(action);
258
+ } catch (error) {
259
+ owner.actions?.record(session, {
260
+ type: action.type,
261
+ target: action.to || "",
262
+ result: "failed",
263
+ });
264
+ trace.steps.push(`动作「${action.type}」没做成:${error.message}`);
265
+ }
266
+ }
267
+ return done;
268
+ }
package/server/app.js CHANGED
@@ -15,6 +15,7 @@ import { Life } from "./mind/life/index.js";
15
15
  import { runtimePaths } from "./paths.js";
16
16
  import { mountDataTransfer } from "./storage/data-transfer-api.js";
17
17
  import { UpdateChecker, mountUpdates } from "./studio/updates.js";
18
+ import { mountVoice } from "./voice/index.js";
18
19
 
19
20
  export function createApp({
20
21
  store,
@@ -47,6 +48,7 @@ export function createApp({
47
48
  "/plugins/import/upload",
48
49
  "/storage/transfer/upload",
49
50
  "/core/stickers/upload",
51
+ "/core/stickers/upload-zip",
50
52
  ].includes(req.path)
51
53
  )
52
54
  return res.status(400).json({ error: "请提交 JSON 对象" });
@@ -64,9 +66,10 @@ export function createApp({
64
66
  res.status(401).json({ error: "请先登录管理台" });
65
67
  });
66
68
  app.use("/api", objectBody);
67
- mountUpdates(app, updates, runtime.updater);
69
+ mountUpdates(app, updates, runtime.updater, runtime.autoUpdate);
68
70
  mountDataTransfer(app, runtime.transfer);
69
71
  if (chatSystem?.stickers) mountStickerUpload(app, chatSystem);
72
+ if (chatSystem?.repo) mountVoice(app, chatSystem);
70
73
  if (plugins) {
71
74
  app.post(
72
75
  "/api/plugins/import/upload",
@@ -16,6 +16,9 @@
16
16
  // the message this bubble answers; a channel with the
17
17
  // quote capability may show it as a quote, any other
18
18
  // channel ignores it
19
+ // actions(sessionId) optional: her non-text actions possible there now,
20
+ // from reply, poke, react, dice, rps, recall
21
+ // perform(message, action) optional: carry one of them out
19
22
  // fetchQuoted(message) the message that `message.replyId` points to, or null
20
23
  // fetchImage(file) a fresh URL or bytes for an expired image, or null
21
24
  // fetchMedia(file, type) optional: a voice or file the same way, or null
@@ -157,6 +157,21 @@ export function createChannelHub(store, channels, { env = process.env } = {}) {
157
157
  const channel = sessionId ? resolve(sessionId) : current();
158
158
  return channel.fetchMedia ? channel.fetchMedia(file, type) : null;
159
159
  },
160
+ // Her non-text actions; a channel without them offers none.
161
+ actions: (sessionId) => {
162
+ try {
163
+ const channel = resolve(sessionId);
164
+ return channel.actions ? channel.actions(sessionId) : [];
165
+ } catch {
166
+ return [];
167
+ }
168
+ },
169
+ perform: async (message, action) => {
170
+ if (message?.simulated) return {};
171
+ const channel = resolve(message.sessionId);
172
+ if (!channel.perform) throw new Error("这个渠道做不了这个动作");
173
+ return channel.perform(message, action);
174
+ },
160
175
  refreshDirectory: () => current().refreshDirectory(),
161
176
  close() {
162
177
  for (const channel of registry.values()) channel.close();
@@ -133,28 +133,35 @@ export const onebot = {
133
133
  : [];
134
134
  // A sticker goes as an image of its own, its bytes inline so the QQ client
135
135
  // needs no path or URL on this machine.
136
- const body = options?.image
136
+ const body = options?.record
137
137
  ? [
138
138
  {
139
- type: "image",
140
- data: {
141
- file: `base64://${options.image.base64}`,
142
- sub_type: options.image.sticker ? 1 : 0,
143
- ...(options.image.summary
144
- ? { summary: options.image.summary }
145
- : {}),
146
- },
139
+ type: "record",
140
+ data: { file: `base64://${options.record.base64}` },
147
141
  },
148
142
  ]
149
- : [
150
- ...(Array.isArray(options?.at) ? options.at : [])
151
- .filter((id) => /^\d{5,12}$/.test(String(id)))
152
- .flatMap((id) => [{ type: "at", data: { qq: String(id) } }]),
153
- {
154
- type: "text",
155
- data: { text: options?.at?.length ? ` ${text}` : text },
156
- },
157
- ];
143
+ : options?.image
144
+ ? [
145
+ {
146
+ type: "image",
147
+ data: {
148
+ file: `base64://${options.image.base64}`,
149
+ sub_type: options.image.sticker ? 1 : 0,
150
+ ...(options.image.summary
151
+ ? { summary: options.image.summary }
152
+ : {}),
153
+ },
154
+ },
155
+ ]
156
+ : [
157
+ ...(Array.isArray(options?.at) ? options.at : [])
158
+ .filter((id) => /^\d{5,12}$/.test(String(id)))
159
+ .flatMap((id) => [{ type: "at", data: { qq: String(id) } }]),
160
+ {
161
+ type: "text",
162
+ data: { text: options?.at?.length ? ` ${text}` : text },
163
+ },
164
+ ];
158
165
  return {
159
166
  message: [...reply, ...body],
160
167
  [parsed.kind === "group" ? "group_id" : "user_id"]:
@@ -196,6 +203,129 @@ export const onebot = {
196
203
  },
197
204
  };
198
205
 
206
+ // Someone poking her is a way of calling her; poking between others is not.
207
+ export function normalizePoke(event) {
208
+ if (
209
+ event?.post_type !== "notice" ||
210
+ !(event.notice_type === "notify" && event.sub_type === "poke") ||
211
+ String(event.target_id) !== String(event.self_id) ||
212
+ String(event.user_id) === String(event.self_id) ||
213
+ !/^\d+$/.test(String(event.user_id)) ||
214
+ !/^\d+$/.test(String(event.self_id))
215
+ )
216
+ return null;
217
+ const kind = event.group_id ? "group" : "private";
218
+ const nativeId = String(event.group_id || event.user_id);
219
+ const time = event.time ? event.time * 1000 : Date.now();
220
+ return {
221
+ sessionId: formatSessionKey({
222
+ channel: "onebot",
223
+ accountId: String(event.self_id),
224
+ kind,
225
+ nativeId,
226
+ }),
227
+ channel: "onebot",
228
+ accountId: String(event.self_id),
229
+ nativeId,
230
+ segments: [],
231
+ kind,
232
+ userId: String(event.user_id),
233
+ name: senderLabel(event.sender, event.user_id),
234
+ text: "[戳了戳你]",
235
+ poke: true,
236
+ mentioned: true,
237
+ replyToBot: false,
238
+ platformId: "",
239
+ time,
240
+ mentions: [],
241
+ replyId: "",
242
+ attachments: [],
243
+ media: { images: 0, stickers: 0, other: 0, count: 0, only: false },
244
+ eventId: `${event.self_id}:poke:${nativeId}:${event.user_id}:${time}`,
245
+ raw: event,
246
+ };
247
+ }
248
+
249
+ // One of her non-text actions as an OneBot call. Poke and reactions are
250
+ // extensions of some implementations; dice, rps and recall are OneBot 11.
251
+ export function actionCall(message, action) {
252
+ const parsed = parseSessionKey(message.sessionId);
253
+ const group = parsed.kind === "group";
254
+ const where = group
255
+ ? { group_id: Number(parsed.nativeId) }
256
+ : { user_id: Number(parsed.nativeId) };
257
+ switch (action.type) {
258
+ case "poke":
259
+ return group
260
+ ? ["group_poke", { ...where, user_id: Number(action.to) }]
261
+ : ["friend_poke", { user_id: Number(action.to) }];
262
+ case "react":
263
+ return [
264
+ "set_msg_emoji_like",
265
+ {
266
+ message_id: Number(action.platformId),
267
+ emoji_id: String(action.emojiId),
268
+ set: true,
269
+ },
270
+ ];
271
+ case "dice":
272
+ case "rps":
273
+ return [
274
+ group ? "send_group_msg" : "send_private_msg",
275
+ { ...where, message: [{ type: action.type, data: {} }] },
276
+ ];
277
+ case "typing":
278
+ // "Typing…" in a private chat while she thinks; groups have none.
279
+ if (group) throw new Error("这个动作 QQ 这边做不了");
280
+ return [
281
+ "set_input_status",
282
+ { user_id: Number(parsed.nativeId), event_type: 1 },
283
+ ];
284
+ case "music":
285
+ return [
286
+ group ? "send_group_msg" : "send_private_msg",
287
+ {
288
+ ...where,
289
+ message: [
290
+ {
291
+ type: "music",
292
+ data: { type: "163", id: String(action.song.id) },
293
+ },
294
+ ],
295
+ },
296
+ ];
297
+ case "text":
298
+ return [
299
+ group ? "send_group_msg" : "send_private_msg",
300
+ { ...where, message: [{ type: "text", data: { text: action.text } }] },
301
+ ];
302
+ case "forward":
303
+ return [
304
+ group ? "send_group_forward_msg" : "send_private_forward_msg",
305
+ {
306
+ ...where,
307
+ messages: action.platformIds.map((id) => ({
308
+ type: "node",
309
+ data: { id: String(id) },
310
+ })),
311
+ },
312
+ ];
313
+ case "file":
314
+ return [
315
+ group ? "upload_group_file" : "upload_private_file",
316
+ {
317
+ ...where,
318
+ file: `base64://${Buffer.from(action.text, "utf8").toString("base64")}`,
319
+ name: action.name,
320
+ },
321
+ ];
322
+ case "recall":
323
+ return ["delete_msg", { message_id: Number(action.platformId) }];
324
+ default:
325
+ throw new Error("这个动作 QQ 这边做不了");
326
+ }
327
+ }
328
+
199
329
  export function normalize(event, { botMessageIds } = {}) {
200
330
  if (
201
331
  !event ||
@@ -235,7 +365,7 @@ export function normalize(event, { botMessageIds } = {}) {
235
365
  ).length,
236
366
  stickers: safeSegments.filter(isSticker).length,
237
367
  other: safeSegments.filter((s) =>
238
- ["record", "video", "file", "json", "xml"].includes(
368
+ ["record", "video", "file", "json", "xml", "forward"].includes(
239
369
  String(s.type).toLowerCase(),
240
370
  ),
241
371
  ).length,
@@ -247,9 +377,20 @@ export function normalize(event, { botMessageIds } = {}) {
247
377
  if (type === "at")
248
378
  return atId(s) === String(event.self_id) ? "@我" : "[提及成员]";
249
379
  if (type === "image" && !isSticker(s)) return "[图片]";
380
+ if (type === "dice") return "[骰子]";
381
+ if (type === "rps") return "[猜拳]";
250
382
  if (isSticker(s)) return "[表情]";
251
- if (["record", "video", "file", "json", "xml"].includes(type))
252
- return "[媒体]";
383
+ if (type === "record") return "[语音]";
384
+ if (type === "forward") return "[合并转发]";
385
+ if (type === "file")
386
+ return `[文件:${String(s.data.name || s.data.file || "").slice(0, 60)}]`;
387
+ if (type === "video") {
388
+ const name = String(
389
+ s.data?.file_name || s.data?.name || s.data?.file || "",
390
+ ).slice(0, 60);
391
+ return name ? `[视频:${name}]` : "[视频]";
392
+ }
393
+ if (["json", "xml"].includes(type)) return "[媒体]";
253
394
  return "";
254
395
  }),
255
396
  plainText = safeSegments
@@ -0,0 +1,132 @@
1
+ // What a QQ message carries beyond text, turned into words before the core
2
+ // sees it: a merged forward is unfolded into its lines, a voice message is
3
+ // transcribed when the owner set up speech recognition, a video keeps its
4
+ // filename when known. All are bounded; when anything fails the placeholder
5
+ // stays and nothing is guessed.
6
+ import { readFile } from "node:fs/promises";
7
+
8
+ const FORWARD_MAX_LINES = 40;
9
+ const FORWARD_MAX_CHARS = 2000;
10
+
11
+ function plain(content) {
12
+ if (typeof content === "string") return content;
13
+ if (!Array.isArray(content)) return "";
14
+ return content
15
+ .map((s) => {
16
+ const type = String(s?.type || "").toLowerCase();
17
+ if (type === "text") return s.data?.text || "";
18
+ if (type === "image") return "[图片]";
19
+ if (type === "forward") return "[合并转发]";
20
+ if (type === "record") return "[语音]";
21
+ if (type === "video") {
22
+ const name = String(
23
+ s.data?.file_name || s.data?.name || s.data?.file || "",
24
+ ).slice(0, 60);
25
+ return name ? `[视频:${name}]` : "[视频]";
26
+ }
27
+ if (type === "face" || type === "mface") return "[表情]";
28
+ return "";
29
+ })
30
+ .join("");
31
+ }
32
+
33
+ export function forwardText(data) {
34
+ const list = Array.isArray(data?.messages)
35
+ ? data.messages
36
+ : Array.isArray(data)
37
+ ? data
38
+ : [];
39
+ const lines = [];
40
+ let chars = 0;
41
+ for (const item of list.slice(0, FORWARD_MAX_LINES)) {
42
+ const who =
43
+ item?.sender?.card || item?.sender?.nickname || item?.nickname || "某人";
44
+ const text = plain(item?.message ?? item?.content).trim();
45
+ if (!text) continue;
46
+ const line = `${String(who).slice(0, 30)}:${text}`.slice(0, 300);
47
+ if (chars + line.length > FORWARD_MAX_CHARS) break;
48
+ lines.push(line);
49
+ chars += line.length;
50
+ }
51
+ if (!lines.length) return "";
52
+ const more =
53
+ list.length > lines.length
54
+ ? `\n(还有 ${list.length - lines.length} 条没展开)`
55
+ : "";
56
+ return `[合并转发:\n${lines.join("\n")}${more}]`;
57
+ }
58
+
59
+ async function audioOf(data) {
60
+ if (data?.base64) return Buffer.from(String(data.base64), "base64");
61
+ if (typeof data?.file === "string" && data.file.startsWith("base64://"))
62
+ return Buffer.from(data.file.slice(9), "base64");
63
+ if (typeof data?.file === "string" && data.file.startsWith("/"))
64
+ return readFile(data.file);
65
+ return null;
66
+ }
67
+
68
+ // rpc(action, params, timeoutMs); transcribe(buffer, format) or null.
69
+ export async function enrich(message, { rpc, transcribe } = {}) {
70
+ const segments = message?.segments || [];
71
+ let text = message.text;
72
+ let changed = false;
73
+ for (const s of segments) {
74
+ const type = String(s?.type || "").toLowerCase();
75
+ if (type === "forward" && s.data?.id) {
76
+ try {
77
+ const data = await rpc(
78
+ "get_forward_msg",
79
+ { id: String(s.data.id), message_id: String(s.data.id) },
80
+ 5000,
81
+ );
82
+ const unfolded = forwardText(data);
83
+ if (unfolded) {
84
+ text = text.includes("[合并转发]")
85
+ ? text.replace("[合并转发]", unfolded)
86
+ : `${text}\n${unfolded}`;
87
+ changed = true;
88
+ }
89
+ } catch {
90
+ /* The placeholder stays. */
91
+ }
92
+ }
93
+ if (type === "video") {
94
+ const name = String(
95
+ s.data?.file_name || s.data?.name || s.data?.file || "",
96
+ ).slice(0, 60);
97
+ const label = name ? `[视频:${name}]` : "[视频]";
98
+ if (text.includes("[媒体]")) {
99
+ text = text.replace("[媒体]", label);
100
+ changed = true;
101
+ } else if (
102
+ name &&
103
+ (text === "[视频]" || /(?:^|\n)\[视频\](?!:)/.test(text))
104
+ ) {
105
+ text = text.replace("[视频]", label);
106
+ changed = true;
107
+ }
108
+ }
109
+ if (type === "record" && s.data?.file && transcribe) {
110
+ try {
111
+ const data = await rpc(
112
+ "get_record",
113
+ { file: String(s.data.file), out_format: "mp3" },
114
+ 8000,
115
+ );
116
+ const audio = await audioOf(data);
117
+ const said = audio
118
+ ? String((await transcribe(audio, "mp3")) || "").trim()
119
+ : "";
120
+ if (said) {
121
+ text = text.includes("[语音]")
122
+ ? text.replace("[语音]", `[语音] ${said.slice(0, 500)}`)
123
+ : `${text}\n[语音] ${said.slice(0, 500)}`;
124
+ changed = true;
125
+ }
126
+ } catch {
127
+ /* Not heard; the placeholder stays. */
128
+ }
129
+ }
130
+ }
131
+ return changed ? { ...message, text: text.slice(0, 4000) } : message;
132
+ }