Skip to content

Commit 6ea3a47

Browse files
committed
fix(feishu): harden routing, parsing, and media delivery
1 parent cdbed3c commit 6ea3a47

12 files changed

Lines changed: 379 additions & 64 deletions

File tree

docs/channels/feishu.md

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ openclaw pairing list feishu
478478
{
479479
channels: {
480480
feishu: {
481+
defaultAccount: "main",
481482
accounts: {
482483
main: {
483484
appId: "cli_xxx",
@@ -496,6 +497,8 @@ openclaw pairing list feishu
496497
}
497498
```
498499

500+
`defaultAccount` controls which Feishu account is used when outbound APIs do not specify an `accountId` explicitly.
501+
499502
### Message limits
500503

501504
- `textChunkLimit`: outbound text chunk size (default: 2000 chars)
@@ -581,28 +584,29 @@ Full configuration: [Gateway configuration](/gateway/configuration)
581584

582585
Key options:
583586

584-
| Setting | Description | Default |
585-
| ------------------------------------------------- | ------------------------------- | ---------------- |
586-
| `channels.feishu.enabled` | Enable/disable channel | `true` |
587-
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
588-
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
589-
| `channels.feishu.verificationToken` | Required for webhook mode | - |
590-
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
591-
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
592-
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
593-
| `channels.feishu.accounts.<id>.appId` | App ID | - |
594-
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
595-
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
596-
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
597-
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
598-
| `channels.feishu.groupPolicy` | Group policy | `open` |
599-
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
600-
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
601-
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
602-
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
603-
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
604-
| `channels.feishu.streaming` | Enable streaming card output | `true` |
605-
| `channels.feishu.blockStreaming` | Enable block streaming | `true` |
587+
| Setting | Description | Default |
588+
| ------------------------------------------------- | --------------------------------------- | ---------------- |
589+
| `channels.feishu.enabled` | Enable/disable channel | `true` |
590+
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
591+
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
592+
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
593+
| `channels.feishu.verificationToken` | Required for webhook mode | - |
594+
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
595+
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
596+
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
597+
| `channels.feishu.accounts.<id>.appId` | App ID | - |
598+
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
599+
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
600+
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
601+
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
602+
| `channels.feishu.groupPolicy` | Group policy | `open` |
603+
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
604+
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
605+
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
606+
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
607+
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
608+
| `channels.feishu.streaming` | Enable streaming card output | `true` |
609+
| `channels.feishu.blockStreaming` | Enable block streaming | `true` |
606610

607611
---
608612

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js";
3+
4+
describe("resolveDefaultFeishuAccountId", () => {
5+
it("prefers channels.feishu.defaultAccount when configured", () => {
6+
const cfg = {
7+
channels: {
8+
feishu: {
9+
defaultAccount: "router-d",
10+
accounts: {
11+
default: { appId: "cli_default", appSecret: "secret_default" },
12+
"router-d": { appId: "cli_router", appSecret: "secret_router" },
13+
},
14+
},
15+
},
16+
};
17+
18+
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
19+
});
20+
21+
it("normalizes configured defaultAccount before lookup", () => {
22+
const cfg = {
23+
channels: {
24+
feishu: {
25+
defaultAccount: "Router D",
26+
accounts: {
27+
"router-d": { appId: "cli_router", appSecret: "secret_router" },
28+
},
29+
},
30+
},
31+
};
32+
33+
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
34+
});
35+
36+
it("falls back to literal default account id when preferred is missing", () => {
37+
const cfg = {
38+
channels: {
39+
feishu: {
40+
defaultAccount: "missing",
41+
accounts: {
42+
default: { appId: "cli_default", appSecret: "secret_default" },
43+
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
44+
},
45+
},
46+
},
47+
};
48+
49+
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
50+
});
51+
});
52+
53+
describe("resolveFeishuAccount", () => {
54+
it("uses configured default account when accountId is omitted", () => {
55+
const cfg = {
56+
channels: {
57+
feishu: {
58+
defaultAccount: "router-d",
59+
accounts: {
60+
default: { enabled: true },
61+
"router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true },
62+
},
63+
},
64+
},
65+
};
66+
67+
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
68+
expect(account.accountId).toBe("router-d");
69+
expect(account.configured).toBe(true);
70+
expect(account.appId).toBe("cli_router");
71+
});
72+
73+
it("keeps explicit accountId selection", () => {
74+
const cfg = {
75+
channels: {
76+
feishu: {
77+
defaultAccount: "router-d",
78+
accounts: {
79+
default: { appId: "cli_default", appSecret: "secret_default" },
80+
"router-d": { appId: "cli_router", appSecret: "secret_router" },
81+
},
82+
},
83+
},
84+
};
85+
86+
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
87+
expect(account.accountId).toBe("default");
88+
expect(account.appId).toBe("cli_default");
89+
});
90+
});

extensions/feishu/src/accounts.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
3535
* Resolve the default account ID.
3636
*/
3737
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
38+
const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
39+
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
3840
const ids = listFeishuAccountIds(cfg);
41+
if (preferred && ids.includes(preferred)) {
42+
return preferred;
43+
}
3944
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
4045
return DEFAULT_ACCOUNT_ID;
4146
}
@@ -64,7 +69,7 @@ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): Feish
6469
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
6570

6671
// Extract base config (exclude accounts field to avoid recursion)
67-
const { accounts: _ignored, ...base } = feishuCfg ?? {};
72+
const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...base } = feishuCfg ?? {};
6873

6974
// Get account-specific overrides
7075
const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -104,7 +109,11 @@ export function resolveFeishuAccount(params: {
104109
cfg: ClawdbotConfig;
105110
accountId?: string | null;
106111
}): ResolvedFeishuAccount {
107-
const accountId = normalizeAccountId(params.accountId);
112+
const hasExplicitAccountId =
113+
typeof params.accountId === "string" && params.accountId.trim() !== "";
114+
const accountId = hasExplicitAccountId
115+
? normalizeAccountId(params.accountId)
116+
: resolveDefaultFeishuAccountId(params.cfg);
108117
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
109118

110119
// Base enabled state (top-level)

extensions/feishu/src/bot.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,10 +1043,10 @@ export async function handleFeishuMessage(params: {
10431043
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
10441044
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
10451045

1046-
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
1047-
sessionKey: route.sessionKey,
1048-
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
1049-
});
1046+
// Do not enqueue inbound user previews as system events.
1047+
// System events are prepended to future prompts and can be misread as
1048+
// authoritative transcript turns.
1049+
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
10501050

10511051
// Resolve media from message
10521052
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default

extensions/feishu/src/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
7979
additionalProperties: false,
8080
properties: {
8181
enabled: { type: "boolean" },
82+
defaultAccount: { type: "string" },
8283
appId: { type: "string" },
8384
appSecret: { type: "string" },
8485
encryptKey: { type: "string" },

extensions/feishu/src/config-schema.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,32 @@ describe("FeishuConfigSchema optimization flags", () => {
138138
expect(result.accounts?.main?.resolveSenderNames).toBe(false);
139139
});
140140
});
141+
142+
describe("FeishuConfigSchema defaultAccount", () => {
143+
it("accepts defaultAccount when it matches an account key", () => {
144+
const result = FeishuConfigSchema.safeParse({
145+
defaultAccount: "router-d",
146+
accounts: {
147+
"router-d": { appId: "cli_router", appSecret: "secret_router" },
148+
},
149+
});
150+
151+
expect(result.success).toBe(true);
152+
});
153+
154+
it("rejects defaultAccount when it does not match an account key", () => {
155+
const result = FeishuConfigSchema.safeParse({
156+
defaultAccount: "router-d",
157+
accounts: {
158+
backup: { appId: "cli_backup", appSecret: "secret_backup" },
159+
},
160+
});
161+
162+
expect(result.success).toBe(false);
163+
if (!result.success) {
164+
expect(result.error.issues.some((issue) => issue.path.join(".") === "defaultAccount")).toBe(
165+
true,
166+
);
167+
}
168+
});
169+
});

extensions/feishu/src/config-schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
12
import { z } from "zod";
23
export { z };
34

@@ -191,6 +192,7 @@ export const FeishuAccountConfigSchema = z
191192
export const FeishuConfigSchema = z
192193
.object({
193194
enabled: z.boolean().optional(),
195+
defaultAccount: z.string().optional(),
194196
// Top-level credentials (backward compatible for single-account mode)
195197
appId: z.string().optional(),
196198
appSecret: z.string().optional(),
@@ -216,6 +218,18 @@ export const FeishuConfigSchema = z
216218
})
217219
.strict()
218220
.superRefine((value, ctx) => {
221+
const defaultAccount = value.defaultAccount?.trim();
222+
if (defaultAccount && value.accounts && Object.keys(value.accounts).length > 0) {
223+
const normalizedDefaultAccount = normalizeAccountId(defaultAccount);
224+
if (!Object.prototype.hasOwnProperty.call(value.accounts, normalizedDefaultAccount)) {
225+
ctx.addIssue({
226+
code: z.ZodIssueCode.custom,
227+
path: ["defaultAccount"],
228+
message: `channels.feishu.defaultAccount="${defaultAccount}" does not match a configured account key`,
229+
});
230+
}
231+
}
232+
219233
const defaultConnectionMode = value.connectionMode ?? "websocket";
220234
const defaultVerificationToken = value.verificationToken?.trim();
221235
if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {

extensions/feishu/src/media.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe("sendMediaFeishu msg_type routing", () => {
108108
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
109109
});
110110

111-
it("uses msg_type=media for mp4", async () => {
111+
it("uses msg_type=file for mp4", async () => {
112112
await sendMediaFeishu({
113113
cfg: {} as any,
114114
to: "user:ou_target",
@@ -124,7 +124,7 @@ describe("sendMediaFeishu msg_type routing", () => {
124124

125125
expect(messageCreateMock).toHaveBeenCalledWith(
126126
expect.objectContaining({
127-
data: expect.objectContaining({ msg_type: "media" }),
127+
data: expect.objectContaining({ msg_type: "file" }),
128128
}),
129129
);
130130
});
@@ -171,7 +171,7 @@ describe("sendMediaFeishu msg_type routing", () => {
171171
);
172172
});
173173

174-
it("uses msg_type=media when replying with mp4", async () => {
174+
it("uses msg_type=file when replying with mp4", async () => {
175175
await sendMediaFeishu({
176176
cfg: {} as any,
177177
to: "user:ou_target",
@@ -183,7 +183,7 @@ describe("sendMediaFeishu msg_type routing", () => {
183183
expect(messageReplyMock).toHaveBeenCalledWith(
184184
expect.objectContaining({
185185
path: { message_id: "om_parent" },
186-
data: expect.objectContaining({ msg_type: "media" }),
186+
data: expect.objectContaining({ msg_type: "file" }),
187187
}),
188188
);
189189

@@ -203,7 +203,7 @@ describe("sendMediaFeishu msg_type routing", () => {
203203
expect(messageReplyMock).toHaveBeenCalledWith(
204204
expect.objectContaining({
205205
path: { message_id: "om_parent" },
206-
data: expect.objectContaining({ msg_type: "media", reply_in_thread: true }),
206+
data: expect.objectContaining({ msg_type: "file", reply_in_thread: true }),
207207
}),
208208
);
209209
});

extensions/feishu/src/media.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,8 @@ export async function sendFileFeishu(params: {
308308
cfg: ClawdbotConfig;
309309
to: string;
310310
fileKey: string;
311-
/** Use "media" for video, "audio" for audio, "file" for documents */
312-
msgType?: "file" | "media" | "audio";
311+
/** Use "audio" for audio files, "file" for documents and video */
312+
msgType?: "file" | "audio";
313313
replyToMessageId?: string;
314314
replyInThread?: boolean;
315315
accountId?: string;
@@ -447,8 +447,8 @@ export async function sendMediaFeishu(params: {
447447
fileType,
448448
accountId,
449449
});
450-
// Feishu API: opus -> "audio", mp4 -> "media", everything else -> "file"
451-
const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
450+
// Feishu API: opus -> "audio", everything else (including video) -> "file"
451+
const msgType = fileType === "opus" ? "audio" : "file";
452452
return sendFileFeishu({
453453
cfg,
454454
to,

extensions/feishu/src/monitor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
199199

200200
/**
201201
* Register common event handlers on an EventDispatcher.
202-
* When fireAndForget is true (webhook mode), message handling is not awaited
203-
* to avoid blocking the HTTP response (Lark requires <3s response).
202+
* When fireAndForget is true, message handling is not awaited to avoid blocking
203+
* event processing (Lark webhooks require <3s response).
204204
*/
205205
function registerEventHandlers(
206206
eventDispatcher: Lark.EventDispatcher,
@@ -361,7 +361,7 @@ async function monitorSingleAccount(params: MonitorAccountParams): Promise<void>
361361
accountId,
362362
runtime,
363363
chatHistories,
364-
fireAndForget: connectionMode === "webhook",
364+
fireAndForget: true,
365365
});
366366

367367
if (connectionMode === "webhook") {

0 commit comments

Comments
 (0)