@openclaw/feishu 2026.3.13 → 2026.5.1-beta.1
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/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +95 -7
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +778 -775
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +63 -1
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +32 -94
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +375 -26
- package/src/media.ts +434 -88
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +218 -312
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +108 -48
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +11 -9
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +220 -60
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +65 -7
- package/src/monitor.webhook-security.test.ts +122 -0
- package/src/monitor.webhook.test-helpers.ts +44 -26
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +616 -37
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +14 -9
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +4 -34
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +660 -29
- package/src/reply-dispatcher.ts +407 -154
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +77 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +399 -86
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
package/src/media.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { Readable } from "stream";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
5
|
+
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
|
6
|
+
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
|
7
|
+
import {
|
|
8
|
+
resolvePreferredOpenClawTmpDir,
|
|
9
|
+
withTempDownloadPath,
|
|
10
|
+
} from "openclaw/plugin-sdk/temp-path";
|
|
11
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
12
|
+
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
13
|
+
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
|
6
14
|
import { createFeishuClient } from "./client.js";
|
|
7
15
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
8
16
|
import { getFeishuRuntime } from "./runtime.js";
|
|
@@ -10,6 +18,24 @@ import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result
|
|
|
10
18
|
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
11
19
|
|
|
12
20
|
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
|
|
21
|
+
const FEISHU_VOICE_FILE_NAME = "voice.ogg";
|
|
22
|
+
const FEISHU_VOICE_SAMPLE_RATE_HZ = 48_000;
|
|
23
|
+
const FEISHU_VOICE_BITRATE = "64k";
|
|
24
|
+
|
|
25
|
+
const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
|
|
26
|
+
".aac",
|
|
27
|
+
".aiff",
|
|
28
|
+
".alac",
|
|
29
|
+
".amr",
|
|
30
|
+
".caf",
|
|
31
|
+
".flac",
|
|
32
|
+
".m4a",
|
|
33
|
+
".mp3",
|
|
34
|
+
".oga",
|
|
35
|
+
".wav",
|
|
36
|
+
".webm",
|
|
37
|
+
".wma",
|
|
38
|
+
]);
|
|
13
39
|
|
|
14
40
|
export type DownloadImageResult = {
|
|
15
41
|
buffer: Buffer;
|
|
@@ -23,10 +49,10 @@ export type DownloadMessageResourceResult = {
|
|
|
23
49
|
};
|
|
24
50
|
|
|
25
51
|
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
|
|
26
|
-
account: ReturnType<typeof
|
|
52
|
+
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
|
27
53
|
client: ReturnType<typeof createFeishuClient>;
|
|
28
54
|
} {
|
|
29
|
-
const account =
|
|
55
|
+
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
30
56
|
if (!account.configured) {
|
|
31
57
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
32
58
|
}
|
|
@@ -40,83 +66,235 @@ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accoun
|
|
|
40
66
|
};
|
|
41
67
|
}
|
|
42
68
|
|
|
69
|
+
type FeishuUploadResponse =
|
|
70
|
+
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
|
|
71
|
+
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
|
|
72
|
+
|
|
73
|
+
type FeishuDownloadResponse =
|
|
74
|
+
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
|
|
75
|
+
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
|
|
76
|
+
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
|
|
77
|
+
|
|
78
|
+
type FeishuHeaderMap = Record<string, string | string[]>;
|
|
79
|
+
type FeishuMessageResourceDownloadType = "image" | "file" | "media";
|
|
80
|
+
|
|
81
|
+
function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined {
|
|
82
|
+
if (!value) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const entries = Object.entries(value);
|
|
86
|
+
if (entries.every(([, entry]) => typeof entry === "string" || Array.isArray(entry))) {
|
|
87
|
+
return Object.fromEntries(entries) as FeishuHeaderMap;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
43
92
|
function extractFeishuUploadKey(
|
|
44
|
-
response:
|
|
93
|
+
response: FeishuUploadResponse,
|
|
45
94
|
params: {
|
|
46
95
|
key: "image_key" | "file_key";
|
|
47
96
|
errorPrefix: string;
|
|
48
97
|
},
|
|
49
98
|
): string {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
99
|
+
if (!response) {
|
|
100
|
+
throw new Error(`${params.errorPrefix}: empty response`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const wrappedResponse = response as {
|
|
104
|
+
image_key?: string;
|
|
105
|
+
file_key?: string;
|
|
106
|
+
code?: number;
|
|
107
|
+
msg?: string;
|
|
108
|
+
data?: Partial<Record<"image_key" | "file_key", string>>;
|
|
109
|
+
};
|
|
110
|
+
if (wrappedResponse.code !== undefined && wrappedResponse.code !== 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${params.errorPrefix}: ${wrappedResponse.msg || `code ${wrappedResponse.code}`}`,
|
|
113
|
+
);
|
|
55
114
|
}
|
|
56
115
|
|
|
57
|
-
const key =
|
|
116
|
+
const key =
|
|
117
|
+
params.key === "image_key"
|
|
118
|
+
? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
|
|
119
|
+
: (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
|
|
58
120
|
if (!key) {
|
|
59
121
|
throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
|
|
60
122
|
}
|
|
61
123
|
return key;
|
|
62
124
|
}
|
|
63
125
|
|
|
126
|
+
function readHeaderValue(
|
|
127
|
+
headers: Record<string, unknown> | undefined,
|
|
128
|
+
name: string,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
if (!headers) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
134
|
+
if (normalizeLowercaseStringOrEmpty(key) !== normalizeLowercaseStringOrEmpty(name)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (typeof value === "string" && value.trim()) {
|
|
138
|
+
return value.trim();
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
const first = value.find((entry) => typeof entry === "string" && entry.trim());
|
|
142
|
+
if (typeof first === "string") {
|
|
143
|
+
return first.trim();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readHttpStatusFromError(error: unknown): number | undefined {
|
|
151
|
+
if (!error || typeof error !== "object") {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const response = (error as { response?: unknown }).response;
|
|
156
|
+
if (response && typeof response === "object") {
|
|
157
|
+
const status = (response as { status?: unknown }).status;
|
|
158
|
+
if (typeof status === "number") {
|
|
159
|
+
return status;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const status = (error as { status?: unknown }).status;
|
|
164
|
+
return typeof status === "number" ? status : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isHttpStatusError(error: unknown, status: number): boolean {
|
|
168
|
+
return readHttpStatusFromError(error) === status;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function containsEastAsianScript(value: string): boolean {
|
|
172
|
+
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function recoverUtf8FileNameFromLatin1Header(value: string): string {
|
|
176
|
+
const recovered = Buffer.from(value, "latin1").toString("utf8");
|
|
177
|
+
if (recovered !== value && !recovered.includes("\uFFFD") && containsEastAsianScript(recovered)) {
|
|
178
|
+
return recovered;
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function decodeDispositionFileName(value: string): string | undefined {
|
|
184
|
+
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
|
185
|
+
if (utf8Match?.[1]) {
|
|
186
|
+
try {
|
|
187
|
+
return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1"));
|
|
188
|
+
} catch {
|
|
189
|
+
return utf8Match[1].trim().replace(/^"(.*)"$/, "$1");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const plainMatch = value.match(/filename="?([^";]+)"?/i);
|
|
194
|
+
const plainFileName = plainMatch?.[1]?.trim();
|
|
195
|
+
return plainFileName ? recoverUtf8FileNameFromLatin1Header(plainFileName) : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
|
|
199
|
+
contentType?: string;
|
|
200
|
+
fileName?: string;
|
|
201
|
+
} {
|
|
202
|
+
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
|
203
|
+
header?: object;
|
|
204
|
+
contentType?: string;
|
|
205
|
+
mime_type?: string;
|
|
206
|
+
data?: {
|
|
207
|
+
contentType?: string;
|
|
208
|
+
mime_type?: string;
|
|
209
|
+
file_name?: string;
|
|
210
|
+
fileName?: string;
|
|
211
|
+
};
|
|
212
|
+
file_name?: string;
|
|
213
|
+
fileName?: string;
|
|
214
|
+
};
|
|
215
|
+
const headers =
|
|
216
|
+
asHeaderMap(responseWithOptionalFields.headers) ??
|
|
217
|
+
asHeaderMap(responseWithOptionalFields.header);
|
|
218
|
+
|
|
219
|
+
const contentType =
|
|
220
|
+
readHeaderValue(headers, "content-type") ??
|
|
221
|
+
responseWithOptionalFields.contentType ??
|
|
222
|
+
responseWithOptionalFields.mime_type ??
|
|
223
|
+
responseWithOptionalFields.data?.contentType ??
|
|
224
|
+
responseWithOptionalFields.data?.mime_type;
|
|
225
|
+
|
|
226
|
+
const disposition = readHeaderValue(headers, "content-disposition");
|
|
227
|
+
const fileName =
|
|
228
|
+
(disposition ? decodeDispositionFileName(disposition) : undefined) ??
|
|
229
|
+
responseWithOptionalFields.file_name ??
|
|
230
|
+
responseWithOptionalFields.fileName ??
|
|
231
|
+
responseWithOptionalFields.data?.file_name ??
|
|
232
|
+
responseWithOptionalFields.data?.fileName;
|
|
233
|
+
|
|
234
|
+
return { contentType, fileName };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readReadableBuffer(stream: Readable): Promise<Buffer> {
|
|
238
|
+
const chunks: Buffer[] = [];
|
|
239
|
+
for await (const chunk of stream) {
|
|
240
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
241
|
+
}
|
|
242
|
+
return Buffer.concat(chunks);
|
|
243
|
+
}
|
|
244
|
+
|
|
64
245
|
async function readFeishuResponseBuffer(params: {
|
|
65
|
-
response:
|
|
246
|
+
response: FeishuDownloadResponse;
|
|
66
247
|
tmpDirPrefix: string;
|
|
67
248
|
errorPrefix: string;
|
|
68
249
|
}): Promise<Buffer> {
|
|
69
250
|
const { response } = params;
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
|
71
|
-
const responseAny = response as any;
|
|
72
|
-
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
|
73
|
-
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
251
|
if (Buffer.isBuffer(response)) {
|
|
77
252
|
return response;
|
|
78
253
|
}
|
|
79
254
|
if (response instanceof ArrayBuffer) {
|
|
80
255
|
return Buffer.from(response);
|
|
81
256
|
}
|
|
82
|
-
|
|
83
|
-
|
|
257
|
+
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
|
258
|
+
code?: number;
|
|
259
|
+
msg?: string;
|
|
260
|
+
data?: Buffer | ArrayBuffer;
|
|
261
|
+
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
|
|
262
|
+
};
|
|
263
|
+
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
|
|
266
|
+
);
|
|
84
267
|
}
|
|
85
|
-
|
|
86
|
-
|
|
268
|
+
|
|
269
|
+
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
|
270
|
+
return responseWithOptionalFields.data;
|
|
87
271
|
}
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
return Buffer.concat(chunks);
|
|
272
|
+
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
|
273
|
+
return Buffer.from(responseWithOptionalFields.data);
|
|
274
|
+
}
|
|
275
|
+
if (typeof response.getReadableStream === "function") {
|
|
276
|
+
return readReadableBuffer(response.getReadableStream());
|
|
95
277
|
}
|
|
96
|
-
if (typeof
|
|
278
|
+
if (typeof response.writeFile === "function") {
|
|
97
279
|
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
|
98
|
-
await
|
|
280
|
+
await response.writeFile(tmpPath);
|
|
99
281
|
return await fs.promises.readFile(tmpPath);
|
|
100
282
|
});
|
|
101
283
|
}
|
|
102
|
-
if (
|
|
284
|
+
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
|
285
|
+
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
|
103
286
|
const chunks: Buffer[] = [];
|
|
104
|
-
for await (const chunk of
|
|
287
|
+
for await (const chunk of asyncIterable) {
|
|
105
288
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
106
289
|
}
|
|
107
290
|
return Buffer.concat(chunks);
|
|
108
291
|
}
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
for await (const chunk of responseAny as Readable) {
|
|
112
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
113
|
-
}
|
|
114
|
-
return Buffer.concat(chunks);
|
|
292
|
+
if (response instanceof Readable) {
|
|
293
|
+
return readReadableBuffer(response);
|
|
115
294
|
}
|
|
116
295
|
|
|
117
|
-
const keys = Object.keys(
|
|
118
|
-
|
|
119
|
-
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${types}]`);
|
|
296
|
+
const keys = Object.keys(response as object);
|
|
297
|
+
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
|
120
298
|
}
|
|
121
299
|
|
|
122
300
|
/**
|
|
@@ -144,7 +322,27 @@ export async function downloadImageFeishu(params: {
|
|
|
144
322
|
tmpDirPrefix: "openclaw-feishu-img-",
|
|
145
323
|
errorPrefix: "Feishu image download failed",
|
|
146
324
|
});
|
|
147
|
-
|
|
325
|
+
const meta = extractFeishuDownloadMetadata(response);
|
|
326
|
+
return { buffer, contentType: meta.contentType };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function downloadMessageResourceWithType(params: {
|
|
330
|
+
client: ReturnType<typeof createFeishuClient>;
|
|
331
|
+
messageId: string;
|
|
332
|
+
fileKey: string;
|
|
333
|
+
type: FeishuMessageResourceDownloadType;
|
|
334
|
+
}): Promise<DownloadMessageResourceResult> {
|
|
335
|
+
const response = await params.client.im.messageResource.get({
|
|
336
|
+
path: { message_id: params.messageId, file_key: params.fileKey },
|
|
337
|
+
params: { type: params.type },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const buffer = await readFeishuResponseBuffer({
|
|
341
|
+
response,
|
|
342
|
+
tmpDirPrefix: "openclaw-feishu-resource-",
|
|
343
|
+
errorPrefix: "Feishu message resource download failed",
|
|
344
|
+
});
|
|
345
|
+
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
|
148
346
|
}
|
|
149
347
|
|
|
150
348
|
/**
|
|
@@ -165,17 +363,28 @@ export async function downloadMessageResourceFeishu(params: {
|
|
|
165
363
|
}
|
|
166
364
|
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
|
167
365
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
366
|
+
try {
|
|
367
|
+
return await downloadMessageResourceWithType({
|
|
368
|
+
client,
|
|
369
|
+
messageId,
|
|
370
|
+
fileKey: normalizedFileKey,
|
|
371
|
+
type,
|
|
372
|
+
});
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
return await downloadMessageResourceWithType({
|
|
379
|
+
client,
|
|
380
|
+
messageId,
|
|
381
|
+
fileKey: normalizedFileKey,
|
|
382
|
+
type: "media",
|
|
383
|
+
});
|
|
384
|
+
} catch {
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
179
388
|
}
|
|
180
389
|
|
|
181
390
|
export type UploadImageResult = {
|
|
@@ -212,8 +421,7 @@ export async function uploadImageFeishu(params: {
|
|
|
212
421
|
const response = await client.im.image.create({
|
|
213
422
|
data: {
|
|
214
423
|
image_type: imageType,
|
|
215
|
-
|
|
216
|
-
image: imageData as any,
|
|
424
|
+
image: imageData,
|
|
217
425
|
},
|
|
218
426
|
});
|
|
219
427
|
|
|
@@ -236,7 +444,7 @@ export async function uploadImageFeishu(params: {
|
|
|
236
444
|
* in chat (regression in v2026.3.2).
|
|
237
445
|
*/
|
|
238
446
|
export function sanitizeFileNameForUpload(fileName: string): string {
|
|
239
|
-
return fileName.replace(/[\
|
|
447
|
+
return fileName.replace(/[\p{Cc}"\\]/gu, "_");
|
|
240
448
|
}
|
|
241
449
|
|
|
242
450
|
/**
|
|
@@ -265,8 +473,7 @@ export async function uploadFileFeishu(params: {
|
|
|
265
473
|
data: {
|
|
266
474
|
file_type: fileType,
|
|
267
475
|
file_name: safeFileName,
|
|
268
|
-
|
|
269
|
-
file: fileData as any,
|
|
476
|
+
file: fileData,
|
|
270
477
|
...(duration !== undefined && { duration }),
|
|
271
478
|
},
|
|
272
479
|
});
|
|
@@ -376,7 +583,7 @@ export async function sendFileFeishu(params: {
|
|
|
376
583
|
export function detectFileType(
|
|
377
584
|
fileName: string,
|
|
378
585
|
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
|
379
|
-
const ext = path.extname(fileName)
|
|
586
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
|
|
380
587
|
switch (ext) {
|
|
381
588
|
case ".opus":
|
|
382
589
|
case ".ogg":
|
|
@@ -401,6 +608,136 @@ export function detectFileType(
|
|
|
401
608
|
}
|
|
402
609
|
}
|
|
403
610
|
|
|
611
|
+
function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): {
|
|
612
|
+
fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
|
|
613
|
+
msgType: "image" | "file" | "audio" | "media";
|
|
614
|
+
} {
|
|
615
|
+
const { fileName, contentType } = params;
|
|
616
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
|
|
617
|
+
const mimeKind = mediaKindFromMime(contentType);
|
|
618
|
+
|
|
619
|
+
const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
|
|
620
|
+
ext,
|
|
621
|
+
);
|
|
622
|
+
if (isImageExt || mimeKind === "image") {
|
|
623
|
+
return { msgType: "image" };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (
|
|
627
|
+
ext === ".opus" ||
|
|
628
|
+
ext === ".ogg" ||
|
|
629
|
+
contentType === "audio/ogg" ||
|
|
630
|
+
contentType === "audio/opus"
|
|
631
|
+
) {
|
|
632
|
+
return { fileType: "opus", msgType: "audio" };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (
|
|
636
|
+
[".mp4", ".mov", ".avi"].includes(ext) ||
|
|
637
|
+
contentType === "video/mp4" ||
|
|
638
|
+
contentType === "video/quicktime" ||
|
|
639
|
+
contentType === "video/x-msvideo"
|
|
640
|
+
) {
|
|
641
|
+
return { fileType: "mp4", msgType: "media" };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const fileType = detectFileType(fileName);
|
|
645
|
+
return {
|
|
646
|
+
fileType,
|
|
647
|
+
msgType:
|
|
648
|
+
fileType === "stream"
|
|
649
|
+
? "file"
|
|
650
|
+
: fileType === "opus"
|
|
651
|
+
? "audio"
|
|
652
|
+
: fileType === "mp4"
|
|
653
|
+
? "media"
|
|
654
|
+
: "file",
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isFeishuNativeVoiceAudio(params: { fileName: string; contentType?: string }): boolean {
|
|
659
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
|
|
660
|
+
const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
|
|
661
|
+
return (
|
|
662
|
+
ext === ".opus" || ext === ".ogg" || contentType === "audio/ogg" || contentType === "audio/opus"
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function isLikelyTranscodableAudio(params: { fileName: string; contentType?: string }): boolean {
|
|
667
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
|
|
668
|
+
const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
|
|
669
|
+
return FEISHU_TRANSCODABLE_AUDIO_EXTS.has(ext) || mediaKindFromMime(contentType) === "audio";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function transcodeToFeishuVoiceOpus(params: {
|
|
673
|
+
buffer: Buffer;
|
|
674
|
+
fileName: string;
|
|
675
|
+
contentType?: string;
|
|
676
|
+
}): Promise<{ buffer: Buffer; fileName: string; contentType: string }> {
|
|
677
|
+
const tempRoot = resolvePreferredOpenClawTmpDir();
|
|
678
|
+
await fs.promises.mkdir(tempRoot, { recursive: true, mode: 0o700 });
|
|
679
|
+
const tempDir = await fs.promises.mkdtemp(path.join(tempRoot, "feishu-voice-"));
|
|
680
|
+
try {
|
|
681
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
|
|
682
|
+
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
|
683
|
+
const inputPath = path.join(tempDir, `input${inputExt}`);
|
|
684
|
+
const outputPath = path.join(tempDir, FEISHU_VOICE_FILE_NAME);
|
|
685
|
+
await fs.promises.writeFile(inputPath, params.buffer, { mode: 0o600 });
|
|
686
|
+
await runFfmpeg([
|
|
687
|
+
"-hide_banner",
|
|
688
|
+
"-loglevel",
|
|
689
|
+
"error",
|
|
690
|
+
"-y",
|
|
691
|
+
"-i",
|
|
692
|
+
inputPath,
|
|
693
|
+
"-vn",
|
|
694
|
+
"-sn",
|
|
695
|
+
"-dn",
|
|
696
|
+
"-t",
|
|
697
|
+
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
|
698
|
+
"-ar",
|
|
699
|
+
String(FEISHU_VOICE_SAMPLE_RATE_HZ),
|
|
700
|
+
"-ac",
|
|
701
|
+
"1",
|
|
702
|
+
"-c:a",
|
|
703
|
+
"libopus",
|
|
704
|
+
"-b:a",
|
|
705
|
+
FEISHU_VOICE_BITRATE,
|
|
706
|
+
outputPath,
|
|
707
|
+
]);
|
|
708
|
+
return {
|
|
709
|
+
buffer: await fs.promises.readFile(outputPath),
|
|
710
|
+
fileName: FEISHU_VOICE_FILE_NAME,
|
|
711
|
+
contentType: "audio/ogg",
|
|
712
|
+
};
|
|
713
|
+
} finally {
|
|
714
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function prepareFeishuVoiceMedia(params: {
|
|
719
|
+
buffer: Buffer;
|
|
720
|
+
fileName: string;
|
|
721
|
+
contentType?: string;
|
|
722
|
+
audioAsVoice?: boolean;
|
|
723
|
+
}): Promise<{ buffer: Buffer; fileName: string; contentType?: string }> {
|
|
724
|
+
if (isFeishuNativeVoiceAudio(params)) {
|
|
725
|
+
return params;
|
|
726
|
+
}
|
|
727
|
+
if (params.audioAsVoice !== true || !isLikelyTranscodableAudio(params)) {
|
|
728
|
+
return params;
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
return await transcodeToFeishuVoiceOpus(params);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
console.warn(
|
|
734
|
+
`[feishu] audioAsVoice transcode failed; sending ${params.fileName} as a file attachment:`,
|
|
735
|
+
err,
|
|
736
|
+
);
|
|
737
|
+
return params;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
404
741
|
/**
|
|
405
742
|
* Upload and send media (image or file) from URL, local path, or buffer.
|
|
406
743
|
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
|
|
@@ -417,6 +754,8 @@ export async function sendMediaFeishu(params: {
|
|
|
417
754
|
accountId?: string;
|
|
418
755
|
/** Allowed roots for local path reads; required for local filePath to work. */
|
|
419
756
|
mediaLocalRoots?: readonly string[];
|
|
757
|
+
/** When true, transcode compatible audio to Feishu native Ogg/Opus voice bubbles. */
|
|
758
|
+
audioAsVoice?: boolean;
|
|
420
759
|
}): Promise<SendMediaResult> {
|
|
421
760
|
const {
|
|
422
761
|
cfg,
|
|
@@ -428,8 +767,9 @@ export async function sendMediaFeishu(params: {
|
|
|
428
767
|
replyInThread,
|
|
429
768
|
accountId,
|
|
430
769
|
mediaLocalRoots,
|
|
770
|
+
audioAsVoice,
|
|
431
771
|
} = params;
|
|
432
|
-
const account =
|
|
772
|
+
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
|
|
433
773
|
if (!account.configured) {
|
|
434
774
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
435
775
|
}
|
|
@@ -437,6 +777,7 @@ export async function sendMediaFeishu(params: {
|
|
|
437
777
|
|
|
438
778
|
let buffer: Buffer;
|
|
439
779
|
let name: string;
|
|
780
|
+
let contentType: string | undefined;
|
|
440
781
|
|
|
441
782
|
if (mediaBuffer) {
|
|
442
783
|
buffer = mediaBuffer;
|
|
@@ -449,36 +790,41 @@ export async function sendMediaFeishu(params: {
|
|
|
449
790
|
});
|
|
450
791
|
buffer = loaded.buffer;
|
|
451
792
|
name = fileName ?? loaded.fileName ?? "file";
|
|
793
|
+
contentType = loaded.contentType;
|
|
452
794
|
} else {
|
|
453
795
|
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
|
454
796
|
}
|
|
455
797
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
798
|
+
const prepared = await prepareFeishuVoiceMedia({
|
|
799
|
+
buffer,
|
|
800
|
+
fileName: name,
|
|
801
|
+
contentType,
|
|
802
|
+
audioAsVoice,
|
|
803
|
+
});
|
|
804
|
+
buffer = prepared.buffer;
|
|
805
|
+
name = prepared.fileName;
|
|
806
|
+
contentType = prepared.contentType;
|
|
807
|
+
|
|
808
|
+
const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
|
|
459
809
|
|
|
460
|
-
if (
|
|
810
|
+
if (routing.msgType === "image") {
|
|
461
811
|
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
|
462
812
|
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
|
|
463
|
-
} else {
|
|
464
|
-
const fileType = detectFileType(name);
|
|
465
|
-
const { fileKey } = await uploadFileFeishu({
|
|
466
|
-
cfg,
|
|
467
|
-
file: buffer,
|
|
468
|
-
fileName: name,
|
|
469
|
-
fileType,
|
|
470
|
-
accountId,
|
|
471
|
-
});
|
|
472
|
-
// Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
|
|
473
|
-
const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
|
|
474
|
-
return sendFileFeishu({
|
|
475
|
-
cfg,
|
|
476
|
-
to,
|
|
477
|
-
fileKey,
|
|
478
|
-
msgType,
|
|
479
|
-
replyToMessageId,
|
|
480
|
-
replyInThread,
|
|
481
|
-
accountId,
|
|
482
|
-
});
|
|
483
813
|
}
|
|
814
|
+
const { fileKey } = await uploadFileFeishu({
|
|
815
|
+
cfg,
|
|
816
|
+
file: buffer,
|
|
817
|
+
fileName: name,
|
|
818
|
+
fileType: routing.fileType ?? "stream",
|
|
819
|
+
accountId,
|
|
820
|
+
});
|
|
821
|
+
return sendFileFeishu({
|
|
822
|
+
cfg,
|
|
823
|
+
to,
|
|
824
|
+
fileKey,
|
|
825
|
+
msgType: routing.msgType,
|
|
826
|
+
replyToMessageId,
|
|
827
|
+
replyInThread,
|
|
828
|
+
accountId,
|
|
829
|
+
});
|
|
484
830
|
}
|