diff --git a/CHANGELOG.md b/CHANGELOG.md index 7257b0d2339..b6a0ae6d0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. - Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. +- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c. - Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew. - Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. - Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr. @@ -85,6 +86,7 @@ Docs: https://docs.openclaw.ai - Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. - Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo. - Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998) +- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo. - Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo. - Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 6ee19453917..49c4a6120d6 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -44,11 +44,15 @@ Examples: Routing picks **one agent** for each inbound message: 1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`). -2. **Guild match** (Discord) via `guildId`. -3. **Team match** (Slack) via `teamId`. -4. **Account match** (`accountId` on the channel). -5. **Channel match** (any account on that channel). -6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). +2. **Parent peer match** (thread inheritance). +3. **Guild + roles match** (Discord) via `guildId` + `roles`. +4. **Guild match** (Discord) via `guildId`. +5. **Team match** (Slack) via `teamId`. +6. **Account match** (`accountId` on the channel). +7. **Channel match** (any account on that channel, `accountId: "*"`). +8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). + +When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply. The matched agent determines which workspace and session store are used. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 3f3031fa337..06f8ddf76ab 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -173,7 +173,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D ### Role-based agent routing -Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. +Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match. ```json5 { diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 027654a9006..8f4c05a7cc8 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -125,11 +125,15 @@ Notes: Bindings are **deterministic** and **most-specific wins**: 1. `peer` match (exact DM/group/channel id) -2. `guildId` (Discord) -3. `teamId` (Slack) -4. `accountId` match for a channel -5. channel-level match (`accountId: "*"`) -6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`) +2. `parentPeer` match (thread inheritance) +3. `guildId + roles` (Discord role routing) +4. `guildId` (Discord) +5. `teamId` (Slack) +6. `accountId` match for a channel +7. channel-level match (`accountId: "*"`) +8. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`) + +If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics). ## Multiple accounts / phone numbers diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 0ebfc826f80..d931d6f9db1 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -24,6 +24,8 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({ contentType: "image/png", kind: "image", }); +const mediaKindFromMimeMock = vi.fn(() => "image"); +const isVoiceCompatibleAudioMock = vi.fn(() => false); const getImageMetadataMock = vi.fn().mockResolvedValue(null); const resizeToJpegMock = vi.fn(); @@ -33,8 +35,8 @@ const runtimeStub = { }, media: { loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), - mediaKindFromMime: () => "image", - isVoiceCompatibleAudio: () => false, + mediaKindFromMime: (...args: unknown[]) => mediaKindFromMimeMock(...args), + isVoiceCompatibleAudio: (...args: unknown[]) => isVoiceCompatibleAudioMock(...args), getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), }, @@ -71,6 +73,8 @@ describe("sendMessageMatrix media", () => { beforeEach(() => { vi.clearAllMocks(); + mediaKindFromMimeMock.mockReturnValue("image"); + isVoiceCompatibleAudioMock.mockReturnValue(false); setMatrixRuntime(runtimeStub); }); @@ -133,6 +137,66 @@ describe("sendMessageMatrix media", () => { expect(content.url).toBeUndefined(); expect(content.file?.url).toBe("mxc://example/file"); }); + + it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => { + const { client, sendMessage } = makeClient(); + mediaKindFromMimeMock.mockReturnValue("audio"); + isVoiceCompatibleAudioMock.mockReturnValue(true); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + fileName: "clip.mp3", + contentType: "audio/mpeg", + kind: "audio", + }); + + await sendMessageMatrix("room:!room:example", "voice caption", { + client, + mediaUrl: "file:///tmp/clip.mp3", + audioAsVoice: true, + }); + + expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({ + contentType: "audio/mpeg", + fileName: "clip.mp3", + }); + expect(sendMessage).toHaveBeenCalledTimes(2); + const mediaContent = sendMessage.mock.calls[0]?.[1] as { + msgtype?: string; + body?: string; + "org.matrix.msc3245.voice"?: Record; + }; + expect(mediaContent.msgtype).toBe("m.audio"); + expect(mediaContent.body).toBe("Voice message"); + expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({}); + }); + + it("keeps regular audio payload when audioAsVoice media is incompatible", async () => { + const { client, sendMessage } = makeClient(); + mediaKindFromMimeMock.mockReturnValue("audio"); + isVoiceCompatibleAudioMock.mockReturnValue(false); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + fileName: "clip.wav", + contentType: "audio/wav", + kind: "audio", + }); + + await sendMessageMatrix("room:!room:example", "voice caption", { + client, + mediaUrl: "file:///tmp/clip.wav", + audioAsVoice: true, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + const mediaContent = sendMessage.mock.calls[0]?.[1] as { + msgtype?: string; + body?: string; + "org.matrix.msc3245.voice"?: Record; + }; + expect(mediaContent.msgtype).toBe("m.audio"); + expect(mediaContent.body).toBe("voice caption"); + expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined(); + }); }); describe("sendMessageMatrix threads", () => { diff --git a/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts b/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts index 8b5ca4e5802..9d6810ed319 100644 --- a/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts +++ b/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts @@ -249,52 +249,28 @@ describe("browser control server", () => { await stopBrowserControlServer(); }); - it("covers primary control routes, validation, and profile compatibility", async () => { + it("keeps maxChars unset when snapshot explicitly passes zero", async () => { const { startBrowserControlServerFromConfig } = await import("./server.js"); const started = await startBrowserControlServerFromConfig(); expect(started?.port).toBe(testPort); const base = `http://127.0.0.1:${testPort}`; - const statusBeforeStart = (await realFetch(`${base}/`).then((r) => r.json())) as { - running: boolean; - pid: number | null; - }; - expect(statusBeforeStart.running).toBe(false); - expect(statusBeforeStart.pid).toBe(null); - expect(statusBeforeStart.profile).toBe("openclaw"); - const startedPayload = (await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json(), )) as { ok: boolean; profile?: string }; expect(startedPayload.ok).toBe(true); expect(startedPayload.profile).toBe("openclaw"); - const statusAfterStart = (await realFetch(`${base}/`).then((r) => r.json())) as { - running: boolean; - pid: number | null; - chosenBrowser: string | null; - }; - expect(statusAfterStart.running).toBe(true); - expect(statusAfterStart.pid).toBe(123); - expect(statusAfterStart.chosenBrowser).toBe("chrome"); - expect(launchCalls.length).toBeGreaterThan(0); - const snapAi = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) => r.json(), )) as { ok: boolean; format?: string }; expect(snapAi.ok).toBe(true); expect(snapAi.format).toBe("ai"); + expect(launchCalls.length).toBeGreaterThan(0); const [call] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? []; expect(call).toEqual({ cdpUrl: cdpBaseUrl, targetId: "abcd1234", }); - - const stopped = (await realFetch(`${base}/stop`, { method: "POST" }).then((r) => r.json())) as { - ok: boolean; - profile?: string; - }; - expect(stopped.ok).toBe(true); - expect(stopped.profile).toBe("openclaw"); }); }); diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 72f4d2dd4d8..92227d14279 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; describe("applyPluginAutoEnable", () => { - it("configures channel plugins with disabled state and updates allowlist", () => { + it("auto-enables channel plugins and updates allowlist", () => { const result = applyPluginAutoEnable({ config: { channels: { slack: { botToken: "x" } }, @@ -11,9 +11,9 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.slack?.enabled).toBe(false); + expect(result.config.plugins?.entries?.slack?.enabled).toBe(true); expect(result.config.plugins?.allow).toEqual(["telegram", "slack"]); - expect(result.changes.join("\n")).toContain("Slack configured, not enabled yet."); + expect(result.changes.join("\n")).toContain("Slack configured, enabled automatically."); }); it("respects explicit disable", () => { @@ -29,7 +29,7 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); - it("configures irc as disabled when configured via env", () => { + it("auto-enables irc when configured via env", () => { const result = applyPluginAutoEnable({ config: {}, env: { @@ -38,11 +38,11 @@ describe("applyPluginAutoEnable", () => { }, }); - expect(result.config.plugins?.entries?.irc?.enabled).toBe(false); - expect(result.changes.join("\n")).toContain("IRC configured, not enabled yet."); + expect(result.config.plugins?.entries?.irc?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically."); }); - it("configures provider auth plugins as disabled when profiles exist", () => { + it("auto-enables provider auth plugins when profiles exist", () => { const result = applyPluginAutoEnable({ config: { auth: { @@ -57,7 +57,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(false); + expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true); }); it("skips when plugins are globally disabled", () => { @@ -85,10 +85,12 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined(); - expect(result.changes.join("\n")).toContain("bluebubbles configured, not enabled yet."); - expect(result.changes.join("\n")).not.toContain("iMessage configured, not enabled yet."); + expect(result.changes.join("\n")).toContain("bluebubbles configured, enabled automatically."); + expect(result.changes.join("\n")).not.toContain( + "iMessage configured, enabled automatically.", + ); }); it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => { @@ -103,7 +105,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); }); @@ -120,8 +122,8 @@ describe("applyPluginAutoEnable", () => { }); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); - expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically."); }); it("allows imessage auto-configure when bluebubbles is in deny list", () => { @@ -137,10 +139,10 @@ describe("applyPluginAutoEnable", () => { }); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined(); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); }); - it("configures imessage as disabled when only imessage is configured", () => { + it("auto-enables imessage when only imessage is configured", () => { const result = applyPluginAutoEnable({ config: { channels: { imessage: { cliPath: "/usr/local/bin/imsg" } }, @@ -148,8 +150,8 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); - expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically."); }); }); }); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index eb56c3402d6..5e02d2c8f95 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -407,7 +407,7 @@ function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawCon ...cfg.plugins?.entries, [pluginId]: { ...(cfg.plugins?.entries?.[pluginId] as Record | undefined), - enabled: false, + enabled: true, }, }; return { @@ -426,7 +426,7 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { const label = getChatChannelMeta(channelId).label; reason = reason.replace(new RegExp(`^${channelId}\\b`, "i"), label); } - return `${reason}, not enabled yet.`; + return `${reason}, enabled automatically.`; } export function applyPluginAutoEnable(params: { diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 2c369b2f923..6bc9fcd0237 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -235,7 +235,7 @@ describe("Cron issue regressions", () => { }); await cron.start(); - const runAt = Date.now() + 5; + const runAt = Date.now() + 1; const job = await cron.add({ name: "timer-overlap", enabled: true, @@ -246,8 +246,8 @@ describe("Cron issue regressions", () => { delivery: { mode: "none" }, }); - for (let i = 0; i < 30 && runIsolatedAgentJob.mock.calls.length === 0; i++) { - await delay(5); + for (let i = 0; i < 20 && runIsolatedAgentJob.mock.calls.length === 0; i++) { + await delay(1); } expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); @@ -256,12 +256,12 @@ describe("Cron issue regressions", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); resolveRun?.({ status: "ok", summary: "done" }); - for (let i = 0; i < 30; i++) { + for (let i = 0; i < 20; i++) { const jobs = await cron.list({ includeDisabled: true }); if (jobs.some((j) => j.id === job.id && j.state.lastStatus === "ok")) { break; } - await delay(5); + await delay(1); } cron.stop(); diff --git a/src/media/audio.test.ts b/src/media/audio.test.ts new file mode 100644 index 00000000000..af25bb69d74 --- /dev/null +++ b/src/media/audio.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { isVoiceCompatibleAudio } from "./audio.js"; + +describe("isVoiceCompatibleAudio", () => { + it.each([ + { contentType: "audio/ogg", fileName: null }, + { contentType: "audio/opus", fileName: null }, + { contentType: "audio/ogg; codecs=opus", fileName: null }, + { contentType: "audio/mpeg", fileName: null }, + { contentType: "audio/mp3", fileName: null }, + { contentType: "audio/mp4", fileName: null }, + { contentType: "audio/mp4; codecs=mp4a.40.2", fileName: null }, + { contentType: "audio/x-m4a", fileName: null }, + { contentType: "audio/m4a", fileName: null }, + ])("returns true for MIME type $contentType", (opts) => { + expect(isVoiceCompatibleAudio(opts)).toBe(true); + }); + + it.each([".ogg", ".oga", ".opus", ".mp3", ".m4a"])("returns true for extension %s", (ext) => { + expect(isVoiceCompatibleAudio({ fileName: `voice${ext}` })).toBe(true); + }); + + it.each([ + { contentType: "audio/wav", fileName: null }, + { contentType: "audio/flac", fileName: null }, + { contentType: "audio/aac", fileName: null }, + { contentType: "video/mp4", fileName: null }, + ])("returns false for unsupported MIME $contentType", (opts) => { + expect(isVoiceCompatibleAudio(opts)).toBe(false); + }); + + it.each([".wav", ".flac", ".webm"])("returns false for extension %s", (ext) => { + expect(isVoiceCompatibleAudio({ fileName: `audio${ext}` })).toBe(false); + }); + + it("returns false when no contentType and no fileName", () => { + expect(isVoiceCompatibleAudio({})).toBe(false); + }); + + it("prefers MIME type over extension", () => { + expect(isVoiceCompatibleAudio({ contentType: "audio/mpeg", fileName: "file.wav" })).toBe(true); + }); +}); diff --git a/src/media/audio.ts b/src/media/audio.ts index aeca2ce0b53..b632533bbb0 100644 --- a/src/media/audio.ts +++ b/src/media/audio.ts @@ -1,14 +1,32 @@ import { getFileExtension } from "./mime.js"; -const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus"]); +const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus", ".mp3", ".m4a"]); + +/** + * MIME types compatible with voice messages. + * Telegram sendVoice supports OGG/Opus, MP3, and M4A. + * https://core.telegram.org/bots/api#sendvoice + */ +const VOICE_MIME_TYPES = new Set([ + "audio/ogg", + "audio/opus", + "audio/mpeg", + "audio/mp3", + "audio/mp4", + "audio/x-m4a", + "audio/m4a", +]); export function isVoiceCompatibleAudio(opts: { contentType?: string | null; fileName?: string | null; }): boolean { - const mime = opts.contentType?.toLowerCase(); - if (mime && (mime.includes("ogg") || mime.includes("opus"))) { - return true; + const mime = opts.contentType?.toLowerCase().trim(); + if (mime) { + const baseMime = mime.split(";")[0].trim(); + if (VOICE_MIME_TYPES.has(baseMime)) { + return true; + } } const fileName = opts.fileName?.trim(); if (!fileName) { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 412e002ffdf..5c45eb69c3c 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -169,6 +169,126 @@ describe("resolveAgentRoute", () => { expect(route.matchedBy).toBe("binding.guild"); }); + test("peer+guild binding does not act as guild-wide fallback when peer mismatches (#14752)", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "olga", + match: { + channel: "discord", + peer: { kind: "channel", id: "CHANNEL_A" }, + guildId: "GUILD_1", + }, + }, + { + agentId: "main", + match: { + channel: "discord", + guildId: "GUILD_1", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "CHANNEL_B" }, + guildId: "GUILD_1", + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("peer+guild binding requires guild match even when peer matches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "wrongguild", + match: { + channel: "discord", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }, + }, + { + agentId: "rightguild", + match: { + channel: "discord", + guildId: "g2", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "c1" }, + guildId: "g2", + }); + expect(route.agentId).toBe("rightguild"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("peer+team binding does not act as team-wide fallback when peer mismatches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "roomonly", + match: { + channel: "slack", + peer: { kind: "channel", id: "C_A" }, + teamId: "T1", + }, + }, + { + agentId: "teamwide", + match: { + channel: "slack", + teamId: "T1", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + teamId: "T1", + peer: { kind: "channel", id: "C_B" }, + }); + expect(route.agentId).toBe("teamwide"); + expect(route.matchedBy).toBe("binding.team"); + }); + + test("peer+team binding requires team match even when peer matches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "wrongteam", + match: { + channel: "slack", + peer: { kind: "channel", id: "C1" }, + teamId: "T1", + }, + }, + { + agentId: "rightteam", + match: { + channel: "slack", + teamId: "T2", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + teamId: "T2", + peer: { kind: "channel", id: "C1" }, + }); + expect(route.agentId).toBe("rightteam"); + expect(route.matchedBy).toBe("binding.team"); + }); + test("missing accountId in binding matches default account only", () => { const cfg: OpenClawConfig = { bindings: [{ agentId: "defaultAcct", match: { channel: "whatsapp" } }], @@ -592,4 +712,37 @@ describe("role-based agent routing", () => { expect(route.agentId).toBe("main"); expect(route.matchedBy).toBe("default"); }); + + test("peer+guild+roles binding does not act as guild+roles fallback when peer mismatches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "peer-roles", + match: { + channel: "discord", + peer: { kind: "channel", id: "c-target" }, + guildId: "g1", + roles: ["r1"], + }, + }, + { + agentId: "guild-roles", + match: { + channel: "discord", + guildId: "g1", + roles: ["r1"], + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c-other" }, + }); + expect(route.agentId).toBe("guild-roles"); + expect(route.matchedBy).toBe("binding.guild+roles"); + }); }); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 55c7d5e475e..e59f53721c6 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -152,25 +152,6 @@ function matchesPeer( return kind === peer.kind && id === peer.id; } -function matchesGuild( - match: { guildId?: string | undefined } | undefined, - guildId: string, -): boolean { - const id = normalizeId(match?.guildId); - if (!id) { - return false; - } - return id === guildId; -} - -function matchesTeam(match: { teamId?: string | undefined } | undefined, teamId: string): boolean { - const id = normalizeId(match?.teamId); - if (!id) { - return false; - } - return id === teamId; -} - function matchesRoles( match: { roles?: string[] | undefined } | undefined, memberRoleIds: string[], @@ -182,6 +163,91 @@ function matchesRoles( return roles.some((role) => memberRoleIds.includes(role)); } +function hasGuildConstraint(match: { guildId?: string | undefined } | undefined): boolean { + return Boolean(normalizeId(match?.guildId)); +} + +function hasTeamConstraint(match: { teamId?: string | undefined } | undefined): boolean { + return Boolean(normalizeId(match?.teamId)); +} + +function hasRolesConstraint(match: { roles?: string[] | undefined } | undefined): boolean { + return Array.isArray(match?.roles) && match.roles.length > 0; +} + +function matchesOptionalPeer( + match: { peer?: { kind?: string; id?: string } | undefined } | undefined, + peer: RoutePeer | null, +): boolean { + if (!match?.peer) { + return true; + } + if (!peer) { + return false; + } + return matchesPeer(match, peer); +} + +function matchesOptionalGuild( + match: { guildId?: string | undefined } | undefined, + guildId: string, +): boolean { + const requiredGuildId = normalizeId(match?.guildId); + if (!requiredGuildId) { + return true; + } + if (!guildId) { + return false; + } + return requiredGuildId === guildId; +} + +function matchesOptionalTeam( + match: { teamId?: string | undefined } | undefined, + teamId: string, +): boolean { + const requiredTeamId = normalizeId(match?.teamId); + if (!requiredTeamId) { + return true; + } + if (!teamId) { + return false; + } + return requiredTeamId === teamId; +} + +function matchesOptionalRoles( + match: { roles?: string[] | undefined } | undefined, + memberRoleIds: string[], +): boolean { + if (!hasRolesConstraint(match)) { + return true; + } + return matchesRoles(match, memberRoleIds); +} + +function matchesBindingScope(params: { + match: + | { + peer?: { kind?: string; id?: string } | undefined; + guildId?: string | undefined; + teamId?: string | undefined; + roles?: string[] | undefined; + } + | undefined; + peer: RoutePeer | null; + guildId: string; + teamId: string; + memberRoleIds: string[]; +}): boolean { + return ( + matchesOptionalPeer(params.match, params.peer) && + matchesOptionalGuild(params.match, params.guildId) && + matchesOptionalTeam(params.match, params.teamId) && + matchesOptionalRoles(params.match, params.memberRoleIds) + ); +} + export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); @@ -228,7 +294,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR }; if (peer) { - const peerMatch = bindings.find((b) => matchesPeer(b.match, peer)); + const peerMatch = bindings.find( + (b) => + Boolean(b.match?.peer) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), + ); if (peerMatch) { return choose(peerMatch.agentId, "binding.peer"); } @@ -239,7 +315,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } : null; if (parentPeer && parentPeer.id) { - const parentPeerMatch = bindings.find((b) => matchesPeer(b.match, parentPeer)); + const parentPeerMatch = bindings.find( + (b) => + Boolean(b.match?.peer) && + matchesBindingScope({ + match: b.match, + peer: parentPeer, + guildId, + teamId, + memberRoleIds, + }), + ); if (parentPeerMatch) { return choose(parentPeerMatch.agentId, "binding.peer.parent"); } @@ -247,7 +333,16 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (guildId && memberRoleIds.length > 0) { const guildRolesMatch = bindings.find( - (b) => matchesGuild(b.match, guildId) && matchesRoles(b.match, memberRoleIds), + (b) => + hasGuildConstraint(b.match) && + hasRolesConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (guildRolesMatch) { return choose(guildRolesMatch.agentId, "binding.guild+roles"); @@ -257,8 +352,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (guildId) { const guildMatch = bindings.find( (b) => - matchesGuild(b.match, guildId) && - (!Array.isArray(b.match?.roles) || b.match.roles.length === 0), + hasGuildConstraint(b.match) && + !hasRolesConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (guildMatch) { return choose(guildMatch.agentId, "binding.guild"); @@ -266,7 +368,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } if (teamId) { - const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId)); + const teamMatch = bindings.find( + (b) => + hasTeamConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), + ); if (teamMatch) { return choose(teamMatch.agentId, "binding.team"); } @@ -274,7 +386,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const accountMatch = bindings.find( (b) => - b.match?.accountId?.trim() !== "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId, + b.match?.accountId?.trim() !== "*" && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (accountMatch) { return choose(accountMatch.agentId, "binding.account"); @@ -282,7 +401,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const anyAccountMatch = bindings.find( (b) => - b.match?.accountId?.trim() === "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId, + b.match?.accountId?.trim() === "*" && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (anyAccountMatch) { return choose(anyAccountMatch.agentId, "binding.channel"); diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index a93a1e41b66..b73a3292684 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -436,6 +436,41 @@ describe("sendMessageTelegram", () => { sendVoice: typeof sendVoice; }; + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + contentType: "audio/wav", + fileName: "clip.wav", + }); + + await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/clip.wav", + asVoice: true, + }); + + expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + parse_mode: "HTML", + }); + expect(sendVoice).not.toHaveBeenCalled(); + }); + + it("sends MP3 as voice when asVoice is true", async () => { + const chatId = "123"; + const sendAudio = vi.fn().mockResolvedValue({ + message_id: 16, + chat: { id: chatId }, + }); + const sendVoice = vi.fn().mockResolvedValue({ + message_id: 17, + chat: { id: chatId }, + }); + const api = { sendAudio, sendVoice } as unknown as { + sendAudio: typeof sendAudio; + sendVoice: typeof sendVoice; + }; + loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("audio"), contentType: "audio/mpeg", @@ -449,11 +484,11 @@ describe("sendMessageTelegram", () => { asVoice: true, }); - expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), { + expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); - expect(sendVoice).not.toHaveBeenCalled(); + expect(sendAudio).not.toHaveBeenCalled(); }); it("includes message_thread_id for forum topic messages", async () => { diff --git a/src/telegram/voice.test.ts b/src/telegram/voice.test.ts index e2d96a971bc..bcae3b0f33d 100644 --- a/src/telegram/voice.test.ts +++ b/src/telegram/voice.test.ts @@ -18,13 +18,13 @@ describe("resolveTelegramVoiceSend", () => { const logFallback = vi.fn(); const result = resolveTelegramVoiceSend({ wantsVoice: true, - contentType: "audio/mpeg", - fileName: "track.mp3", + contentType: "audio/wav", + fileName: "track.wav", logFallback, }); expect(result.useVoice).toBe(false); expect(logFallback).toHaveBeenCalledWith( - "Telegram voice requested but media is audio/mpeg (track.mp3); sending as audio file instead.", + "Telegram voice requested but media is audio/wav (track.wav); sending as audio file instead.", ); }); @@ -39,4 +39,19 @@ describe("resolveTelegramVoiceSend", () => { expect(result.useVoice).toBe(true); expect(logFallback).not.toHaveBeenCalled(); }); + + it.each([ + { contentType: "audio/mpeg", fileName: "track.mp3" }, + { contentType: "audio/mp4", fileName: "track.m4a" }, + ])("keeps voice for compatible MIME $contentType", ({ contentType, fileName }) => { + const logFallback = vi.fn(); + const result = resolveTelegramVoiceSend({ + wantsVoice: true, + contentType, + fileName, + logFallback, + }); + expect(result.useVoice).toBe(true); + expect(logFallback).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/theme/theme.test.ts b/src/tui/theme/theme.test.ts index ac3ebd304c2..3e9f91b4007 100644 --- a/src/tui/theme/theme.test.ts +++ b/src/tui/theme/theme.test.ts @@ -3,109 +3,49 @@ import { markdownTheme } from "./theme.js"; describe("markdownTheme", () => { describe("highlightCode", () => { - it("should return an array of lines for JavaScript code", () => { + it("returns highlighted lines for common language inputs", () => { const code = `const x = 42;`; - const result = markdownTheme.highlightCode!(code, "javascript"); - - expect(result).toBeInstanceOf(Array); - expect(result).toHaveLength(1); - // Result should contain the original code (possibly with ANSI codes) - expect(result[0]).toContain("const"); - expect(result[0]).toContain("42"); - }); - - it("should return correct line count for multi-line code", () => { - const code = `function greet(name: string) { + const js = markdownTheme.highlightCode!(code, "javascript"); + const ts = markdownTheme.highlightCode!( + `function greet(name: string) { return "Hello, " + name; -}`; - const result = markdownTheme.highlightCode!(code, "typescript"); +}`, + "typescript", + ); - expect(result).toHaveLength(3); - expect(result[0]).toContain("function"); - expect(result[1]).toContain("return"); - expect(result[2]).toContain("}"); + expect(js).toBeInstanceOf(Array); + expect(js).toHaveLength(1); + expect(js[0]).toContain("const"); + expect(js[0]).toContain("42"); + expect(ts).toHaveLength(3); + expect(ts[0]).toContain("function"); + expect(ts[1]).toContain("return"); + expect(ts[2]).toContain("}"); }); - it("should handle Python code", () => { - const code = `def hello(): - print("world")`; - const result = markdownTheme.highlightCode!(code, "python"); - - expect(result).toHaveLength(2); - expect(result[0]).toContain("def"); - expect(result[1]).toContain("print"); - }); - - it("should handle unknown languages gracefully", () => { - const code = `const x = 42;`; - const result = markdownTheme.highlightCode!(code, "not-a-real-language"); - - expect(result).toBeInstanceOf(Array); - expect(result).toHaveLength(1); - // Should still return the code content - expect(result[0]).toContain("const"); - }); - - it("should handle code without language specifier", () => { + it("handles unknown and missing language without throwing", () => { const code = `echo "hello"`; - const result = markdownTheme.highlightCode!(code, undefined); - - expect(result).toBeInstanceOf(Array); - expect(result).toHaveLength(1); - expect(result[0]).toContain("echo"); + const unknown = markdownTheme.highlightCode!(code, "not-a-real-language"); + const missing = markdownTheme.highlightCode!(code, undefined); + expect(unknown).toBeInstanceOf(Array); + expect(missing).toBeInstanceOf(Array); + expect(unknown).toHaveLength(1); + expect(missing).toHaveLength(1); + expect(unknown[0]).toContain("echo"); + expect(missing[0]).toContain("echo"); }); - it("should handle empty code", () => { - const result = markdownTheme.highlightCode!("", "javascript"); - - expect(result).toBeInstanceOf(Array); - expect(result).toHaveLength(1); - expect(result[0]).toBe(""); - }); - - it("should handle bash/shell code", () => { - const code = `#!/bin/bash -echo "Hello" -for i in {1..5}; do - echo $i -done`; - const result = markdownTheme.highlightCode!(code, "bash"); - - expect(result).toHaveLength(5); - expect(result[0]).toContain("#!/bin/bash"); - expect(result[1]).toContain("echo"); - }); - - it("should handle JSON", () => { - const code = `{"name": "test", "count": 42, "active": true}`; - const result = markdownTheme.highlightCode!(code, "json"); - - expect(result).toHaveLength(1); - expect(result[0]).toContain("name"); - expect(result[0]).toContain("42"); - }); - - it("should handle code with special characters", () => { - const code = `const regex = /\\d+/g; -const str = "Hello\\nWorld";`; - const result = markdownTheme.highlightCode!(code, "javascript"); - - expect(result).toHaveLength(2); - // Should not throw and should return valid output - expect(result[0].length).toBeGreaterThan(0); - expect(result[1].length).toBeGreaterThan(0); - }); - - it("should preserve code content through highlighting", () => { + it("preserves code content and handles empty input", () => { const code = `const message = "Hello, World!"; console.log(message);`; const result = markdownTheme.highlightCode!(code, "javascript"); + const empty = markdownTheme.highlightCode!("", "javascript"); - // Strip ANSI codes to verify content is preserved const stripAnsi = (str: string) => str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); expect(stripAnsi(result[0])).toBe(`const message = "Hello, World!";`); expect(stripAnsi(result[1])).toBe("console.log(message);"); + expect(empty).toEqual([""]); }); }); }); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index eb23d887b02..382a54a1463 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -107,7 +107,7 @@ describe("web inbound media saves with extension", () => { await fs.rm(HOME, { recursive: true, force: true }); }); - it("stores inbound image with jpeg extension", async () => { + it("stores image extension, extracts caption mentions, and keeps document filename", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const { createWaSocket } = await import("./session.js"); @@ -117,7 +117,7 @@ describe("web inbound media saves with extension", () => { }> )(); - const upsert = { + realSock.ev.emit("messages.upsert", { type: "notify", messages: [ { @@ -126,31 +126,17 @@ describe("web inbound media saves with extension", () => { messageTimestamp: 1_700_000_001, }, ], - }; + }); - realSock.ev.emit("messages.upsert", upsert); - - const msg = await waitForMessage(onMessage); - const mediaPath = msg.mediaPath; + const first = await waitForMessage(onMessage); + const mediaPath = first.mediaPath; expect(mediaPath).toBeDefined(); expect(path.extname(mediaPath as string)).toBe(".jpg"); const stat = await fs.stat(mediaPath as string); expect(stat.size).toBeGreaterThan(0); - await listener.close(); - }); - - it("extracts mentions from media captions", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const { createWaSocket } = await import("./session.js"); - const realSock = await ( - createWaSocket as unknown as () => Promise<{ - ev: import("node:events").EventEmitter; - }> - )(); - - const upsert = { + onMessage.mockClear(); + realSock.ev.emit("messages.upsert", { type: "notify", messages: [ { @@ -171,13 +157,30 @@ describe("web inbound media saves with extension", () => { messageTimestamp: 1_700_000_002, }, ], - }; + }); - realSock.ev.emit("messages.upsert", upsert); + const second = await waitForMessage(onMessage); + expect(second.chatType).toBe("group"); + expect(second.mentionedJids).toEqual(["999@s.whatsapp.net"]); - const msg = await waitForMessage(onMessage); - expect(msg.chatType).toBe("group"); - expect(msg.mentionedJids).toEqual(["999@s.whatsapp.net"]); + onMessage.mockClear(); + const fileName = "invoice.pdf"; + realSock.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { id: "doc1", fromMe: false, remoteJid: "333@s.whatsapp.net" }, + message: { documentMessage: { mimetype: "application/pdf", fileName } }, + messageTimestamp: 1_700_000_004, + }, + ], + }); + + const third = await waitForMessage(onMessage); + expect(third.mediaFileName).toBe(fileName); + expect(saveMediaBufferSpy).toHaveBeenCalled(); + const lastCall = saveMediaBufferSpy.mock.calls.at(-1); + expect(lastCall?.[4]).toBe(fileName); await listener.close(); }); @@ -216,37 +219,4 @@ describe("web inbound media saves with extension", () => { await listener.close(); }); - - it("passes document filenames to saveMediaBuffer", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const { createWaSocket } = await import("./session.js"); - const realSock = await ( - createWaSocket as unknown as () => Promise<{ - ev: import("node:events").EventEmitter; - }> - )(); - - const fileName = "invoice.pdf"; - const upsert = { - type: "notify", - messages: [ - { - key: { id: "doc1", fromMe: false, remoteJid: "333@s.whatsapp.net" }, - message: { documentMessage: { mimetype: "application/pdf", fileName } }, - messageTimestamp: 1_700_000_004, - }, - ], - }; - - realSock.ev.emit("messages.upsert", upsert); - - const msg = await waitForMessage(onMessage); - expect(msg.mediaFileName).toBe(fileName); - expect(saveMediaBufferSpy).toHaveBeenCalled(); - const lastCall = saveMediaBufferSpy.mock.calls.at(-1); - expect(lastCall?.[4]).toBe(fileName); - - await listener.close(); - }); }); diff --git a/src/web/media.test.ts b/src/web/media.test.ts index a1b6422bfc9..3305e764fc0 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -40,8 +40,8 @@ beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-")); largeJpegBuffer = await sharp({ create: { - width: 1200, - height: 1200, + width: 900, + height: 900, channels: 3, background: "#ff0000", }, @@ -138,24 +138,6 @@ describe("web media loading", () => { expect(result.contentType).toBe("image/jpeg"); }); - it("adds extension to URL fileName when missing", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - body: true, - arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, - headers: { get: () => "application/pdf" }, - status: 200, - } as Response); - - const result = await loadWebMedia("https://example.com/download", 1024 * 1024); - - expect(result.kind).toBe("document"); - expect(result.contentType).toBe("application/pdf"); - expect(result.fileName).toBe("download.pdf"); - - fetchMock.mockRestore(); - }); - it("includes URL + status in fetch errors", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, @@ -217,50 +199,6 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); - it("preserves GIF animation by skipping JPEG optimization", async () => { - // Create a minimal valid GIF (1x1 pixel) - // GIF89a header + minimal image data - const gifBuffer = Buffer.from([ - 0x47, - 0x49, - 0x46, - 0x38, - 0x39, - 0x61, // GIF89a - 0x01, - 0x00, - 0x01, - 0x00, // 1x1 dimensions - 0x00, - 0x00, - 0x00, // no global color table - 0x2c, - 0x00, - 0x00, - 0x00, - 0x00, // image descriptor - 0x01, - 0x00, - 0x01, - 0x00, - 0x00, // 1x1 image - 0x02, - 0x01, - 0x44, - 0x00, - 0x3b, // minimal LZW data + trailer - ]); - - const file = await writeTempFile(gifBuffer, ".gif"); - - const result = await loadWebMedia(file, 1024 * 1024); - - expect(result.kind).toBe("image"); - expect(result.contentType).toBe("image/gif"); - // GIF should NOT be converted to JPEG - expect(result.buffer.slice(0, 3).toString()).toBe("GIF"); - }); - it("preserves GIF from URL without JPEG conversion", async () => { const gifBytes = new Uint8Array([ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00,