Skip to content

Commit 14312ff

Browse files
committed
fix(feishu): avoid duplicate voice reply text
1 parent 3c51692 commit 14312ff

7 files changed

Lines changed: 317 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121

2222
- Agents/sessions: keep delayed `sessions_send` A2A replies alive after soft wait-window timeouts, while preserving terminal run timeouts and avoiding stale target replies in requester sessions. Fixes #76443. Thanks @ryswork1993 and @vincentkoc.
2323
- Config/doctor: cap `.clobbered.*` forensic snapshots per config path and serialize snapshot writes so repeated `doctor --fix` recovery loops cannot flood the config directory. Fixes #76454; carries forward #65649. Thanks @JUSTICEESSIELP, @rsnow, and @vincentkoc.
24+
- Feishu: suppress duplicate text when replies send native voice media while preserving captions for ordinary audio files and falling back to text plus attachment links when voice uploads fail.
2425
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana.
2526
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
2627
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.

extensions/feishu/src/media.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
5555
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
5656
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
5757
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
58+
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
5859

5960
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
6061
expect(pathValue).not.toContain(key);
@@ -92,6 +93,7 @@ describe("sendMediaFeishu msg_type routing", () => {
9293
downloadMessageResourceFeishu,
9394
sanitizeFileNameForUpload,
9495
sendMediaFeishu,
96+
shouldSuppressFeishuTextForVoiceMedia,
9597
} = await import("./media.js"));
9698
});
9799

@@ -155,6 +157,25 @@ describe("sendMediaFeishu msg_type routing", () => {
155157
});
156158
});
157159

