fix(channels): normalize MIME kind parsing and reaction fallbacks

This commit is contained in:
Peter Steinberger
2026-03-02 23:48:00 +00:00
parent 32ecd6f579
commit ea3b7dfde5
13 changed files with 114 additions and 21 deletions

View File

@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
@@ -44,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.

View File

@@ -456,6 +456,43 @@ describe("handleDiscordMessageAction", () => {
expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }),
);
});
it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => {
await handleDiscordMessageAction({
action: "react",
params: {
channelId: "123",
emoji: "ok",
},
cfg: {} as OpenClawConfig,
toolContext: { currentMessageId: "9001" },
});
const call = handleDiscordAction.mock.calls.at(-1);
expect(call?.[0]).toEqual(
expect.objectContaining({
action: "react",
channelId: "123",
messageId: "9001",
emoji: "ok",
}),
);
});
it("rejects reactions when neither messageId nor toolContext.currentMessageId is provided", async () => {
await expect(
handleDiscordMessageAction({
action: "react",
params: {
channelId: "123",
emoji: "ok",
},
cfg: {} as OpenClawConfig,
}),
).rejects.toThrow(/messageId required/i);
expect(handleDiscordAction).not.toHaveBeenCalled();
});
});
describe("telegramMessageActions", () => {

View File

@@ -8,6 +8,7 @@ import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actio
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import { resolveDiscordChannelId } from "../../../../discord/targets.js";
import type { ChannelMessageActionContext } from "../../types.js";
import { resolveReactionMessageId } from "../reaction-message-id.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
const providerId = "discord";
@@ -107,7 +108,13 @@ export async function handleDiscordMessageAction(
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", { required: true });
const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext });
const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : "";
if (!messageId) {
throw new Error(
"messageId required. Provide messageId explicitly or react to the current inbound message.",
);
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleDiscordAction(

View File

@@ -25,12 +25,12 @@ import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
import {
isInboundPathAllowed,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../../media/inbound-path-policy.js";
import { kindFromMime } from "../../media/mime.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
@@ -224,7 +224,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
// Build arrays for all attachments (for multi-image support)
const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
const kind = mediaKindFromMime(mediaType ?? undefined);
const kind = kindFromMime(mediaType ?? undefined);
const placeholder = kind
? `<media:${kind}>`
: validAttachments.length

View File

@@ -71,6 +71,19 @@ describe("sendMessageIMessage", () => {
expect(params.text).toBe("<media:image>");
});
it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => {
await sendWithDefaults("chat_id:7", "", {
mediaUrl: "http://x/voice",
resolveAttachmentImpl: async () => ({
path: "/tmp/imessage-media.ogg",
contentType: " Audio/Ogg; codecs=opus ",
}),
});
const params = getSentParams();
expect(params.file).toBe("/tmp/imessage-media.ogg");
expect(params.text).toBe("<media:audio>");
});
it("returns message id when rpc provides one", async () => {
requestMock.mockResolvedValue({ ok: true, id: 123 });
const result = await sendWithDefaults("chat_id:7", "hello");

View File

@@ -1,7 +1,7 @@
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { convertMarkdownTables } from "../markdown/tables.js";
import { mediaKindFromMime } from "../media/constants.js";
import { kindFromMime } from "../media/mime.js";
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
@@ -129,7 +129,7 @@ export async function sendMessageIMessage(
});
filePath = resolved.path;
if (!message.trim()) {
const kind = mediaKindFromMime(resolved.contentType ?? undefined);
const kind = kindFromMime(resolved.contentType ?? undefined);
if (kind) {
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
}

View File

@@ -146,6 +146,31 @@ describe("signal mention gating", () => {
);
});
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: createSignalConfig({ requireMention: true }),
historyLimit: 5,
groupHistories,
ignoreAttachments: false,
}),
);
await handler(
makeGroupEvent({
message: "",
attachments: [{ contentType: " Audio/Ogg; codecs=opus " }],
}),
);
expect(capturedCtx).toBeUndefined();
const entries = groupHistories.get("g1");
expect(entries).toHaveLength(1);
expect(entries[0].body).toBe("<media:audio>");
});
it("records quote text in pending history for skipped quote-only group messages", async () => {
await expectSkippedGroupHistory({ message: "", quoteText: "quoted context" }, "quoted context");
});

View File

