openclaw-channel-dmwork 0.2.31 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.2.31",
3
+ "version": "0.3.0",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -43,5 +43,10 @@
43
43
  "crypto-js",
44
44
  "curve25519-js",
45
45
  "md5-typescript"
46
+ ],
47
+ "bundleDependencies": [
48
+ "crypto-js",
49
+ "curve25519-js",
50
+ "md5-typescript"
46
51
  ]
47
52
  }
package/src/api-fetch.ts CHANGED
@@ -11,7 +11,7 @@ const DEFAULT_HEADERS = {
11
11
  "Content-Type": "application/json",
12
12
  };
13
13
 
14
- async function postJson<T>(
14
+ export async function postJson<T>(
15
15
  apiUrl: string,
16
16
  botToken: string,
17
17
  path: string,
@@ -52,6 +52,7 @@ export async function sendMessage(params: {
52
52
  mentionUids?: string[];
53
53
  mentionAll?: boolean;
54
54
  streamNo?: string;
55
+ replyMsgId?: string;
55
56
  signal?: AbortSignal;
56
57
  }): Promise<void> {
57
58
  const payload: Record<string, unknown> = {
@@ -69,6 +70,10 @@ export async function sendMessage(params: {
69
70
  }
70
71
  payload.mention = mention;
71
72
  }
73
+ // Add reply field if replyMsgId is provided
74
+ if (params.replyMsgId) {
75
+ payload.reply = { message_id: params.replyMsgId };
76
+ }
72
77
  await postJson(params.apiUrl, params.botToken, "/v1/bot/sendMessage", {
73
78
  channel_id: params.channelId,
74
79
  channel_type: params.channelType,
package/src/channel.ts CHANGED
@@ -16,6 +16,9 @@ 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
+ import path from "path";
20
+ import os from "os";
21
+ import { mkdir, writeFile } from "fs/promises";
19
22
  // HistoryEntry type - compatible with any version
20
23
  type HistoryEntry = { sender: string; body: string; timestamp: number };
21
24
  const DEFAULT_GROUP_HISTORY_LIMIT = 20;
@@ -108,6 +111,39 @@ function ensureCleanupTimer(): void {
108
111
  }
109
112
  }
110
113
 
114
+ async function checkForUpdates(
115
+ apiUrl: string,
116
+ log?: { info?: (msg: string) => void; error?: (msg: string) => void; warn?: (msg: string) => void },
117
+ ): Promise<void> {
118
+ try {
119
+ // Check npm version
120
+ const localVersion = (await import("../package.json", { with: { type: "json" } })).default.version;
121
+ const resp = await fetch("https://registry.npmjs.org/openclaw-channel-dmwork/latest");
122
+ if (resp.ok) {
123
+ const data = await resp.json() as { version?: string };
124
+ if (data.version && data.version !== localVersion) {
125
+ log?.info?.(`dmwork: new version available: ${data.version} (current: ${localVersion}). Run: npm install openclaw-channel-dmwork@latest`);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ log?.error?.(`dmwork: version check failed: ${String(err)}`);
130
+ }
131
+
132
+ try {
133
+ // Fetch skill.md
134
+ const skillResp = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/bot/skill.md`);
135
+ if (skillResp.ok) {
136
+ const skillContent = await skillResp.text();
137
+ const skillDir = path.join(os.homedir(), ".openclaw", "skills", "dmwork");
138
+ await mkdir(skillDir, { recursive: true });
139
+ await writeFile(path.join(skillDir, "SKILL.md"), skillContent, "utf-8");
140
+ log?.info?.("dmwork: updated SKILL.md");
141
+ }
142
+ } catch (err) {
143
+ log?.error?.(`dmwork: skill.md fetch failed: ${String(err)}`);
144
+ }
145
+ }
146
+
111
147
  const meta = {
112
148
  id: "dmwork",
113
149
  label: "DMWork",
@@ -275,6 +311,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
275
311
  `[${account.accountId}] bot registered as ${credentials.robot_id}`,
276
312
  );
277
313
 
314
+ // Check for updates in background (fire-and-forget)
315
+ checkForUpdates(account.config.apiUrl, log).catch(() => {});
316
+
278
317
  ctx.setStatus({
279
318
  accountId: account.accountId,
280
319
  running: true,
package/src/inbound.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers } from "./api-fetch.js";
2
+ import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, postJson } from "./api-fetch.js";
3
3
  import type { ResolvedDmworkAccount } from "./accounts.js";
4
4
  import type { BotMessage } from "./types.js";
5
5
  import { ChannelType, MessageType } from "./types.js";
@@ -46,39 +46,165 @@ export type DmworkStatusSink = (patch: {
46
46
  lastError?: string | null;
47
47
  }) => void;
48
48
 
49
+ /** Extract media URLs from deliver payload */
50
+ function resolveOutboundMediaUrls(payload: { mediaUrl?: string; mediaUrls?: string[] }): string[] {
51
+ return [
52
+ ...(payload.mediaUrls ?? []),
53
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
54
+ ].filter(Boolean);
55
+ }
56
+
57
+ /** Extract filename from a URL path */
58
+ function extractFilename(url: string): string {
59
+ try {
60
+ const pathname = new URL(url).pathname;
61
+ const parts = pathname.split("/");
62
+ return parts[parts.length - 1] || "file";
63
+ } catch {
64
+ return "file";
65
+ }
66
+ }
67
+
68
+ /** Upload media to MinIO and send as image/file message */
69
+ async function uploadAndSendMedia(params: {
70
+ mediaUrl: string;
71
+ apiUrl: string;
72
+ botToken: string;
73
+ channelId: string;
74
+ channelType: ChannelType;
75
+ log?: ChannelLogSink;
76
+ }): Promise<void> {
77
+ const { mediaUrl, apiUrl, botToken, channelId, channelType, log } = params;
78
+
79
+ // Fetch the media content
80
+ const resp = await fetch(mediaUrl);
81
+ if (!resp.ok) throw new Error(`Failed to fetch media: ${resp.status}`);
82
+ const buffer = Buffer.from(await resp.arrayBuffer());
83
+ const contentType = resp.headers.get("content-type") || "application/octet-stream";
84
+ const filename = extractFilename(mediaUrl);
85
+
86
+ // Upload to MinIO via multipart
87
+ const boundary = `----FormBoundary${Date.now()}`;
88
+ const bodyParts: Buffer[] = [];
89
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
90
+ const footer = `\r\n--${boundary}--\r\n`;
91
+ bodyParts.push(Buffer.from(header, "utf-8"));
92
+ bodyParts.push(buffer);
93
+ bodyParts.push(Buffer.from(footer, "utf-8"));
94
+ const body = Buffer.concat(bodyParts);
95
+
96
+ const uploadUrl = `${apiUrl.replace(/\/+$/, "")}/v1/bot/upload?type=chat`;
97
+ const uploadResp = await fetch(uploadUrl, {
98
+ method: "POST",
99
+ headers: {
100
+ Authorization: `Bearer ${botToken}`,
101
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
102
+ },
103
+ body,
104
+ });
105
+ if (!uploadResp.ok) {
106
+ const text = await uploadResp.text().catch(() => "");
107
+ throw new Error(`Upload failed (${uploadResp.status}): ${text}`);
108
+ }
109
+ const uploadResult = await uploadResp.json() as { path?: string; url?: string };
110
+ const fileUrl = uploadResult.path ?? uploadResult.url ?? "";
111
+
112
+ // Determine message type from MIME
113
+ const msgType = contentType.startsWith("image/") ? MessageType.Image : MessageType.File;
114
+
115
+ log?.info?.(`dmwork: uploaded media as ${msgType === MessageType.Image ? "image" : "file"}: ${filename}`);
116
+
117
+ // Send via sendMessage payload
118
+ await postJson(apiUrl, botToken, "/v1/bot/sendMessage", {
119
+ channel_id: channelId,
120
+ channel_type: channelType,
121
+ payload: {
122
+ type: msgType,
123
+ url: fileUrl,
124
+ name: filename,
125
+ },
126
+ });
127
+ }
128
+
49
129
  interface ResolvedContent {
50
130
  text: string;
51
131
  mediaUrl?: string;
52
132
  mediaType?: string;
53
133
  }
54
134
 
55
- function resolveContent(payload: BotMessage["payload"]): ResolvedContent {
135
+ function resolveContent(payload: BotMessage["payload"], apiUrl?: string): ResolvedContent {
56
136
  if (!payload) return { text: "" };
137
+
138
+ const makeFullUrl = (relUrl?: string) => {
139
+ if (!relUrl) return undefined;
140
+ if (relUrl.startsWith("http")) return relUrl;
141
+ return `${apiUrl?.replace(/\/+$/, "")}/v1/bot/file/${relUrl}`;
142
+ };
143
+
57
144
  switch (payload.type) {
58
145
  case MessageType.Text:
59
146
  return { text: payload.content ?? "" };
60
147
  case MessageType.Image:
61
- return { text: "[图片]", mediaUrl: payload.url, mediaType: "image" };
148
+ return { text: "[图片]", mediaUrl: makeFullUrl(payload.url), mediaType: "image" };
62
149
  case MessageType.GIF:
63
- return { text: "[GIF]", mediaUrl: payload.url, mediaType: "image" };
150
+ return { text: "[GIF]", mediaUrl: makeFullUrl(payload.url), mediaType: "image" };
64
151
  case MessageType.Voice:
65
- return { text: "[语音消息]" };
152
+ return { text: "[语音消息]", mediaUrl: makeFullUrl(payload.url), mediaType: "audio" };
66
153
  case MessageType.Video:
67
- return { text: "[视频]", mediaUrl: payload.url, mediaType: "video" };
154
+ return { text: "[视频]", mediaUrl: makeFullUrl(payload.url), mediaType: "video" };
68
155
  case MessageType.File:
69
- return { text: `[文件: ${payload.name ?? "未知文件"}]`, mediaUrl: payload.url };
70
- case MessageType.Location:
71
- return { text: "[位置信息]" };
72
- case MessageType.Card:
73
- return { text: "[名片]" };
156
+ return { text: `[文件: ${payload.name ?? "未知文件"}]`, mediaUrl: makeFullUrl(payload.url), mediaType: "file" };
157
+ case MessageType.Location: {
158
+ const lat = payload.latitude ?? payload.lat;
159
+ const lng = payload.longitude ?? payload.lng ?? payload.lon;
160
+ const locText = lat != null && lng != null ? `[位置信息: ${lat},${lng}]` : "[位置信息]";
161
+ return { text: locText };
162
+ }
163
+ case MessageType.Card: {
164
+ const cardName = payload.name ?? "未知";
165
+ const cardUid = payload.uid ?? "";
166
+ const cardText = cardUid ? `[名片: ${cardName} (${cardUid})]` : `[名片: ${cardName}]`;
167
+ return { text: cardText };
168
+ }
74
169
  default:
75
170
  return { text: payload.content ?? payload.url ?? "" };
76
171
  }
77
172
  }
78
173
 
79
174
  /** Extract text-only content for history/quotes (no mediaUrl) */
80
- function resolveContentText(payload: BotMessage["payload"]): string {
81
- return resolveContent(payload).text;
175
+ function resolveContentText(payload: BotMessage["payload"], apiUrl?: string): string {
176
+ return resolveContent(payload, apiUrl).text;
177
+ }
178
+
179
+ const TEXT_FILE_EXTENSIONS = new Set([
180
+ "txt", "html", "htm", "md", "csv", "json", "xml", "yaml", "yml",
181
+ "log", "py", "js", "ts", "go", "java",
182
+ ]);
183
+
184
+ async function resolveFileContent(
185
+ url: string,
186
+ botToken: string,
187
+ maxBytes = 5 * 1024 * 1024,
188
+ ): Promise<string | null> {
189
+ try {
190
+ const ext = url.split(".").pop()?.toLowerCase() ?? "";
191
+ if (!TEXT_FILE_EXTENSIONS.has(ext)) return null;
192
+
193
+ const resp = await fetch(url, {
194
+ headers: { Authorization: `Bearer ${botToken}` },
195
+ signal: AbortSignal.timeout(30_000),
196
+ });
197
+ if (!resp.ok || !resp.body) return null;
198
+
199
+ const contentLength = resp.headers.get("content-length");
200
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) return null;
201
+
202
+ const buffer = await resp.arrayBuffer();
203
+ if (buffer.byteLength > maxBytes) return null;
204
+ return new TextDecoder().decode(buffer);
205
+ } catch {
206
+ return null;
207
+ }
82
208
  }
83
209
 
84
210
  /** Placeholder text for non-text API history messages */
@@ -219,20 +345,46 @@ export async function handleInboundMessage(params: {
219
345
  message.channel_type === ChannelType.Group;
220
346
 
221
347
  // Parse space_id from channel_id (format: s{spaceId}_{peerId})
348
+ // For DM, channel_id is a fake channel: s{spaceId}_{uid1}@s{spaceId}_{uid2}
349
+ // Use LastIndex approach: spaceId is everything between 's' and the last '_' before peerId
222
350
  let spaceId = "";
223
351
  const effectiveChannelId = isGroup ? message.channel_id! : message.from_uid;
224
- const spaceMatch = effectiveChannelId.match(/^s([^_]+)_(.+)$/);
225
- if (spaceMatch) {
226
- spaceId = spaceMatch[1];
352
+ if (effectiveChannelId.startsWith("s")) {
353
+ const lastUnderscore = effectiveChannelId.lastIndexOf("_");
354
+ if (lastUnderscore > 0) {
355
+ spaceId = effectiveChannelId.substring(1, lastUnderscore);
356
+ }
357
+ }
358
+ // Also try to extract spaceId from the WS channel_id (compound DM format)
359
+ if (!spaceId && message.channel_id && message.channel_id.startsWith("s")) {
360
+ // DM compound: s{spaceId}_{uid1}@s{spaceId}_{uid2}
361
+ const atIdx = message.channel_id.indexOf("@");
362
+ const firstPart = atIdx > 0 ? message.channel_id.substring(0, atIdx) : message.channel_id;
363
+ if (firstPart.startsWith("s")) {
364
+ const lastUnderscore = firstPart.lastIndexOf("_");
365
+ if (lastUnderscore > 0) {
366
+ spaceId = firstPart.substring(1, lastUnderscore);
367
+ }
368
+ }
227
369
  }
228
370
 
371
+ // Session ID: include spaceId for Space isolation (same user in different Spaces = different sessions)
229
372
  const sessionId = isGroup
230
373
  ? message.channel_id!
231
- : message.from_uid;
374
+ : spaceId ? `${spaceId}:${message.from_uid}` : message.from_uid;
375
+
376
+ const resolved = resolveContent(message.payload, account.config.apiUrl);
377
+ let rawBody = resolved.text;
378
+ let inboundMediaUrl = resolved.mediaUrl;
379
+ // Inline text file content if possible
380
+ if (resolved.mediaType === "file" && resolved.mediaUrl) {
381
+ const fileContent = await resolveFileContent(resolved.mediaUrl, account.config.botToken ?? "");
382
+ if (fileContent) {
383
+ rawBody = `[文件: ${message.payload.name ?? "未知文件"}]\n\n--- 文件内容 ---\n${fileContent}\n--- 文件结束 ---`;
384
+ inboundMediaUrl = undefined;
385
+ }
386
+ }
232
387
 
233
- const resolved = resolveContent(message.payload);
234
- const rawBody = resolved.text;
235
- const inboundMediaUrl = resolved.mediaUrl;
236
388
  if (!rawBody) {
237
389
  log?.info?.(
238
390
  `dmwork: inbound dropped session=${sessionId} reason=empty-content`,
@@ -245,7 +397,7 @@ export async function handleInboundMessage(params: {
245
397
  const replyData = message.payload?.reply;
246
398
  if (replyData) {
247
399
  const replyPayload = replyData.payload;
248
- const replyContent = replyPayload?.content ?? (replyPayload ? resolveContentText(replyPayload) : "");
400
+ const replyContent = replyPayload?.content ?? (replyPayload ? resolveContentText(replyPayload, account.config.apiUrl) : "");
249
401
  const replyFrom = replyData.from_uid ?? replyData.from_name ?? "unknown";
250
402
  if (replyContent) {
251
403
  quotePrefix = `[Quoted message from ${replyFrom}]: ${replyContent}\n---\n`;
@@ -488,9 +640,55 @@ export async function handleInboundMessage(params: {
488
640
  .then(() => log?.info?.("dmwork: typing sent OK"))
489
641
  .catch((err) => log?.error?.(`dmwork: typing failed: ${String(err)}`));
490
642
 
643
+ const apiUrl = account.config.apiUrl;
644
+ const botToken = account.config.botToken ?? "";
645
+
646
+ // Keep sending typing indicator while AI is processing
647
+ const typingInterval = setInterval(() => {
648
+ sendTyping({ apiUrl, botToken, channelId: replyChannelId, channelType: replyChannelType }).catch(() => {});
649
+ }, 5000);
650
+
651
+ // Streaming state
652
+ let streamNo: string | undefined;
653
+ let streamFailed = false;
654
+
655
+ try {
491
656
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
492
657
  ctx: ctxPayload,
493
658
  cfg: config,
659
+ replyOptions: {
660
+ onPartialReply: async (partial: { text?: string; mediaUrls?: string[] }) => {
661
+ if (streamFailed) return;
662
+ const text = partial.text?.trim();
663
+ if (!text) return;
664
+ try {
665
+ if (!streamNo) {
666
+ // Start stream
667
+ const payloadB64 = Buffer.from(JSON.stringify({ type: 1, content: text })).toString("base64");
668
+ const result = await postJson<{ stream_no: string }>(apiUrl, botToken, "/v1/bot/stream/start", {
669
+ channel_id: replyChannelId,
670
+ channel_type: replyChannelType,
671
+ payload: payloadB64,
672
+ });
673
+ streamNo = result?.stream_no;
674
+ log?.info?.(`dmwork: stream started: ${streamNo}`);
675
+ } else {
676
+ // Continue stream
677
+ await sendMessage({
678
+ apiUrl,
679
+ botToken,
680
+ channelId: replyChannelId,
681
+ channelType: replyChannelType,
682
+ content: text,
683
+ streamNo,
684
+ });
685
+ }
686
+ } catch (err) {
687
+ log?.error?.(`dmwork: stream partial failed, falling back to deliver: ${String(err)}`);
688
+ streamFailed = true;
689
+ }
690
+ },
691
+ },
494
692
  dispatcherOptions: {
495
693
  deliver: async (payload: {
496
694
  text?: string;
@@ -498,14 +696,31 @@ export async function handleInboundMessage(params: {
498
696
  mediaUrl?: string;
499
697
  replyToId?: string | null;
500
698
  }) => {
501
- const contentParts: string[] = [];
502
- if (payload.text) contentParts.push(payload.text);
503
- const mediaUrls = [
504
- ...(payload.mediaUrls ?? []),
505
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
506
- ].filter(Boolean);
507
- if (mediaUrls.length > 0) contentParts.push(...mediaUrls);
508
- const content = contentParts.join("\n").trim();
699
+ // Resolve outbound media URLs
700
+ const outboundMediaUrls = resolveOutboundMediaUrls(payload);
701
+
702
+ // Upload and send each media file
703
+ for (const mediaUrl of outboundMediaUrls) {
704
+ try {
705
+ await uploadAndSendMedia({
706
+ mediaUrl,
707
+ apiUrl: account.config.apiUrl,
708
+ botToken: account.config.botToken ?? "",
709
+ channelId: replyChannelId,
710
+ channelType: replyChannelType,
711
+ log,
712
+ });
713
+ } catch (err) {
714
+ log?.error?.(`dmwork: media send failed for ${mediaUrl}: ${String(err)}`);
715
+ }
716
+ }
717
+
718
+ // If there are no media URLs, fall through to text logic; if there are, only send text if caption exists
719
+ const content = payload.text?.trim() ?? "";
720
+ if (!content && outboundMediaUrls.length > 0) {
721
+ statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
722
+ return;
723
+ }
509
724
  if (!content) return;
510
725
 
511
726
  // Build mentionUids from @mentions in content, using memberMap to resolve displayName -> uid
@@ -613,9 +828,37 @@ export async function handleInboundMessage(params: {
613
828
 
614
829
  statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
615
830
  },
616
- onError: (err, info) => {
831
+ onError: async (err: unknown, info: { kind: string }) => {
832
+ clearInterval(typingInterval);
617
833
  log?.error?.(`dmwork ${info.kind} reply failed: ${String(err)}`);
834
+ try {
835
+ await sendMessage({
836
+ apiUrl,
837
+ botToken,
838
+ channelId: replyChannelId,
839
+ channelType: replyChannelType,
840
+ content: "⚠️ 抱歉,处理您的消息时遇到了问题,请稍后重试。",
841
+ });
842
+ } catch (sendErr) {
843
+ log?.error?.(`dmwork: failed to send error message: ${String(sendErr)}`);
844
+ }
618
845
  },
619
846
  },
620
847
  });
848
+ } finally {
849
+ clearInterval(typingInterval);
850
+ // End stream if one was started (skip if stream failed — deliver handles final message)
851
+ if (streamNo && !streamFailed) {
852
+ try {
853
+ await postJson(apiUrl, botToken, "/v1/bot/stream/end", {
854
+ stream_no: streamNo,
855
+ channel_id: replyChannelId,
856
+ channel_type: replyChannelType,
857
+ });
858
+ log?.info?.(`dmwork: stream ended: ${streamNo}`);
859
+ } catch (err) {
860
+ log?.error?.(`dmwork: stream end failed: ${String(err)}`);
861
+ }
862
+ }
863
+ }
621
864
  }