openclaw-channel-dmwork 0.5.16 → 0.5.17-dev.91d4810

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.16",
3
+ "version": "0.5.17-dev.91d4810",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -148,6 +148,189 @@ describe("handleDmworkMessageAction", () => {
148
148
  });
149
149
  });
150
150
 
151
+ // -----------------------------------------------------------------------
152
+ // send — v2 structured mentions (@[uid:name])
153
+ // -----------------------------------------------------------------------
154
+ describe("send — v2 structured mentions converted to @name + entities", () => {
155
+ it("should convert @[uid:name] to @name with correct entities", async () => {
156
+ let sentPayload: any = null;
157
+ globalThis.fetch = mockFetch({
158
+ "/v1/bot/sendMessage": async (_url, init) => {
159
+ sentPayload = JSON.parse(init?.body as string);
160
+ return jsonResponse({ message_id: 1, message_seq: 1 });
161
+ },
162
+ });
163
+
164
+ const uidToNameMap = new Map([
165
+ ["uid_chen", "陈皮皮"],
166
+ ["uid_bob", "bob"],
167
+ ]);
168
+
169
+ const { handleDmworkMessageAction } = await import("./actions.js");
170
+ const result = await handleDmworkMessageAction({
171
+ action: "send",
172
+ args: { target: "group:grp1", message: "Hello @[uid_chen:陈皮皮] and @[uid_bob:bob]!" },
173
+ apiUrl: "http://localhost:8090",
174
+ botToken: "test-token",
175
+ uidToNameMap,
176
+ });
177
+
178
+ expect(result.ok).toBe(true);
179
+ // Content should have @name format (not @[uid:name])
180
+ expect(sentPayload.payload.content).toBe("Hello @陈皮皮 and @bob!");
181
+ // Entities should have correct offset/length/uid
182
+ const entities = sentPayload.payload.mention.entities;
183
+ expect(entities).toHaveLength(2);
184
+ expect(entities[0]).toMatchObject({ uid: "uid_chen", offset: 6, length: 4 });
185
+ expect(entities[1]).toMatchObject({ uid: "uid_bob", offset: 15, length: 4 });
186
+ // UIDs should be present
187
+ expect(sentPayload.payload.mention.uids).toEqual(["uid_chen", "uid_bob"]);
188
+ });
189
+ });
190
+
191
+ describe("send — @all detection", () => {
192
+ it("should set mentionAll when @all is present", async () => {
193
+ let sentPayload: any = null;
194
+ globalThis.fetch = mockFetch({
195
+ "/v1/bot/sendMessage": async (_url, init) => {
196
+ sentPayload = JSON.parse(init?.body as string);
197
+ return jsonResponse({ message_id: 1, message_seq: 1 });
198
+ },
199
+ });
200
+
201
+ const { handleDmworkMessageAction } = await import("./actions.js");
202
+ const result = await handleDmworkMessageAction({
203
+ action: "send",
204
+ args: { target: "group:grp1", message: "Attention @all please read" },
205
+ apiUrl: "http://localhost:8090",
206
+ botToken: "test-token",
207
+ });
208
+
209
+ expect(result.ok).toBe(true);
210
+ expect(sentPayload.payload.mention.all).toBe(1);
211
+ });
212
+ });
213
+
214
+ describe("send — @所有人 detection", () => {
215
+ it("should set mentionAll when @所有人 is present", async () => {
216
+ let sentPayload: any = null;
217
+ globalThis.fetch = mockFetch({
218
+ "/v1/bot/sendMessage": async (_url, init) => {
219
+ sentPayload = JSON.parse(init?.body as string);
220
+ return jsonResponse({ message_id: 1, message_seq: 1 });
221
+ },
222
+ });
223
+
224
+ const { handleDmworkMessageAction } = await import("./actions.js");
225
+ const result = await handleDmworkMessageAction({
226
+ action: "send",
227
+ args: { target: "group:grp1", message: "大家注意 @所有人 请查收" },
228
+ apiUrl: "http://localhost:8090",
229
+ botToken: "test-token",
230
+ });
231
+
232
+ expect(result.ok).toBe(true);
233
+ expect(sentPayload.payload.mention.all).toBe(1);
234
+ });
235
+ });
236
+
237
+ describe("send — mixed v1+v2 mentions", () => {
238
+ it("should resolve both @[uid:name] and @name in same message", async () => {
239
+ let sentPayload: any = null;
240
+ globalThis.fetch = mockFetch({
241
+ "/v1/bot/sendMessage": async (_url, init) => {
242
+ sentPayload = JSON.parse(init?.body as string);
243
+ return jsonResponse({ message_id: 1, message_seq: 1 });
244
+ },
245
+ });
246
+
247
+ const memberMap = new Map([["alice", "uid_alice"]]);
248
+ const uidToNameMap = new Map([
249
+ ["uid_chen", "陈皮皮"],
250
+ ["uid_alice", "alice"],
251
+ ]);
252
+
253
+ const { handleDmworkMessageAction } = await import("./actions.js");
254
+ const result = await handleDmworkMessageAction({
255
+ action: "send",
256
+ args: { target: "group:grp1", message: "Hey @[uid_chen:陈皮皮] and @alice!" },
257
+ apiUrl: "http://localhost:8090",
258
+ botToken: "test-token",
259
+ memberMap,
260
+ uidToNameMap,
261
+ });
262
+
263
+ expect(result.ok).toBe(true);
264
+ // Content should have both converted
265
+ expect(sentPayload.payload.content).toBe("Hey @陈皮皮 and @alice!");
266
+ const entities = sentPayload.payload.mention.entities;
267
+ expect(entities).toHaveLength(2);
268
+ // First entity from v2 conversion
269
+ expect(entities[0]).toMatchObject({ uid: "uid_chen", offset: 4, length: 4 });
270
+ // Second entity from v1 fallback
271
+ expect(entities[1]).toMatchObject({ uid: "uid_alice", offset: 13, length: 6 });
272
+ });
273
+ });
274
+
275
+ describe("send — v2 without uidToNameMap graceful fallback", () => {
276
+ it("should leave @[uid:name] unchanged when uidToNameMap is not provided", async () => {
277
+ let sentPayload: any = null;
278
+ globalThis.fetch = mockFetch({
279
+ "/v1/bot/sendMessage": async (_url, init) => {
280
+ sentPayload = JSON.parse(init?.body as string);
281
+ return jsonResponse({ message_id: 1, message_seq: 1 });
282
+ },
283
+ });
284
+
285
+ const { handleDmworkMessageAction } = await import("./actions.js");
286
+ const result = await handleDmworkMessageAction({
287
+ action: "send",
288
+ args: { target: "group:grp1", message: "Hello @[uid_chen:陈皮皮]!" },
289
+ apiUrl: "http://localhost:8090",
290
+ botToken: "test-token",
291
+ // no uidToNameMap provided
292
+ });
293
+
294
+ expect(result.ok).toBe(true);
295
+ // Content should be unchanged — no conversion without uidToNameMap
296
+ expect(sentPayload.payload.content).toBe("Hello @[uid_chen:陈皮皮]!");
297
+ });
298
+ });
299
+
300
+ describe("send — invalid uid in v2 (uid not in uidToNameMap)", () => {
301
+ it("should convert format but not create entity for unknown uid", async () => {
302
+ let sentPayload: any = null;
303
+ globalThis.fetch = mockFetch({
304
+ "/v1/bot/sendMessage": async (_url, init) => {
305
+ sentPayload = JSON.parse(init?.body as string);
306
+ return jsonResponse({ message_id: 1, message_seq: 1 });
307
+ },
308
+ });
309
+
310
+ const uidToNameMap = new Map([
311
+ ["uid_bob", "bob"],
312
+ ]);
313
+ // uid_unknown is NOT in uidToNameMap
314
+
315
+ const { handleDmworkMessageAction } = await import("./actions.js");
316
+ const result = await handleDmworkMessageAction({
317
+ action: "send",
318
+ args: { target: "group:grp1", message: "Hello @[uid_unknown:Ghost] and @[uid_bob:bob]!" },
319
+ apiUrl: "http://localhost:8090",
320
+ botToken: "test-token",
321
+ uidToNameMap,
322
+ });
323
+
324
+ expect(result.ok).toBe(true);
325
+ // Format is still converted for both
326
+ expect(sentPayload.payload.content).toBe("Hello @Ghost and @bob!");
327
+ // Only valid uid gets an entity
328
+ const entities = sentPayload.payload.mention.entities;
329
+ expect(entities).toHaveLength(1);
330
+ expect(entities[0]).toMatchObject({ uid: "uid_bob" });
331
+ });
332
+ });
333
+
151
334
  describe("send — unresolvable @mentions still sends", () => {
152
335
  it("should send without mentionUids when names are unresolvable", async () => {
153
336
  let sentPayload: any = null;
package/src/actions.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  updateGroupMd,
17
17
  } from "./api-fetch.js";
18
18
  import { uploadAndSendMedia } from "./inbound.js";
19
- import { buildEntitiesFromFallback } from "./mention-utils.js";
19
+ import { buildEntitiesFromFallback, parseStructuredMentions, convertStructuredMentions } from "./mention-utils.js";
20
20
  import type { MentionEntity } from "./types.js";
21
21
  import { getKnownGroupIds } from "./group-md.js";
22
22
 
@@ -110,7 +110,7 @@ export async function handleDmworkMessageAction(params: {
110
110
 
111
111
  switch (action) {
112
112
  case "send":
113
- return handleSend({ args, apiUrl, botToken, memberMap, currentChannelId, log });
113
+ return handleSend({ args, apiUrl, botToken, memberMap, uidToNameMap, currentChannelId, log });
114
114
  case "read":
115
115
  return handleRead({ args, apiUrl, botToken, uidToNameMap, currentChannelId, log });
116
116
  case "member-info":
@@ -137,10 +137,11 @@ async function handleSend(params: {
137
137
  apiUrl: string;
138
138
  botToken: string;
139
139
  memberMap?: Map<string, string>;
140
+ uidToNameMap?: Map<string, string>;
140
141
  currentChannelId?: string;
141
142
  log?: LogSink;
142
143
  }): Promise<MessageActionResult> {
143
- const { args, apiUrl, botToken, memberMap, currentChannelId, log } = params;
144
+ const { args, apiUrl, botToken, memberMap, uidToNameMap, currentChannelId, log } = params;
144
145
 
145
146
  const target = args.target as string | undefined;
146
147
  if (!target) {
@@ -166,21 +167,56 @@ async function handleSend(params: {
166
167
  if (message) {
167
168
  let mentionUids: string[] = [];
168
169
  let mentionEntities: MentionEntity[] = [];
170
+ let finalMessage = message;
171
+
172
+ if (channelType === ChannelType.Group) {
173
+ // v2 path: convert @[uid:name] → @name + entities
174
+ if (uidToNameMap) {
175
+ const structuredMentions = parseStructuredMentions(finalMessage);
176
+ if (structuredMentions.length > 0) {
177
+ const validUids = new Set(uidToNameMap.keys());
178
+ const converted = convertStructuredMentions(finalMessage, structuredMentions, validUids);
179
+ finalMessage = converted.content;
180
+ mentionEntities = [...converted.entities];
181
+ mentionUids = [...converted.uids];
182
+ }
183
+ }
184
+
185
+ // v1 fallback: resolve remaining @name via memberMap
186
+ if (memberMap) {
187
+ const { entities, uids } = buildEntitiesFromFallback(finalMessage, memberMap);
188
+ const existingOffsets = new Set(mentionEntities.map(e => e.offset));
189
+ for (const entity of entities) {
190
+ if (!existingOffsets.has(entity.offset)) {
191
+ mentionEntities.push(entity);
192
+ }
193
+ }
194
+ for (const uid of uids) {
195
+ if (!mentionUids.includes(uid)) {
196
+ mentionUids.push(uid);
197
+ }
198
+ }
199
+ }
169
200
 
170
- if (channelType === ChannelType.Group && memberMap) {
171
- const { entities, uids } = buildEntitiesFromFallback(message, memberMap);
172
- mentionUids = uids;
173
- mentionEntities = entities;
201
+ // Sort entities by offset and rebuild uids from sorted entities
202
+ if (mentionEntities.length > 0) {
203
+ mentionEntities.sort((a, b) => a.offset - b.offset);
204
+ mentionUids = mentionEntities.map(e => e.uid);
205
+ }
174
206
  }
175
207
 
208
+ // Detect @all/@所有人 in final content
209
+ const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(finalMessage);
210
+
176
211
  await sendMessage({
177
212
  apiUrl,
178
213
  botToken,
179
214
  channelId,
180
215
  channelType,
181
- content: message,
216
+ content: finalMessage,
182
217
  ...(mentionUids.length > 0 ? { mentionUids } : {}),
183
218
  ...(mentionEntities.length > 0 ? { mentionEntities } : {}),
219
+ mentionAll: hasAtAll || undefined,
184
220
  });
185
221
  }
186
222
 
@@ -5,6 +5,7 @@ import {
5
5
  resolveInnerMessageText,
6
6
  resolveApiMessagePlaceholder,
7
7
  resolveMultipleForwardText,
8
+ buildMediaUrl,
8
9
  calcDownloadTimeout,
9
10
  formatSize,
10
11
  resolveFileContentWithRetry,
@@ -865,3 +866,204 @@ describe("buildMemberListPrefix", () => {
865
866
  expect(result).toContain("50 members");
866
867
  });
867
868
  });
869
+
870
+ /**
871
+ * Tests for buildMediaUrl — exported module-level URL builder.
872
+ */
873
+ describe("buildMediaUrl", () => {
874
+ it("should return undefined for empty url", () => {
875
+ expect(buildMediaUrl(undefined)).toBeUndefined();
876
+ expect(buildMediaUrl("")).toBeUndefined();
877
+ });
878
+
879
+ it("should return absolute URL as-is", () => {
880
+ expect(buildMediaUrl("https://cdn.example.com/img.jpg")).toBe("https://cdn.example.com/img.jpg");
881
+ expect(buildMediaUrl("http://example.com/file.pdf")).toBe("http://example.com/file.pdf");
882
+ });
883
+
884
+ it("should use cdnUrl when provided", () => {
885
+ expect(buildMediaUrl("upload/abc123.jpg", "https://api.example.com", "https://cdn.example.com"))
886
+ .toBe("https://cdn.example.com/upload/abc123.jpg");
887
+ });
888
+
889
+ it("should strip trailing slashes from cdnUrl", () => {
890
+ expect(buildMediaUrl("upload/abc.jpg", undefined, "https://cdn.example.com///"))
891
+ .toBe("https://cdn.example.com/upload/abc.jpg");
892
+ });
893
+
894
+ it("should strip file/preview/ prefix with cdnUrl", () => {
895
+ expect(buildMediaUrl("file/preview/bucket/img.jpg", undefined, "https://cdn.example.com"))
896
+ .toBe("https://cdn.example.com/bucket/img.jpg");
897
+ });
898
+
899
+ it("should strip file/ prefix with cdnUrl", () => {
900
+ expect(buildMediaUrl("file/bucket/img.jpg", undefined, "https://cdn.example.com"))
901
+ .toBe("https://cdn.example.com/bucket/img.jpg");
902
+ });
903
+
904
+ it("should fall back to apiUrl when cdnUrl is not provided", () => {
905
+ expect(buildMediaUrl("upload/abc123.jpg", "https://api.example.com"))
906
+ .toBe("https://api.example.com/file/upload/abc123.jpg");
907
+ });
908
+
909
+ it("should strip trailing slashes from apiUrl", () => {
910
+ expect(buildMediaUrl("upload/abc.jpg", "https://api.example.com/"))
911
+ .toBe("https://api.example.com/file/upload/abc.jpg");
912
+ });
913
+
914
+ it("should strip file/ prefix with apiUrl fallback", () => {
915
+ expect(buildMediaUrl("file/bucket/img.jpg", "https://api.example.com"))
916
+ .toBe("https://api.example.com/file/bucket/img.jpg");
917
+ });
918
+
919
+ it("should return /file/path when neither cdnUrl nor apiUrl provided", () => {
920
+ expect(buildMediaUrl("upload/abc.jpg")).toBe("/file/upload/abc.jpg");
921
+ });
922
+ });
923
+
924
+ /**
925
+ * Tests for resolveInnerMessageText with buildUrl parameter.
926
+ */
927
+ describe("resolveInnerMessageText with buildUrl", () => {
928
+ const mockBuildUrl = (url?: string) => url ? `https://cdn.example.com/${url}` : undefined;
929
+
930
+ it("should append URL for Image when buildUrl is provided", () => {
931
+ const result = resolveInnerMessageText(
932
+ { type: MessageType.Image, url: "img.jpg" },
933
+ mockBuildUrl,
934
+ );
935
+ expect(result).toBe("[图片]\nhttps://cdn.example.com/img.jpg");
936
+ });
937
+
938
+ it("should append URL for GIF when buildUrl is provided", () => {
939
+ const result = resolveInnerMessageText(
940
+ { type: MessageType.GIF, url: "anim.gif" },
941
+ mockBuildUrl,
942
+ );
943
+ expect(result).toBe("[GIF]\nhttps://cdn.example.com/anim.gif");
944
+ });
945
+
946
+ it("should append URL for Voice when buildUrl is provided", () => {
947
+ const result = resolveInnerMessageText(
948
+ { type: MessageType.Voice, url: "voice.mp3" },
949
+ mockBuildUrl,
950
+ );
951
+ expect(result).toBe("[语音]\nhttps://cdn.example.com/voice.mp3");
952
+ });
953
+
954
+ it("should append URL for Video when buildUrl is provided", () => {
955
+ const result = resolveInnerMessageText(
956
+ { type: MessageType.Video, url: "clip.mp4" },
957
+ mockBuildUrl,
958
+ );
959
+ expect(result).toBe("[视频]\nhttps://cdn.example.com/clip.mp4");
960
+ });
961
+
962
+ it("should append URL for File when buildUrl is provided", () => {
963
+ const result = resolveInnerMessageText(
964
+ { type: MessageType.File, name: "report.pdf", url: "report.pdf" },
965
+ mockBuildUrl,
966
+ );
967
+ expect(result).toBe("[文件: report.pdf]\nhttps://cdn.example.com/report.pdf");
968
+ });
969
+
970
+ it("should return placeholder without URL when buildUrl is not provided", () => {
971
+ expect(resolveInnerMessageText({ type: MessageType.Image, url: "img.jpg" })).toBe("[图片]");
972
+ expect(resolveInnerMessageText({ type: MessageType.GIF, url: "anim.gif" })).toBe("[GIF]");
973
+ expect(resolveInnerMessageText({ type: MessageType.Voice, url: "voice.mp3" })).toBe("[语音]");
974
+ expect(resolveInnerMessageText({ type: MessageType.Video, url: "clip.mp4" })).toBe("[视频]");
975
+ expect(resolveInnerMessageText({ type: MessageType.File, name: "doc.pdf", url: "doc.pdf" })).toBe("[文件: doc.pdf]");
976
+ });
977
+
978
+ it("should return placeholder when payload.url is missing even with buildUrl", () => {
979
+ expect(resolveInnerMessageText({ type: MessageType.Image }, mockBuildUrl)).toBe("[图片]");
980
+ expect(resolveInnerMessageText({ type: MessageType.Voice }, mockBuildUrl)).toBe("[语音]");
981
+ expect(resolveInnerMessageText({ type: MessageType.File, name: "doc.pdf" }, mockBuildUrl)).toBe("[文件: doc.pdf]");
982
+ });
983
+ });
984
+
985
+ /**
986
+ * Tests for resolveMultipleForwardText with apiUrl/cdnUrl — nested media URL resolution.
987
+ */
988
+ describe("resolveMultipleForwardText with URL resolution", () => {
989
+ it("should include full URLs for media messages when apiUrl is provided", () => {
990
+ const payload = {
991
+ type: MessageType.MultipleForward,
992
+ users: [{ uid: "user1", name: "Alice" }],
993
+ msgs: [
994
+ { from_uid: "user1", payload: { type: MessageType.Image, url: "upload/img.jpg" } },
995
+ { from_uid: "user1", payload: { type: MessageType.File, name: "doc.pdf", url: "upload/doc.pdf" } },
996
+ ],
997
+ };
998
+
999
+ const result = resolveMultipleForwardText(payload, "https://api.example.com");
1000
+ expect(result).toContain("Alice: [图片]\nhttps://api.example.com/file/upload/img.jpg");
1001
+ expect(result).toContain("Alice: [文件: doc.pdf]\nhttps://api.example.com/file/upload/doc.pdf");
1002
+ });
1003
+
1004
+ it("should use cdnUrl when provided", () => {
1005
+ const payload = {
1006
+ type: MessageType.MultipleForward,
1007
+ users: [{ uid: "user1", name: "Bob" }],
1008
+ msgs: [
1009
+ { from_uid: "user1", payload: { type: MessageType.Video, url: "upload/clip.mp4" } },
1010
+ ],
1011
+ };
1012
+
1013
+ const result = resolveMultipleForwardText(payload, "https://api.example.com", "https://cdn.example.com");
1014
+ expect(result).toContain("Bob: [视频]\nhttps://cdn.example.com/upload/clip.mp4");
1015
+ });
1016
+
1017
+ it("should recursively resolve nested MultipleForward with URLs", () => {
1018
+ const payload = {
1019
+ type: MessageType.MultipleForward,
1020
+ users: [{ uid: "user1", name: "张三" }],
1021
+ msgs: [
1022
+ {
1023
+ from_uid: "user1",
1024
+ payload: {
1025
+ type: MessageType.MultipleForward,
1026
+ users: [{ uid: "user2", name: "李四" }],
1027
+ msgs: [
1028
+ { from_uid: "user2", payload: { type: MessageType.File, name: "secret.docx", url: "upload/secret.docx" } },
1029
+ ],
1030
+ },
1031
+ },
1032
+ ],
1033
+ };
1034
+
1035
+ const result = resolveMultipleForwardText(payload, "https://api.example.com");
1036
+ expect(result).toContain("张三: [合并转发]");
1037
+ expect(result).toContain("[合并转发: 聊天记录]");
1038
+ expect(result).toContain("李四: [文件: secret.docx]\nhttps://api.example.com/file/upload/secret.docx");
1039
+ });
1040
+
1041
+ it("should keep placeholders when no apiUrl or cdnUrl provided", () => {
1042
+ const payload = {
1043
+ type: MessageType.MultipleForward,
1044
+ users: [{ uid: "user1", name: "Test" }],
1045
+ msgs: [
1046
+ { from_uid: "user1", payload: { type: MessageType.Image, url: "upload/img.jpg" } },
1047
+ ],
1048
+ };
1049
+
1050
+ const result = resolveMultipleForwardText(payload);
1051
+ expect(result).toBe("[合并转发: 聊天记录]\nTest: [图片]");
1052
+ });
1053
+
1054
+ it("should handle payload.url being empty in nested messages", () => {
1055
+ const payload = {
1056
+ type: MessageType.MultipleForward,
1057
+ users: [{ uid: "user1", name: "Test" }],
1058
+ msgs: [
1059
+ { from_uid: "user1", payload: { type: MessageType.Image } },
1060
+ { from_uid: "user1", payload: { type: MessageType.File, name: "doc.pdf" } },
1061
+ ],
1062
+ };
1063
+
1064
+ const result = resolveMultipleForwardText(payload, "https://api.example.com");
1065
+ expect(result).toContain("Test: [图片]");
1066
+ expect(result).toContain("Test: [文件: doc.pdf]");
1067
+ expect(result).not.toContain("https://");
1068
+ });
1069
+ });
package/src/inbound.ts CHANGED
@@ -243,26 +243,50 @@ export interface ForwardMessage {
243
243
  };
244
244
  }
245
245
 
246
+ /** Build a full media URL from a relative storage path */
247
+ export function buildMediaUrl(relUrl?: string, apiUrl?: string, cdnUrl?: string): string | undefined {
248
+ if (!relUrl) return undefined;
249
+ if (relUrl.startsWith("http")) return relUrl;
250
+ let storagePath = relUrl;
251
+ if (storagePath.startsWith("file/preview/")) {
252
+ storagePath = storagePath.substring("file/preview/".length);
253
+ } else if (storagePath.startsWith("file/")) {
254
+ storagePath = storagePath.substring("file/".length);
255
+ }
256
+ if (cdnUrl) {
257
+ const base = cdnUrl.replace(/\/+$/, "");
258
+ return `${base}/${storagePath}`;
259
+ }
260
+ const baseUrl = apiUrl?.replace(/\/+$/, "") ?? "";
261
+ return `${baseUrl}/file/${storagePath}`;
262
+ }
263
+
246
264
  /** Resolve inner message type to display text for MultipleForward */
247
- export function resolveInnerMessageText(payload: ForwardMessage["payload"]): string {
265
+ export function resolveInnerMessageText(
266
+ payload: ForwardMessage["payload"],
267
+ buildUrl?: (url?: string) => string | undefined,
268
+ ): string {
248
269
  if (!payload) return "";
270
+ const fullUrl = buildUrl?.(payload.url);
249
271
  switch (payload.type) {
250
272
  case MessageType.Text:
251
273
  return payload.content ?? "";
252
274
  case MessageType.Image:
253
- return "[图片]";
275
+ return fullUrl ? `[图片]\n${fullUrl}` : "[图片]";
254
276
  case MessageType.GIF:
255
- return "[GIF]";
277
+ return fullUrl ? `[GIF]\n${fullUrl}` : "[GIF]";
256
278
  case MessageType.Voice:
257
- return "[语音]";
279
+ return fullUrl ? `[语音]\n${fullUrl}` : "[语音]";
258
280
  case MessageType.Video:
259
- return "[视频]";
281
+ return fullUrl ? `[视频]\n${fullUrl}` : "[视频]";
260
282
  case MessageType.Location:
261
283
  return "[位置信息]";
262
284
  case MessageType.Card:
263
285
  return "[名片]";
264
- case MessageType.File:
265
- return payload.name ? `[文件: ${payload.name}]` : "[文件]";
286
+ case MessageType.File: {
287
+ const label = payload.name ? `[文件: ${payload.name}]` : "[文件]";
288
+ return fullUrl ? `${label}\n${fullUrl}` : label;
289
+ }
266
290
  case MessageType.MultipleForward:
267
291
  return "[合并转发]";
268
292
  default:
@@ -271,18 +295,27 @@ export function resolveInnerMessageText(payload: ForwardMessage["payload"]): str
271
295
  }
272
296
 
273
297
  /** Resolve MultipleForward payload into readable text */
274
- export function resolveMultipleForwardText(payload: any): string {
298
+ export function resolveMultipleForwardText(payload: any, apiUrl?: string, cdnUrl?: string): string {
275
299
  const users: ForwardUser[] = payload?.users ?? [];
276
300
  const msgs: ForwardMessage[] = payload?.msgs ?? [];
277
301
  const userMap = new Map<string, string>();
278
302
  for (const u of users) {
279
303
  if (u.uid && u.name) userMap.set(u.uid, u.name);
280
304
  }
305
+ const buildUrl = (apiUrl || cdnUrl)
306
+ ? (url?: string) => buildMediaUrl(url, apiUrl, cdnUrl)
307
+ : undefined;
281
308
  const lines: string[] = ["[合并转发: 聊天记录]"];
282
309
  for (const m of msgs) {
283
310
  const senderName = userMap.get(m.from_uid) ?? m.from_uid;
284
- const content = resolveInnerMessageText(m.payload);
285
- lines.push(`${senderName}: ${content}`);
311
+ if (m.payload?.type === MessageType.MultipleForward) {
312
+ const nested = resolveMultipleForwardText(m.payload, apiUrl, cdnUrl);
313
+ lines.push(`${senderName}: [合并转发]`);
314
+ lines.push(nested);
315
+ } else {
316
+ const content = resolveInnerMessageText(m.payload, buildUrl);
317
+ lines.push(`${senderName}: ${content}`);
318
+ }
286
319
  }
287
320
  return lines.join("\n");
288
321
  }
@@ -290,26 +323,7 @@ export function resolveMultipleForwardText(payload: any): string {
290
323
  function resolveContent(payload: BotMessage["payload"], apiUrl?: string, log?: ChannelLogSink, cdnUrl?: string): ResolvedContent {
291
324
  if (!payload) return { text: "" };
292
325
 
293
- const makeFullUrl = (relUrl?: string) => {
294
- if (!relUrl) return undefined;
295
- if (relUrl.startsWith("http")) return relUrl;
296
- // Strip common path prefixes to get the raw storage path
297
- let storagePath = relUrl;
298
- // Remove "file/preview/" or "file/" prefix
299
- if (storagePath.startsWith("file/preview/")) {
300
- storagePath = storagePath.substring("file/preview/".length);
301
- } else if (storagePath.startsWith("file/")) {
302
- storagePath = storagePath.substring("file/".length);
303
- }
304
- if (cdnUrl) {
305
- // CDN direct: public-read, no auth needed, LLM can access directly
306
- const base = cdnUrl.replace(/\/+$/, "");
307
- return `${base}/${storagePath}`;
308
- }
309
- // Fallback: Nginx public /file/ path (no auth)
310
- const baseUrl = apiUrl?.replace(/\/+$/, "") ?? "";
311
- return `${baseUrl}/file/${storagePath}`;
312
- };
326
+ const makeFullUrl = (relUrl?: string) => buildMediaUrl(relUrl, apiUrl, cdnUrl);
313
327
 
314
328
  switch (payload.type) {
315
329
  case MessageType.Text:
@@ -353,7 +367,7 @@ function resolveContent(payload: BotMessage["payload"], apiUrl?: string, log?: C
353
367
  return { text: cardText };
354
368
  }
355
369
  case MessageType.MultipleForward: {
356
- return { text: resolveMultipleForwardText(payload) };
370
+ return { text: resolveMultipleForwardText(payload, apiUrl, cdnUrl) };
357
371
  }
358
372
  default:
359
373
  return { text: payload.content ?? payload.url ?? "" };
@@ -1188,7 +1202,7 @@ export async function handleInboundMessage(params: {
1188
1202
  let body = m.content || resolveApiMessagePlaceholder(m.type, m.name);
1189
1203
  // For MultipleForward, expand the nested messages from full payload
1190
1204
  if (m.type === MessageType.MultipleForward && m.payload) {
1191
- body = resolveMultipleForwardText(m.payload);
1205
+ body = resolveMultipleForwardText(m.payload, account.config.apiUrl, account.config.cdnUrl);
1192
1206
  }
1193
1207
  const entry: any = {
1194
1208
  sender: m.from_uid,
@@ -1416,8 +1430,17 @@ export async function handleInboundMessage(params: {
1416
1430
  replyOptions: {
1417
1431
  onPartialReply: async (partial: { text?: string; mediaUrls?: string[] }) => {
1418
1432
  if (streamFailed) return;
1419
- const text = partial.text?.trim();
1433
+ let text = partial.text?.trim();
1420
1434
  if (!text) return;
1435
+ // Convert @[uid:name] → @name for display (no entities — streaming should not trigger notifications)
1436
+ if (isGroup) {
1437
+ const structuredMentions = parseStructuredMentions(text);
1438
+ if (structuredMentions.length > 0) {
1439
+ const validUids = new Set(uidToNameMap.keys());
1440
+ const converted = convertStructuredMentions(text, structuredMentions, validUids);
1441
+ text = converted.content;
1442
+ }
1443
+ }
1421
1444
  try {
1422
1445
  if (!streamNo) {
1423
1446
  // Start stream