openclaw-channel-dmwork 0.2.32 → 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.32",
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 */
@@ -247,9 +373,18 @@ export async function handleInboundMessage(params: {
247
373
  ? message.channel_id!
248
374
  : spaceId ? `${spaceId}:${message.from_uid}` : message.from_uid;
249
375
 
250
- const resolved = resolveContent(message.payload);
251
- const rawBody = resolved.text;
252
- const inboundMediaUrl = resolved.mediaUrl;
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
+ }
387
+
253
388
  if (!rawBody) {
254
389
  log?.info?.(
255
390
  `dmwork: inbound dropped session=${sessionId} reason=empty-content`,
@@ -262,7 +397,7 @@ export async function handleInboundMessage(params: {
262
397
  const replyData = message.payload?.reply;
263
398
  if (replyData) {
264
399
  const replyPayload = replyData.payload;
265
- const replyContent = replyPayload?.content ?? (replyPayload ? resolveContentText(replyPayload) : "");
400
+ const replyContent = replyPayload?.content ?? (replyPayload ? resolveContentText(replyPayload, account.config.apiUrl) : "");
266
401
  const replyFrom = replyData.from_uid ?? replyData.from_name ?? "unknown";
267
402
  if (replyContent) {
268
403
  quotePrefix = `[Quoted message from ${replyFrom}]: ${replyContent}\n---\n`;
@@ -505,9 +640,55 @@ export async function handleInboundMessage(params: {
505
640
  .then(() => log?.info?.("dmwork: typing sent OK"))
506
641
  .catch((err) => log?.error?.(`dmwork: typing failed: ${String(err)}`));
507
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 {
508
656
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
509
657
  ctx: ctxPayload,
510
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
+ },
511
692
  dispatcherOptions: {
512
693
  deliver: async (payload: {
513
694
  text?: string;
@@ -515,14 +696,31 @@ export async function handleInboundMessage(params: {
515
696
  mediaUrl?: string;
516
697
  replyToId?: string | null;
517
698
  }) => {
518
- const contentParts: string[] = [];
519
- if (payload.text) contentParts.push(payload.text);
520
- const mediaUrls = [
521
- ...(payload.mediaUrls ?? []),
522
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
523
- ].filter(Boolean);
524
- if (mediaUrls.length > 0) contentParts.push(...mediaUrls);
525
- 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
+ }
526
724
  if (!content) return;
527
725
 
528
726
  // Build mentionUids from @mentions in content, using memberMap to resolve displayName -> uid
@@ -630,9 +828,37 @@ export async function handleInboundMessage(params: {
630
828
 
631
829
  statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
632
830
  },
633
- onError: (err, info) => {
831
+ onError: async (err: unknown, info: { kind: string }) => {
832
+ clearInterval(typingInterval);
634
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
+ }
635
845
  },
636
846
  },
637
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
+ }
638
864
  }