fix(feishu): avoid duplicate voice reply text

This commit is contained in:
Vincent Koc
2026-05-02 23:18:15 -07:00
parent 3c51692543
commit 14312ff570
7 changed files with 317 additions and 24 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -55,6 +55,7 @@ let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
@@ -92,6 +93,7 @@ describe("sendMediaFeishu msg_type routing", () => {
downloadMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
shouldSuppressFeishuTextForVoiceMedia,
} = await import("./media.js"));
});
@@ -155,6 +157,25 @@ describe("sendMediaFeishu msg_type routing", () => {
});
});
it("suppresses reply text only for voice-intent or native voice media", () => {
expect(
shouldSuppressFeishuTextForVoiceMedia({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
).toBe(true);
expect(
shouldSuppressFeishuTextForVoiceMedia({
mediaUrl: "https://example.com/reply.ogg?download=1",
}),
).toBe(true);
expect(
shouldSuppressFeishuTextForVoiceMedia({
mediaUrl: "https://example.com/song.mp3",
}),
).toBe(false);
});
it("uses msg_type=media for mp4 video", async () => {
await sendMediaFeishu({
cfg: emptyConfig,

View File

@@ -694,6 +694,41 @@ function isFeishuNativeVoiceAudio(params: { fileName: string; contentType?: stri
);
}
function normalizeMediaNameForExtension(raw: string): string {
try {
return new URL(raw).pathname;
} catch {
return raw.split(/[?#]/, 1)[0] ?? raw;
}
}
export function shouldSuppressFeishuTextForVoiceMedia(params: {
mediaUrl?: string;
fileName?: string;
contentType?: string;
audioAsVoice?: boolean;
}): boolean {
if (params.audioAsVoice === true) {
return true;
}
if (
params.fileName &&
isFeishuNativeVoiceAudio({
fileName: params.fileName,
contentType: params.contentType,
})
) {
return true;
}
if (!params.mediaUrl) {
return false;
}
return isFeishuNativeVoiceAudio({
fileName: normalizeMediaNameForExtension(params.mediaUrl),
contentType: params.contentType,
});
}
function isLikelyTranscodableAudio(params: { fileName: string; contentType?: string }): boolean {
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
const contentType = normalizeLowercaseStringOrEmpty(params.contentType);

View File

@@ -12,9 +12,14 @@ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false));
const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
() => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
);
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
shouldSuppressFeishuTextForVoiceMedia: shouldSuppressFeishuTextForVoiceMediaMock,
}));
vi.mock("./send.js", () => ({
@@ -406,13 +411,13 @@ describe("feishuOutbound.sendPayload native cards", () => {
await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text: "Choose <at id=\"ou_1\">",
text: 'Choose <at id="ou_1">',
accountId: "main",
payload: {
text: "Choose <at id=\"ou_1\">",
text: 'Choose <at id="ou_1">',
presentation: {
blocks: [
{ type: "context", text: "</font><at id=\"ou_2\">Injected</at>" },
{ type: "context", text: '</font><at id="ou_2">Injected</at>' },
{
type: "buttons",
buttons: [
@@ -428,10 +433,11 @@ describe("feishuOutbound.sendPayload native cards", () => {
const card = sendCardFeishuMock.mock.calls[0][0].card;
expect(card.body.elements).toEqual(
expect.arrayContaining([
{ tag: "markdown", content: "Choose &lt;at id=\"ou_1\"&gt;" },
{ tag: "markdown", content: 'Choose &lt;at id="ou_1"&gt;' },
{
tag: "markdown",
content: "<font color='grey'>&lt;/font&gt;&lt;at id=\"ou_2\"&gt;Injected&lt;/at&gt;</font>",
content:
"<font color='grey'>&lt;/font&gt;&lt;at id=\"ou_2\"&gt;Injected&lt;/at&gt;</font>",
},
{
tag: "action",
@@ -466,7 +472,7 @@ describe("feishuOutbound.sendPayload native cards", () => {
body: {
elements: [
{ tag: "img", img_key: "image-secret" },
{ tag: "markdown", content: "<at id=\"ou_1\">ping</at>" },
{ tag: "markdown", content: '<at id="ou_1">ping</at>' },
{
tag: "action",
actions: [
@@ -493,7 +499,7 @@ describe("feishuOutbound.sendPayload native cards", () => {
const card = sendCardFeishuMock.mock.calls[0][0].card;
expect(card.header.template).toBe("blue");
expect(card.body.elements).toEqual([
{ tag: "markdown", content: "&lt;at id=\"ou_1\"&gt;ping&lt;/at&gt;" },
{ tag: "markdown", content: '&lt;at id="ou_1"&gt;ping&lt;/at&gt;' },
{
tag: "action",
actions: [
@@ -855,6 +861,83 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
);
});
it("suppresses duplicate text when sending voice media", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
accountId: "main",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
});
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "spoken reply",
mediaUrl: "https://example.com/reply.ogg?download=1",
accountId: "main",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.ogg?download=1",
}),
);
});
it("keeps captions for regular audio file attachments", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "caption text",
mediaUrl: "https://example.com/song.mp3",
accountId: "main",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption text",
}),
);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/song.mp3",
}),
);
});
it("keeps skipped voice text in the upload failure fallback", async () => {
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
accountId: "main",
});
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply\n\n📎 https://example.com/reply.mp3",
}),
);
});
it("forwards replyToId to text caption send", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,

View File

@@ -25,7 +25,7 @@ import { createFeishuClient } from "./client.js";
import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js";
import { parseFeishuCommentTarget } from "./comment-target.js";
import { deliverCommentThreadText } from "./drive.js";
import { sendMediaFeishu } from "./media.js";
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
import {
resolveFeishuCardTemplate,
@@ -132,9 +132,10 @@ function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown
if (!isRecord(button)) {
return undefined;
}
const text = isRecord(button.text) && typeof button.text.content === "string"
? button.text.content
: undefined;
const text =
isRecord(button.text) && typeof button.text.content === "string"
? button.text.content
: undefined;
if (!text?.trim()) {
return undefined;
}
@@ -176,7 +177,9 @@ function sanitizeNativeFeishuCardElement(element: unknown): Record<string, unkno
return undefined;
}
function sanitizeNativeFeishuCard(card: Record<string, unknown>): Record<string, unknown> | undefined {
function sanitizeNativeFeishuCard(
card: Record<string, unknown>,
): Record<string, unknown> | undefined {
const body = isRecord(card.body) ? card.body : undefined;
const rawElements = Array.isArray(body?.elements) ? body.elements : [];
const elements = rawElements
@@ -187,9 +190,10 @@ function sanitizeNativeFeishuCard(card: Record<string, unknown>): Record<string,
}
const header = isRecord(card.header) ? card.header : undefined;
const title = isRecord(header?.title) && typeof header.title.content === "string"
? header.title.content
: undefined;
const title =
isRecord(header?.title) && typeof header.title.content === "string"
? header.title.content
: undefined;
return markRenderedFeishuCard({
schema: "2.0",
config: { width_mode: "fill" },
@@ -668,8 +672,15 @@ export const feishuOutbound: ChannelOutboundAdapter = {
});
}
// Send text first if provided
if (text?.trim()) {
const suppressTextForVoiceMedia =
mediaUrl !== undefined &&
shouldSuppressFeishuTextForVoiceMedia({
mediaUrl,
audioAsVoice,
});
// Send text first if provided, except for Feishu native voice bubbles.
if (text?.trim() && !suppressTextForVoiceMedia) {
await sendOutboundText({
cfg,
to,
@@ -695,10 +706,11 @@ export const feishuOutbound: ChannelOutboundAdapter = {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
const fallbackText = [text?.trim(), `📎 ${mediaUrl}`].filter(Boolean).join("\n\n");
return await sendOutboundText({
cfg,
to,
text: `📎 ${mediaUrl}`,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});

View File

@@ -20,6 +20,10 @@ const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
() => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
);
function mergeStreamingText(
previousText: string | undefined,
@@ -58,7 +62,10 @@ vi.mock("./send.js", () => ({
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
shouldSuppressFeishuTextForVoiceMedia: shouldSuppressFeishuTextForVoiceMediaMock,
}));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
vi.mock("./typing.js", () => ({
@@ -619,6 +626,92 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("suppresses duplicate text when final replies send voice media", async () => {
const { options } = createDispatcherHarness();
await options.deliver(
{
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
},
{ kind: "final" },
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
});
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
const { options } = createDispatcherHarness();
await options.deliver(
{
text: "spoken reply",
mediaUrl: "https://example.com/reply.opus?download=1",
},
{ kind: "final" },
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.opus?download=1",
}),
);
});
it("preserves captions for regular audio attachments", async () => {
const { options } = createDispatcherHarness();
await options.deliver(
{
text: "caption text",
mediaUrl: "https://example.com/song.mp3",
},
{ kind: "final" },
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption text",
}),
);
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/song.mp3",
}),
);
});
it("keeps skipped voice text in the upload failure fallback", async () => {
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
const { options } = createDispatcherHarness();
await options.deliver(
{
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
},
{ kind: "final" },
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply\n\n📎 https://example.com/reply.mp3",
}),
);
});
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
const { options } = createDispatcherHarness();
await options.deliver(

View File

@@ -8,7 +8,7 @@ import {
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { sendMediaFeishu } from "./media.js";
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent } from "./mention.js";
import {
@@ -54,6 +54,12 @@ function rememberStreamingStartFailure(accountId: string, now = Date.now()): num
return backoffUntil;
}
function formatMediaFallbackText(text: string | undefined, mediaUrl: string): string {
const trimmedText = text?.trim() ?? "";
const attachmentText = `📎 ${mediaUrl}`;
return trimmedText ? `${trimmedText}\n\n${attachmentText}` : attachmentText;
}
export function clearFeishuStreamingStartBackoffForTests() {
streamingStartBackoffUntilByAccount.clear();
}
@@ -431,9 +437,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
};
const sendMediaReplies = async (payload: ReplyPayload) => {
const sendMediaReplies = async (payload: ReplyPayload, options?: { fallbackText?: string }) => {
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
let sentFallbackText = false;
await sendMediaWithLeadingCaption({
mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls,
mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
await sendMediaFeishu({
@@ -446,6 +454,32 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
});
},
onError:
options?.fallbackText === undefined
? undefined
: async ({ mediaUrl }) => {
const fallbackText = formatMediaFallbackText(
sentFallbackText ? undefined : options.fallbackText,
mediaUrl,
);
sentFallbackText = true;
await sendChunkedTextReply({
text: fallbackText,
useCard: false,
infoKind: "final",
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: isFirst ? mentionTargets : undefined,
accountId,
});
},
});
},
});
};
@@ -468,6 +502,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const text = reply.text;
const hasText = reply.hasText;
const hasMedia = reply.hasMedia;
const hasVoiceMedia =
hasMedia &&
reply.mediaUrls.some((mediaUrl) =>
shouldSuppressFeishuTextForVoiceMedia({
mediaUrl,
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
}),
);
const useCard =
hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)));
const skipTextForDuplicateFinal =
@@ -480,7 +522,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
streamingEnabled &&
useCard;
const shouldDeliverText =
hasText && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
hasText &&
!hasVoiceMedia &&
!skipTextForDuplicateFinal &&
!skipTextForClosedStreamingFinal;
if (!shouldDeliverText && !hasMedia) {
return;
@@ -567,7 +612,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
if (hasMedia) {
await sendMediaReplies(payload);
await sendMediaReplies(
payload,
hasVoiceMedia && hasText ? { fallbackText: text } : undefined,
);
}
},
onError: async (error, info) => {