mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
merge latest main into fix/config-schema-key-14998
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, never>;
|
||||
};
|
||||
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<string, never>;
|
||||
};
|
||||
expect(mediaContent.msgtype).toBe("m.audio");
|
||||
expect(mediaContent.body).toBe("voice caption");
|
||||
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix threads", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,7 +407,7 @@ function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawCon
|
||||
...cfg.plugins?.entries,
|
||||
[pluginId]: {
|
||||
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | 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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
43
src/media/audio.test.ts
Normal file
43
src/media/audio.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([""]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user