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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
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
- // But allow group messages through — bot-to-bot @mention in groups is legitimate;
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: () => {
@@ -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: m.content || resolveApiMessagePlaceholder(m.type, m.name),
665
+ body,
594
666
  timestamp: m.timestamp,
595
667
  };
596
668
  // For media message types, resolve the URL directly (storage is public-read)
package/src/types.ts CHANGED
@@ -105,6 +105,7 @@ export enum MessageType {
105
105
  Location = 6,
106
106
  Card = 7,
107
107
  File = 8,
108
+ MultipleForward = 11,
108
109
  }
109
110
 
110
111
  /** Plugin config */