openclaw-channel-dmwork 0.5.16 → 0.5.17-dev.84f17dc
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 +1 -1
- package/src/actions.test.ts +183 -0
- package/src/actions.ts +44 -8
- package/src/inbound.test.ts +202 -0
- package/src/inbound.ts +56 -33
package/package.json
CHANGED
package/src/actions.test.ts
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
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
|
|
package/src/inbound.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|