openclaw-channel-dmwork 0.3.8 → 0.4.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.
- package/package.json +1 -1
- package/src/api-fetch.ts +90 -0
- package/src/channel.ts +85 -6
- package/src/inbound.test.ts +122 -0
- package/src/inbound.ts +75 -3
- package/src/types.ts +1 -0
package/package.json
CHANGED
package/src/api-fetch.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { ChannelType, MessageType } from "./types.js";
|
|
7
|
+
import path from "path";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
10
|
|
|
@@ -43,6 +44,94 @@ export async function postJson<T>(
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Upload a file to DMWork backend via multipart/form-data.
|
|
49
|
+
* Returns the CDN URL of the uploaded file.
|
|
50
|
+
*/
|
|
51
|
+
export async function uploadFile(params: {
|
|
52
|
+
apiUrl: string;
|
|
53
|
+
botToken: string;
|
|
54
|
+
fileBuffer: Buffer;
|
|
55
|
+
filename: string;
|
|
56
|
+
contentType: string;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
}): Promise<{ url: string }> {
|
|
59
|
+
const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/upload`;
|
|
60
|
+
const formData = new FormData();
|
|
61
|
+
const blob = new Blob([new Uint8Array(params.fileBuffer)], { type: params.contentType });
|
|
62
|
+
formData.append("file", blob, params.filename);
|
|
63
|
+
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${params.botToken}`,
|
|
68
|
+
},
|
|
69
|
+
body: formData,
|
|
70
|
+
signal: params.signal,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const text = await response.text().catch(() => "");
|
|
75
|
+
throw new Error(`DMWork API /v1/bot/upload failed (${response.status}): ${text || response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = await response.json() as { url?: string };
|
|
79
|
+
if (!data.url) {
|
|
80
|
+
throw new Error("DMWork API /v1/bot/upload returned no url");
|
|
81
|
+
}
|
|
82
|
+
return { url: data.url };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a media message (image or file) to a channel.
|
|
87
|
+
*/
|
|
88
|
+
export async function sendMediaMessage(params: {
|
|
89
|
+
apiUrl: string;
|
|
90
|
+
botToken: string;
|
|
91
|
+
channelId: string;
|
|
92
|
+
channelType: ChannelType;
|
|
93
|
+
type: MessageType;
|
|
94
|
+
url: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
size?: number;
|
|
97
|
+
mentionUids?: string[];
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}): Promise<void> {
|
|
100
|
+
const payload: Record<string, unknown> = {
|
|
101
|
+
type: params.type,
|
|
102
|
+
url: params.url,
|
|
103
|
+
};
|
|
104
|
+
if (params.name) payload.name = params.name;
|
|
105
|
+
if (params.size != null) payload.size = params.size;
|
|
106
|
+
if (params.mentionUids && params.mentionUids.length > 0) {
|
|
107
|
+
payload.mention = { uids: params.mentionUids };
|
|
108
|
+
}
|
|
109
|
+
await postJson(params.apiUrl, params.botToken, "/v1/bot/sendMessage", {
|
|
110
|
+
channel_id: params.channelId,
|
|
111
|
+
channel_type: params.channelType,
|
|
112
|
+
payload,
|
|
113
|
+
}, params.signal);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Infer MIME type from filename extension. Returns a sensible default if unknown.
|
|
118
|
+
*/
|
|
119
|
+
export function inferContentType(filename: string): string {
|
|
120
|
+
const ext = path.extname(filename).toLowerCase();
|
|
121
|
+
const map: Record<string, string> = {
|
|
122
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
123
|
+
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
124
|
+
".bmp": "image/bmp", ".ico": "image/x-icon",
|
|
125
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
126
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
127
|
+
".pdf": "application/pdf", ".zip": "application/zip",
|
|
128
|
+
".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
129
|
+
".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
130
|
+
".txt": "text/plain", ".json": "application/json",
|
|
131
|
+
};
|
|
132
|
+
return map[ext] ?? "application/octet-stream";
|
|
133
|
+
}
|
|
134
|
+
|
|
46
135
|
export async function sendMessage(params: {
|
|
47
136
|
apiUrl: string;
|
|
48
137
|
botToken: string;
|
|
@@ -271,6 +360,7 @@ export async function getChannelMessages(params: {
|
|
|
271
360
|
url: payload.url ?? undefined,
|
|
272
361
|
name: payload.name ?? undefined,
|
|
273
362
|
content: payload.content ?? "",
|
|
363
|
+
payload, // preserve full payload for types that need nested data (e.g. MultipleForward)
|
|
274
364
|
// Convert seconds to milliseconds (API returns seconds, internal standard is ms)
|
|
275
365
|
timestamp: (m.timestamp ?? Math.floor(Date.now() / 1000)) * 1000,
|
|
276
366
|
};
|
package/src/channel.ts
CHANGED
|
@@ -11,14 +11,14 @@ import {
|
|
|
11
11
|
resolveDmworkAccount,
|
|
12
12
|
type ResolvedDmworkAccount,
|
|
13
13
|
} from "./accounts.js";
|
|
14
|
-
import { registerBot, sendMessage, sendHeartbeat } from "./api-fetch.js";
|
|
14
|
+
import { registerBot, sendMessage, sendHeartbeat, uploadFile, sendMediaMessage, inferContentType } from "./api-fetch.js";
|
|
15
15
|
import { WKSocket } from "./socket.js";
|
|
16
16
|
import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
|
|
17
17
|
import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
|
|
18
18
|
import { parseMentions } from "./mention-utils.js";
|
|
19
19
|
import path from "path";
|
|
20
20
|
import os from "os";
|
|
21
|
-
import { mkdir, writeFile } from "fs/promises";
|
|
21
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
22
22
|
// HistoryEntry type - compatible with any version
|
|
23
23
|
type HistoryEntry = { sender: string; body: string; timestamp: number };
|
|
24
24
|
const DEFAULT_GROUP_HISTORY_LIMIT = 20;
|
|
@@ -242,6 +242,85 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
242
242
|
...(mentionUids.length > 0 ? { mentionUids } : {}),
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
return { channel: "dmwork", to: ctx.to, messageId: "" };
|
|
246
|
+
},
|
|
247
|
+
sendMedia: async (ctx) => {
|
|
248
|
+
const account = resolveDmworkAccount({
|
|
249
|
+
cfg: ctx.cfg as OpenClawConfig,
|
|
250
|
+
accountId: ctx.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
251
|
+
});
|
|
252
|
+
if (!account.config.botToken) {
|
|
253
|
+
throw new Error("DMWork botToken is not configured");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const mediaUrl = ctx.mediaUrl;
|
|
257
|
+
if (!mediaUrl) {
|
|
258
|
+
throw new Error("sendMedia called without mediaUrl");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 1. Download the file
|
|
262
|
+
let fileBuffer: Buffer;
|
|
263
|
+
let contentType: string | undefined;
|
|
264
|
+
let filename: string;
|
|
265
|
+
|
|
266
|
+
if (mediaUrl.startsWith("file://")) {
|
|
267
|
+
const filePath = decodeURIComponent(mediaUrl.slice(7));
|
|
268
|
+
fileBuffer = await readFile(filePath);
|
|
269
|
+
filename = path.basename(filePath);
|
|
270
|
+
contentType = inferContentType(filename);
|
|
271
|
+
} else {
|
|
272
|
+
const resp = await fetch(mediaUrl, { signal: AbortSignal.timeout(60_000) });
|
|
273
|
+
if (!resp.ok) {
|
|
274
|
+
throw new Error(`Failed to download media from ${mediaUrl}: ${resp.status}`);
|
|
275
|
+
}
|
|
276
|
+
fileBuffer = Buffer.from(await resp.arrayBuffer());
|
|
277
|
+
contentType = resp.headers.get("content-type") ?? undefined;
|
|
278
|
+
// Extract filename from URL path
|
|
279
|
+
const urlPath = new URL(mediaUrl).pathname;
|
|
280
|
+
filename = path.basename(urlPath) || "file";
|
|
281
|
+
if (!contentType) {
|
|
282
|
+
contentType = inferContentType(filename);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
contentType = contentType || "application/octet-stream";
|
|
287
|
+
|
|
288
|
+
// 2. Upload to backend
|
|
289
|
+
const { url: cdnUrl } = await uploadFile({
|
|
290
|
+
apiUrl: account.config.apiUrl,
|
|
291
|
+
botToken: account.config.botToken,
|
|
292
|
+
fileBuffer,
|
|
293
|
+
filename,
|
|
294
|
+
contentType,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 3. Parse target (same logic as sendText)
|
|
298
|
+
let channelId = ctx.to;
|
|
299
|
+
let channelType = ChannelType.DM;
|
|
300
|
+
|
|
301
|
+
if (ctx.to.startsWith("group:")) {
|
|
302
|
+
const groupPart = ctx.to.slice(6);
|
|
303
|
+
const atIdx = groupPart.indexOf("@");
|
|
304
|
+
channelId = atIdx >= 0 ? groupPart.slice(0, atIdx) : groupPart;
|
|
305
|
+
channelType = ChannelType.Group;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 4. Determine message type and send
|
|
309
|
+
const msgType = contentType.startsWith("image/")
|
|
310
|
+
? MessageType.Image
|
|
311
|
+
: MessageType.File;
|
|
312
|
+
|
|
313
|
+
await sendMediaMessage({
|
|
314
|
+
apiUrl: account.config.apiUrl,
|
|
315
|
+
botToken: account.config.botToken,
|
|
316
|
+
channelId,
|
|
317
|
+
channelType,
|
|
318
|
+
type: msgType,
|
|
319
|
+
url: cdnUrl,
|
|
320
|
+
name: filename,
|
|
321
|
+
size: fileBuffer.length,
|
|
322
|
+
});
|
|
323
|
+
|
|
245
324
|
return { channel: "dmwork", to: ctx.to, messageId: "" };
|
|
246
325
|
},
|
|
247
326
|
},
|
|
@@ -380,11 +459,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
380
459
|
// Skip self messages
|
|
381
460
|
if (msg.from_uid === credentials.robot_id) return;
|
|
382
461
|
// Skip messages from any other bot in this plugin instance (prevent bot-to-bot loops)
|
|
383
|
-
|
|
384
|
-
// mention gating in inbound.ts ensures only @-targeted messages trigger AI.
|
|
385
|
-
if (_knownBotUids.has(msg.from_uid) && msg.channel_type === ChannelType.DM) return;
|
|
462
|
+
if (_knownBotUids.has(msg.from_uid)) return;
|
|
386
463
|
// Skip unsupported message types (Location, Card)
|
|
387
|
-
const supportedTypes = [MessageType.Text, MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video, MessageType.File];
|
|
464
|
+
const supportedTypes = [MessageType.Text, MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video, MessageType.File, MessageType.MultipleForward];
|
|
388
465
|
if (!msg.payload || !supportedTypes.includes(msg.payload.type)) return;
|
|
389
466
|
|
|
390
467
|
// Defense-in-depth DM filter (kept for safety, though v0.2.28+ uses independent
|
|
@@ -429,6 +506,8 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
429
506
|
statusSink({ lastError: null });
|
|
430
507
|
startHeartbeat();
|
|
431
508
|
// WS connected successfully = WuKongIM accepted the token
|
|
509
|
+
// Reset refresh flag so we can refresh again if kicked later (#92)
|
|
510
|
+
hasRefreshedToken = false;
|
|
432
511
|
},
|
|
433
512
|
|
|
434
513
|
onDisconnected: () => {
|
package/src/inbound.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { ChannelType, MessageType, type MentionPayload } from "./types.js";
|
|
3
3
|
import { DEFAULT_HISTORY_PROMPT_TEMPLATE } from "./config-schema.js";
|
|
4
|
+
import { resolveInnerMessageText, resolveApiMessagePlaceholder, resolveMultipleForwardText } from "./inbound.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Tests for mention.all detection logic.
|
|
@@ -166,3 +167,124 @@ describe("timestamp standardization", () => {
|
|
|
166
167
|
expect(String(secondsTimestamp * 1000).length).toBe(13);
|
|
167
168
|
});
|
|
168
169
|
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tests for MultipleForward (type=11) message handling.
|
|
173
|
+
*
|
|
174
|
+
* MultipleForward is a merge-forwarded chat record containing:
|
|
175
|
+
* - users: array of {uid, name} for sender info
|
|
176
|
+
* - msgs: array of messages with payload
|
|
177
|
+
*/
|
|
178
|
+
describe("MultipleForward handling", () => {
|
|
179
|
+
it("should resolve MultipleForward with text messages", () => {
|
|
180
|
+
const payload = {
|
|
181
|
+
type: MessageType.MultipleForward,
|
|
182
|
+
users: [
|
|
183
|
+
{ uid: "user1", name: "大棍子" },
|
|
184
|
+
{ uid: "user2", name: "托马斯" },
|
|
185
|
+
],
|
|
186
|
+
msgs: [
|
|
187
|
+
{ from_uid: "user1", payload: { type: MessageType.Text, content: "你好" } },
|
|
188
|
+
{ from_uid: "user2", payload: { type: MessageType.Text, content: "Hello" } },
|
|
189
|
+
{ from_uid: "user1", payload: { type: MessageType.Text, content: "晚上好" } },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const result = { text: resolveMultipleForwardText(payload) };
|
|
194
|
+
expect(result.text).toBe(
|
|
195
|
+
"[合并转发: 聊天记录]\n大棍子: 你好\n托马斯: Hello\n大棍子: 晚上好"
|
|
196
|
+
);
|
|
197
|
+
// mediaUrl is not part of the resolved text result
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("should resolve MultipleForward with mixed types", () => {
|
|
201
|
+
const payload = {
|
|
202
|
+
type: MessageType.MultipleForward,
|
|
203
|
+
users: [
|
|
204
|
+
{ uid: "user1", name: "Alice" },
|
|
205
|
+
{ uid: "user2", name: "Bob" },
|
|
206
|
+
],
|
|
207
|
+
msgs: [
|
|
208
|
+
{ from_uid: "user1", payload: { type: MessageType.Text, content: "Check this out" } },
|
|
209
|
+
{ from_uid: "user2", payload: { type: MessageType.Image, url: "http://example.com/img.jpg" } },
|
|
210
|
+
{ from_uid: "user1", payload: { type: MessageType.File, name: "document.pdf" } },
|
|
211
|
+
{ from_uid: "user2", payload: { type: MessageType.Voice } },
|
|
212
|
+
{ from_uid: "user1", payload: { type: MessageType.Video } },
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = { text: resolveMultipleForwardText(payload) };
|
|
217
|
+
expect(result.text).toContain("[合并转发: 聊天记录]");
|
|
218
|
+
expect(result.text).toContain("Alice: Check this out");
|
|
219
|
+
expect(result.text).toContain("Bob: [图片]");
|
|
220
|
+
expect(result.text).toContain("Alice: [文件: document.pdf]");
|
|
221
|
+
expect(result.text).toContain("Bob: [语音]");
|
|
222
|
+
expect(result.text).toContain("Alice: [视频]");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should resolve nested MultipleForward", () => {
|
|
226
|
+
const payload = {
|
|
227
|
+
type: MessageType.MultipleForward,
|
|
228
|
+
users: [{ uid: "user1", name: "张三" }],
|
|
229
|
+
msgs: [
|
|
230
|
+
{ from_uid: "user1", payload: { type: MessageType.Text, content: "看这个" } },
|
|
231
|
+
{
|
|
232
|
+
from_uid: "user1",
|
|
233
|
+
payload: {
|
|
234
|
+
type: MessageType.MultipleForward,
|
|
235
|
+
users: [{ uid: "user2", name: "李四" }],
|
|
236
|
+
msgs: [{ from_uid: "user2", payload: { type: MessageType.Text, content: "内层消息" } }],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const result = { text: resolveMultipleForwardText(payload) };
|
|
243
|
+
expect(result.text).toContain("[合并转发: 聊天记录]");
|
|
244
|
+
expect(result.text).toContain("张三: 看这个");
|
|
245
|
+
expect(result.text).toContain("张三: [合并转发]");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should handle empty msgs array", () => {
|
|
249
|
+
const payload = {
|
|
250
|
+
type: MessageType.MultipleForward,
|
|
251
|
+
users: [{ uid: "user1", name: "Test" }],
|
|
252
|
+
msgs: [],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const result = { text: resolveMultipleForwardText(payload) };
|
|
256
|
+
expect(result.text).toBe("[合并转发: 聊天记录]");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should handle missing users array", () => {
|
|
260
|
+
const payload = {
|
|
261
|
+
type: MessageType.MultipleForward,
|
|
262
|
+
msgs: [
|
|
263
|
+
{ from_uid: "unknown_uid_123", payload: { type: MessageType.Text, content: "Hello" } },
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const result = { text: resolveMultipleForwardText(payload) };
|
|
268
|
+
expect(result.text).toContain("[合并转发: 聊天记录]");
|
|
269
|
+
expect(result.text).toContain("unknown_uid_123: Hello");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("should return placeholder for resolveApiMessagePlaceholder", () => {
|
|
273
|
+
expect(resolveApiMessagePlaceholder(MessageType.MultipleForward)).toBe("[合并转发]");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("resolveInnerMessageText should handle all message types", () => {
|
|
277
|
+
expect(resolveInnerMessageText({ type: MessageType.Text, content: "test" })).toBe("test");
|
|
278
|
+
expect(resolveInnerMessageText({ type: MessageType.Image })).toBe("[图片]");
|
|
279
|
+
expect(resolveInnerMessageText({ type: MessageType.GIF })).toBe("[GIF]");
|
|
280
|
+
expect(resolveInnerMessageText({ type: MessageType.Voice })).toBe("[语音]");
|
|
281
|
+
expect(resolveInnerMessageText({ type: MessageType.Video })).toBe("[视频]");
|
|
282
|
+
expect(resolveInnerMessageText({ type: MessageType.Location })).toBe("[位置信息]");
|
|
283
|
+
expect(resolveInnerMessageText({ type: MessageType.Card })).toBe("[名片]");
|
|
284
|
+
expect(resolveInnerMessageText({ type: MessageType.File, name: "doc.pdf" })).toBe("[文件: doc.pdf]");
|
|
285
|
+
expect(resolveInnerMessageText({ type: MessageType.File })).toBe("[文件]");
|
|
286
|
+
expect(resolveInnerMessageText({ type: MessageType.MultipleForward })).toBe("[合并转发]");
|
|
287
|
+
expect(resolveInnerMessageText({ type: 99 })).toBe("[消息]");
|
|
288
|
+
expect(resolveInnerMessageText({ type: 99, content: "fallback" })).toBe("fallback");
|
|
289
|
+
});
|
|
290
|
+
});
|
package/src/inbound.ts
CHANGED
|
@@ -145,12 +145,75 @@ function guessMime(pathOrName?: string, fallback = "application/octet-stream"):
|
|
|
145
145
|
return map[ext] ?? fallback;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
interface ResolvedContent {
|
|
148
|
+
export interface ResolvedContent {
|
|
149
149
|
text: string;
|
|
150
150
|
mediaUrl?: string;
|
|
151
151
|
mediaType?: string;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
export interface ForwardUser {
|
|
155
|
+
uid: string;
|
|
156
|
+
name: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ForwardMessage {
|
|
160
|
+
message_id?: string;
|
|
161
|
+
from_uid: string;
|
|
162
|
+
timestamp?: number;
|
|
163
|
+
payload: {
|
|
164
|
+
type: number;
|
|
165
|
+
content?: string;
|
|
166
|
+
url?: string;
|
|
167
|
+
name?: string;
|
|
168
|
+
users?: ForwardUser[];
|
|
169
|
+
msgs?: ForwardMessage[];
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolve inner message type to display text for MultipleForward */
|
|
174
|
+
export function resolveInnerMessageText(payload: ForwardMessage["payload"]): string {
|
|
175
|
+
if (!payload) return "";
|
|
176
|
+
switch (payload.type) {
|
|
177
|
+
case MessageType.Text:
|
|
178
|
+
return payload.content ?? "";
|
|
179
|
+
case MessageType.Image:
|
|
180
|
+
return "[图片]";
|
|
181
|
+
case MessageType.GIF:
|
|
182
|
+
return "[GIF]";
|
|
183
|
+
case MessageType.Voice:
|
|
184
|
+
return "[语音]";
|
|
185
|
+
case MessageType.Video:
|
|
186
|
+
return "[视频]";
|
|
187
|
+
case MessageType.Location:
|
|
188
|
+
return "[位置信息]";
|
|
189
|
+
case MessageType.Card:
|
|
190
|
+
return "[名片]";
|
|
191
|
+
case MessageType.File:
|
|
192
|
+
return payload.name ? `[文件: ${payload.name}]` : "[文件]";
|
|
193
|
+
case MessageType.MultipleForward:
|
|
194
|
+
return "[合并转发]";
|
|
195
|
+
default:
|
|
196
|
+
return payload.content ?? "[消息]";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Resolve MultipleForward payload into readable text */
|
|
201
|
+
export function resolveMultipleForwardText(payload: any): string {
|
|
202
|
+
const users: ForwardUser[] = payload?.users ?? [];
|
|
203
|
+
const msgs: ForwardMessage[] = payload?.msgs ?? [];
|
|
204
|
+
const userMap = new Map<string, string>();
|
|
205
|
+
for (const u of users) {
|
|
206
|
+
if (u.uid && u.name) userMap.set(u.uid, u.name);
|
|
207
|
+
}
|
|
208
|
+
const lines: string[] = ["[合并转发: 聊天记录]"];
|
|
209
|
+
for (const m of msgs) {
|
|
210
|
+
const senderName = userMap.get(m.from_uid) ?? m.from_uid;
|
|
211
|
+
const content = resolveInnerMessageText(m.payload);
|
|
212
|
+
lines.push(`${senderName}: ${content}`);
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
154
217
|
function resolveContent(payload: BotMessage["payload"], apiUrl?: string, log?: ChannelLogSink, cdnUrl?: string): ResolvedContent {
|
|
155
218
|
if (!payload) return { text: "" };
|
|
156
219
|
|
|
@@ -216,6 +279,9 @@ function resolveContent(payload: BotMessage["payload"], apiUrl?: string, log?: C
|
|
|
216
279
|
const cardText = cardUid ? `[名片: ${cardName} (${cardUid})]` : `[名片: ${cardName}]`;
|
|
217
280
|
return { text: cardText };
|
|
218
281
|
}
|
|
282
|
+
case MessageType.MultipleForward: {
|
|
283
|
+
return { text: resolveMultipleForwardText(payload) };
|
|
284
|
+
}
|
|
219
285
|
default:
|
|
220
286
|
return { text: payload.content ?? payload.url ?? "" };
|
|
221
287
|
}
|
|
@@ -282,7 +348,7 @@ async function resolveFileContent(
|
|
|
282
348
|
}
|
|
283
349
|
|
|
284
350
|
/** Placeholder text for non-text API history messages */
|
|
285
|
-
function resolveApiMessagePlaceholder(type?: number, name?: string): string {
|
|
351
|
+
export function resolveApiMessagePlaceholder(type?: number, name?: string): string {
|
|
286
352
|
switch (type) {
|
|
287
353
|
case MessageType.Image: return "[图片]";
|
|
288
354
|
case MessageType.GIF: return "[GIF]";
|
|
@@ -291,6 +357,7 @@ function resolveApiMessagePlaceholder(type?: number, name?: string): string {
|
|
|
291
357
|
case MessageType.File: return `[文件: ${name ?? "未知文件"}]`;
|
|
292
358
|
case MessageType.Location: return "[位置信息]";
|
|
293
359
|
case MessageType.Card: return "[名片]";
|
|
360
|
+
case MessageType.MultipleForward: return "[合并转发]";
|
|
294
361
|
default: return "[消息]";
|
|
295
362
|
}
|
|
296
363
|
}
|
|
@@ -588,9 +655,14 @@ export async function handleInboundMessage(params: {
|
|
|
588
655
|
.filter((m: any) => m.from_uid !== botUid && (m.content || m.type !== 1))
|
|
589
656
|
.slice(-historyLimit);
|
|
590
657
|
entries = filteredApiMsgs.map((m: any) => {
|
|
658
|
+
let body = m.content || resolveApiMessagePlaceholder(m.type, m.name);
|
|
659
|
+
// For MultipleForward, expand the nested messages from full payload
|
|
660
|
+
if (m.type === MessageType.MultipleForward && m.payload) {
|
|
661
|
+
body = resolveMultipleForwardText(m.payload);
|
|
662
|
+
}
|
|
591
663
|
const entry: any = {
|
|
592
664
|
sender: m.from_uid,
|
|
593
|
-
body
|
|
665
|
+
body,
|
|
594
666
|
timestamp: m.timestamp,
|
|
595
667
|
};
|
|
596
668
|
// For media message types, resolve the URL directly (storage is public-read)
|