fix(channels): preserve degraded voice text and mention boundaries

This commit is contained in:
Peter Steinberger
2026-05-03 12:17:01 +01:00
parent 1e4098134a
commit 8dd6a2d323
9 changed files with 132 additions and 8 deletions

View File

@@ -361,7 +361,7 @@ describe("sendMediaFeishu msg_type routing", () => {
contentType: "audio/mpeg",
});
await sendMediaFeishu({
const result = await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaUrl: "https://example.com/reply.mp3",
@@ -382,6 +382,7 @@ describe("sendMediaFeishu msg_type routing", () => {
data: expect.objectContaining({ msg_type: "file" }),
}),
);
expect(result).toEqual(expect.objectContaining({ voiceIntentDegradedToFile: true }));
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("audioAsVoice transcode failed"),
expect.any(Error),

View File

@@ -399,6 +399,7 @@ export type UploadFileResult = {
export type SendMediaResult = {
messageId: string;
chatId: string;
voiceIntentDegradedToFile?: boolean;
};
/**
@@ -872,10 +873,22 @@ export async function sendMediaFeishu(params: {
contentType = prepared.contentType;
const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
const voiceIntentDegradedToFile = audioAsVoice === true && routing.msgType !== "audio";
if (routing.msgType === "image") {
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
const result = await sendImageFeishu({
cfg,
to,
imageKey,
replyToMessageId,
replyInThread,
accountId,
});
return {
...result,
...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}),
};
}
const { fileKey } = await uploadFileFeishu({
cfg,
@@ -884,7 +897,7 @@ export async function sendMediaFeishu(params: {
fileType: routing.fileType ?? "stream",
accountId,
});
return sendFileFeishu({
const result = await sendFileFeishu({
cfg,
to,
fileKey,
@@ -893,4 +906,8 @@ export async function sendMediaFeishu(params: {
replyInThread,
accountId,
});
return {
...result,
...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}),
};
}

View File

@@ -880,6 +880,35 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
);
});
it("sends skipped voice text when voice media degrades to a file attachment", async () => {
sendMediaFeishuMock.mockResolvedValueOnce({
messageId: "file_msg",
voiceIntentDegradedToFile: true,
});
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
accountId: "main",
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply",
}),
);
});
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,

View File

@@ -693,7 +693,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
// Upload and send media if URL or local path provided
if (mediaUrl) {
try {
return await sendMediaFeishu({
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
@@ -702,6 +702,16 @@ export const feishuOutbound: ChannelOutboundAdapter = {
replyToMessageId,
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
});
if (result.voiceIntentDegradedToFile && text?.trim()) {
await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
return result;
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);

View File

@@ -648,6 +648,37 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("sends skipped voice text when final voice media degrades to a file attachment", async () => {
sendMediaFeishuMock.mockResolvedValueOnce({
messageId: "file_msg",
voiceIntentDegradedToFile: true,
});
const { options } = createDispatcherHarness();
await options.deliver(
{
text: "spoken reply",
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
},
{ kind: "final" },
);
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply",
}),
);
});
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
const { options } = createDispatcherHarness();
await options.deliver(

View File

@@ -444,7 +444,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
await sendMediaFeishu({
const result = await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
@@ -453,6 +453,25 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
accountId,
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
});
if (result?.voiceIntentDegradedToFile && options?.fallbackText && !sentFallbackText) {
sentFallbackText = true;
await sendChunkedTextReply({
text: options.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,
});
},
});
}
},
onError:
options?.fallbackText === undefined