160+
it("suppresses reply text only for voice-intent or native voice media", () => {
161+
expect(
162+
shouldSuppressFeishuTextForVoiceMedia({
163+
mediaUrl: "https://example.com/reply.mp3",
164+
audioAsVoice: true,
165+
}),
166+
).toBe(true);
167+
expect(
168+
shouldSuppressFeishuTextForVoiceMedia({
169+
mediaUrl: "https://example.com/reply.ogg?download=1",
170+
}),
171+
).toBe(true);
172+
expect(
173+
shouldSuppressFeishuTextForVoiceMedia({
174+
mediaUrl: "https://example.com/song.mp3",
175+
}),
176+
).toBe(false);
177+
});
178+
158179
it("uses msg_type=media for mp4 video", async () => {
159180
await sendMediaFeishu({
160181
cfg: emptyConfig,

extensions/feishu/src/media.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,41 @@ function isFeishuNativeVoiceAudio(params: { fileName: string; contentType?: stri
694694
);
695695
}
696696

697+
function normalizeMediaNameForExtension(raw: string): string {
698+
try {
699+
return new URL(raw).pathname;
700+
} catch {
701+
return raw.split(/[?#]/, 1)[0] ?? raw;
702+
}
703+
}
704+
705+
export function shouldSuppressFeishuTextForVoiceMedia(params: {
706+
mediaUrl?: string;
707+
fileName?: string;
708+
contentType?: string;
709+
audioAsVoice?: boolean;
710+
}): boolean {
711+
if (params.audioAsVoice === true) {
712+
return true;
713+
}
714+
if (
715+
params.fileName &&
716+
isFeishuNativeVoiceAudio({
717+
fileName: params.fileName,
718+
contentType: params.contentType,
719+
})
720+
) {
721+
return true;
722+
}
723+
if (!params.mediaUrl) {
724+
return false;
725+
}
726+
return isFeishuNativeVoiceAudio({
727+
fileName: normalizeMediaNameForExtension(params.mediaUrl),
728+
contentType: params.contentType,
729+
});
730+
}
731+
697732
function isLikelyTranscodableAudio(params: { fileName: string; contentType?: string }): boolean {
698733
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
699734
const contentType = normalizeLowercaseStringOrEmpty(params.contentType);

extensions/feishu/src/outbound.test.ts

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
1212
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
1313
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
1414
const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false));
15+
const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
16+
() => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
17+
params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
18+
);
1519

1620
vi.mock("./media.js", () => ({
1721
sendMediaFeishu: sendMediaFeishuMock,
22+
shouldSuppressFeishuTextForVoiceMedia: shouldSuppressFeishuTextForVoiceMediaMock,
1823
}));
1924

2025
vi.mock("./send.js", () => ({
@@ -406,13 +411,13 @@ describe("feishuOutbound.sendPayload native cards", () => {
406411
await feishuOutbound.sendPayload?.({
407412
cfg: emptyConfig,
408413
to: "chat_1",
409-
text: "Choose <at id=\"ou_1\">",
414+
text: 'Choose <at id="ou_1">',
410415
accountId: "main",
411416
payload: {
412-
text: "Choose <at id=\"ou_1\">",
417+
text: 'Choose <at id="ou_1">',
413418
presentation: {
414419
blocks: [
415-
{ type: "context", text: "</font><at id=\"ou_2\">Injected</at>" },
420+
{ type: "context", text: '</font><at id="ou_2">Injected</at>' },
416421
{
417422
type: "buttons",
418423
buttons: [
@@ -428,10 +433,11 @@ describe("feishuOutbound.sendPayload native cards", () => {
428433
const card = sendCardFeishuMock.mock.calls[0][0].card;
429434
expect(card.body.elements).toEqual(
430435
expect.arrayContaining([
431-
{ tag: "markdown", content: "Choose &lt;at id=\"ou_1\"&gt;" },
436+
{ tag: "markdown", content: 'Choose &lt;at id="ou_1"&gt;' },
432437
{
433438
tag: "markdown",
434-
content: "<font color='grey'>&lt;/font&gt;&lt;at id=\"ou_2\"&gt;Injected&lt;/at&gt;</font>",
439+
content:
440+
"<font color='grey'>&lt;/font&gt;&lt;at id=\"ou_2\"&gt;Injected&lt;/at&gt;</font>",
435441
},
436442
{
437443
tag: "action",
@@ -466,7 +472,7 @@ describe("feishuOutbound.sendPayload native cards", () => {
466472
body: {
467473
elements: [
468474
{ tag: "img", img_key: "image-secret" },
469-
{ tag: "markdown", content: "<at id=\"ou_1\">ping</at>" },
475+
{ tag: "markdown", content: '<at id="ou_1">ping</at>' },
470476
{
471477
tag: "action",
472478
actions: [
@@ -493,7 +499,7 @@ describe("feishuOutbound.sendPayload native cards", () => {
493499
const card = sendCardFeishuMock.mock.calls[0][0].card;
494500
expect(card.header.template).toBe("blue");
495501
expect(card.body.elements).toEqual([
496-
{ tag: "markdown", content: "&lt;at id=\"ou_1\"&gt;ping&lt;/at&gt;" },
502+
{ tag: "markdown", content: '&lt;at id="ou_1"&gt;ping&lt;/at&gt;' },
497503
{
498504
tag: "action",
499505
actions: [
@@ -855,6 +861,83 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
855861
);
856862
});
857863

864+
it("suppresses duplicate text when sending voice media", async () => {
865+
await feishuOutbound.sendMedia?.({
866+
cfg: emptyConfig,
867+
to: "chat_1",
868+
text: "spoken reply",
869+
mediaUrl: "https://example.com/reply.mp3",
870+
audioAsVoice: true,
871+
accountId: "main",
872+
});
873+
874+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
875+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
876+
expect.objectContaining({
877+
mediaUrl: "https://example.com/reply.mp3",
878+
audioAsVoice: true,
879+
}),
880+
);
881+
});
882+
883+
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
884+
await feishuOutbound.sendMedia?.({
885+
cfg: emptyConfig,
886+
to: "chat_1",
887+
text: "spoken reply",
888+
mediaUrl: "https://example.com/reply.ogg?download=1",
889+
accountId: "main",
890+
});
891+
892+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
893+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
894+
expect.objectContaining({
895+
mediaUrl: "https://example.com/reply.ogg?download=1",
896+
}),
897+
);
898+
});
899+
900+
it("keeps captions for regular audio file attachments", async () => {
901+
await feishuOutbound.sendMedia?.({
902+
cfg: emptyConfig,
903+
to: "chat_1",
904+
text: "caption text",
905+
mediaUrl: "https://example.com/song.mp3",
906+
accountId: "main",
907+
});
908+
909+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
910+
expect.objectContaining({
911+
text: "caption text",
912+
}),
913+
);
914+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
915+
expect.objectContaining({
916+
mediaUrl: "https://example.com/song.mp3",
917+
}),
918+
);
919+
});
920+
921+
it("keeps skipped voice text in the upload failure fallback", async () => {
922+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
923+
924+
await feishuOutbound.sendMedia?.({
925+
cfg: emptyConfig,
926+
to: "chat_1",
927+
text: "spoken reply",
928+
mediaUrl: "https://example.com/reply.mp3",
929+
audioAsVoice: true,
930+
accountId: "main",
931+
});
932+
933+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
934+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
935+
expect.objectContaining({
936+
text: "spoken reply\n\n📎 https://example.com/reply.mp3",
937+
}),
938+
);
939+
});
940+
858941
it("forwards replyToId to text caption send", async () => {
859942
await feishuOutbound.sendMedia?.({
860943
cfg: emptyConfig,

extensions/feishu/src/outbound.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { createFeishuClient } from "./client.js";
2525
import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js";
2626
import { parseFeishuCommentTarget } from "./comment-target.js";
2727
import { deliverCommentThreadText } from "./drive.js";
28-
import { sendMediaFeishu } from "./media.js";
28+
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
2929
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
3030
import {
3131
resolveFeishuCardTemplate,
@@ -132,9 +132,10 @@ function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown
132132
if (!isRecord(button)) {
133133
return undefined;
134134
}
135-
const text = isRecord(button.text) && typeof button.text.content === "string"
136-
? button.text.content
137-
: undefined;
135+
const text =
136+
isRecord(button.text) && typeof button.text.content === "string"
137+
? button.text.content
138+
: undefined;
138139
if (!text?.trim()) {
139140
return undefined;
140141
}
@@ -176,7 +177,9 @@ function sanitizeNativeFeishuCardElement(element: unknown): Record<string, unkno
176177
return undefined;
177178
}
178179

179-
function sanitizeNativeFeishuCard(card: Record<string, unknown>): Record<string, unknown> | undefined {
180+
function sanitizeNativeFeishuCard(
181+
card: Record<string, unknown>,
182+
): Record<string, unknown> | undefined {
180183
const body = isRecord(card.body) ? card.body : undefined;
181184
const rawElements = Array.isArray(body?.elements) ? body.elements : [];
182185
const elements = rawElements
@@ -187,9 +190,10 @@ function sanitizeNativeFeishuCard(card: Record<string, unknown>): Record<string,
187190
}
188191

189192
const header = isRecord(card.header) ? card.header : undefined;
190-
const title = isRecord(header?.title) && typeof header.title.content === "string"
191-
? header.title.content
192-
: undefined;
193+
const title =
194+
isRecord(header?.title) && typeof header.title.content === "string"
195+
? header.title.content
196+
: undefined;
193197
return markRenderedFeishuCard({
194198
schema: "2.0",
195199
config: { width_mode: "fill" },
@@ -668,8 +672,15 @@ export const feishuOutbound: ChannelOutboundAdapter = {
668672
});
669673
}
670674

671-
// Send text first if provided
672-
if (text?.trim()) {
675+
const suppressTextForVoiceMedia =
676+
mediaUrl !== undefined &&
677+
shouldSuppressFeishuTextForVoiceMedia({
678+
mediaUrl,
679+
audioAsVoice,
680+
});
681+
682+
// Send text first if provided, except for Feishu native voice bubbles.
683+
if (text?.trim() && !suppressTextForVoiceMedia) {
673684
await sendOutboundText({
674685
cfg,
675686
to,
@@ -695,10 +706,11 @@ export const feishuOutbound: ChannelOutboundAdapter = {
695706
// Log the error for debugging
696707
console.error(`[feishu] sendMediaFeishu failed:`, err);
697708
// Fallback to URL link if upload fails
709+
const fallbackText = [text?.trim(), `📎 ${mediaUrl}`].filter(Boolean).join("\n\n");
698710
return await sendOutboundText({
699711
cfg,
700712
to,
701-
text: `📎 ${mediaUrl}`,
713+
text: fallbackText,
702714
accountId: accountId ?? undefined,
703715
replyToMessageId,
704716
});

0 commit comments

Comments
 (0)