@@ -29,7 +29,7 @@ import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { kindFromMime } from "../../media/mime.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
DM_GROUP_ACCESS_REASON,
@@ -636,7 +636,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
return "<media:attachment>";
}
const firstContentType = dataMessage.attachments?.[0]?.contentType;
const pendingKind = mediaKindFromMime(firstContentType ?? undefined);
const pendingKind = kindFromMime(firstContentType ?? undefined);
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
})();
const pendingBodyText = messageText || pendingPlaceholder || quoteText;
@@ -679,7 +679,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
}
}
const kind = mediaKindFromMime(mediaType ?? undefined);
const kind = kindFromMime(mediaType ?? undefined);
if (kind) {
placeholder = `<media:${kind}>`;
} else if (dataMessage.attachments?.length) {

View File

@@ -1,6 +1,6 @@
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { mediaKindFromMime } from "../media/constants.js";
import { kindFromMime } from "../media/mime.js";
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
@@ -130,7 +130,7 @@ export async function sendMessageSignal(
localRoots: opts.mediaLocalRoots,
});
attachments = [resolved.path];
const kind = mediaKindFromMime(resolved.contentType ?? undefined);
const kind = kindFromMime(resolved.contentType ?? undefined);
if (!message && kind) {
// Avoid sending an empty body when only attachments exist.
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;

View File

@@ -5,9 +5,8 @@ import type { ReplyToMode } from "../../config/config.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { danger, logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { buildOutboundMediaLoadOptions } from "../../media/load-options.js";
import { isGifMedia } from "../../media/mime.js";
import { isGifMedia, kindFromMime } from "../../media/mime.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js";
import type { TelegramInlineButtons } from "../button-types.js";
@@ -234,7 +233,7 @@ async function deliverMediaReply(params: {
mediaUrl,
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
);
const kind = mediaKindFromMime(media.contentType ?? undefined);
const kind = kindFromMime(media.contentType ?? undefined);
const isGif = isGifMedia({
contentType: media.contentType,
fileName: media.fileName,

View File

@@ -872,6 +872,16 @@ describe("sendMessageTelegram", () => {
expectedMethod: "sendVoice" as const,
expectedOptions: { caption: "caption", parse_mode: "HTML" },
},
{
name: "normalizes parameterized audio MIME with mixed casing",
chatId: "123",
text: "caption",
mediaUrl: "https://example.com/note",
contentType: " Audio/Ogg; codecs=opus ",
fileName: "note.ogg",
expectedMethod: "sendAudio" as const,
expectedOptions: { caption: "caption", parse_mode: "HTML" },
},
];
for (const testCase of cases) {

View File

@@ -15,9 +15,9 @@ import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import type { RetryConfig } from "../infra/retry.js";
import { redactSensitiveText } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { mediaKindFromMime } from "../media/constants.js";
import type { MediaKind } from "../media/constants.js";
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
import { isGifMedia } from "../media/mime.js";
import { isGifMedia, kindFromMime } from "../media/mime.js";
import { normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js";
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
@@ -566,7 +566,7 @@ export async function sendMessageTelegram(
mediaLocalRoots: opts.mediaLocalRoots,
}),
);
const kind = mediaKindFromMime(media.contentType ?? undefined);
const kind = kindFromMime(media.contentType ?? undefined);
const isGif = isGifMedia({
contentType: media.contentType,
fileName: media.fileName,
@@ -944,7 +944,7 @@ export async function editMessageTelegram(
return { ok: true, messageId: String(messageId), chatId };
}
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
function inferFilename(kind: MediaKind) {
switch (kind) {
case "image":
return "image.jpg";

View File

@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { type MediaKind, maxBytesForKind } from "../media/constants.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import {
convertHeicToJpeg,
@@ -13,7 +13,7 @@ import {
resizeToJpeg,
} from "../media/image-ops.js";
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { detectMime, extensionForMime } from "../media/mime.js";
import { detectMime, extensionForMime, kindFromMime } from "../media/mime.js";
import { resolveUserPath } from "../utils.js";
export type WebMediaResult = {
@@ -333,7 +333,7 @@ async function loadWebMediaInternal(
: maxBytes;
const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap, ssrfPolicy });
const { buffer, contentType, fileName } = fetched;
const kind = mediaKindFromMime(contentType);
const kind = kindFromMime(contentType);
return await clampAndFinalize({ buffer, contentType, kind, fileName });
}
@@ -385,7 +385,7 @@ async function loadWebMediaInternal(
}
}
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const kind = mediaKindFromMime(mime);
const kind = kindFromMime(mime);
let fileName = path.basename(mediaUrl) || undefined;
if (fileName && !path.extname(fileName) && mime) {
const ext = extensionForMime(mime);