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 +6 -1
- package/src/api-fetch.ts +6 -1
- package/src/channel.ts +39 -0
- package/src/inbound.ts +252 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-channel-dmwork",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
}
|