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

@@ -43,12 +43,14 @@ Docs: https://docs.openclaw.ai
- CLI/sessions: keep intentional empty agent replies silent after tool-delivered channel output, instead of surfacing a misleading "No reply from agent." fallback. Thanks @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.
- Feishu: send the skipped reply text when `audioAsVoice` falls back to a generic file attachment after transcode failure, so voice-intent replies do not lose their caption.
- Feishu: keep packaged Feishu startup from bundling the Lark SDK's ESM `__dirname` path by loading the SDK as a plugin-local runtime dependency. Fixes #76291 and #76494. (#76392) Thanks @zqchris.
- Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt.
- 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.
- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+<digits>` and `@<digits>` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck.
- Channels/WhatsApp: require outbound mention tokens to end at a word boundary so phone-number prefixes inside longer strings no longer trigger hidden native mentions.
- Plugins/uninstall: remove empty managed git install parent directories after deleting cloned plugin repos and cover npm/git uninstall residue in Docker plugin lifecycle tests. Thanks @vincentkoc.
- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc.
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.

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

View File

@@ -114,6 +114,19 @@ describe("resolveWhatsAppOutboundMentions", () => {
});
});
it("does not mention numeric prefixes inside longer tokens", () => {
expect(
resolveWhatsAppOutboundMentions({
chatJid: "120363000000000000@g.us",
text: "literal @15551234567abc and x@15551234567",
participants: [{ id: "15551234567@s.whatsapp.net" }],
}),
).toEqual({
text: "literal @15551234567abc and x@15551234567",
mentionedJids: [],
});
});
it("does not add mention metadata for direct chats or unmatched group participants", () => {
expect(
resolveWhatsAppOutboundMentions({

View File

@@ -175,13 +175,15 @@ function buildMentionTargetMaps(participants: readonly WhatsAppOutboundMentionPa
function shouldSkipMentionAt(
text: string,
index: number,
end: number,
codeRanges: readonly TextRange[],
): boolean {
if (isInRange(index, codeRanges)) {
return true;
}
const previous = index > 0 ? text[index - 1] : "";
return Boolean(previous && /[\w@]/.test(previous));
const next = text[end] ?? "";
return Boolean((previous && /[\w@]/.test(previous)) || (next && /[\w@]/.test(next)));
}
export function resolveWhatsAppOutboundMentions(params: {
@@ -209,10 +211,10 @@ export function resolveWhatsAppOutboundMentions(params: {
for (const match of params.text.matchAll(OUTBOUND_MENTION_RE)) {
const start = match.index;
if (shouldSkipMentionAt(params.text, start, codeRanges)) {
const token = match[0];
if (shouldSkipMentionAt(params.text, start, start + token.length, codeRanges)) {
continue;
}
const token = match[0];
const digits = match[1].replace(/\D/g, "");
const target = token.startsWith("@+")
? (byPhone.get(digits) ?? byLid.get(digits))