Skip to content

Commit cf9bc0c

Browse files
committed
fix(cli): address --message-file review findings
1 parent aef209a commit cf9bc0c

3 files changed

Lines changed: 49 additions & 16 deletions

File tree

docs/cli/message.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ Name lookup:
6868

6969
- `send`
7070
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Matrix/Microsoft Teams
71-
- Required: `--target`, plus `--message`, `--media`, or `--presentation`
72-
- Optional: `--media`, `--presentation`, `--delivery`, `--pin`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
71+
- Required: `--target`, plus `--message` or `--message-file`, `--media`, or `--presentation`
72+
- Optional: `--message-file <path>` (read message body from a file; mutually exclusive with `--message`), `--media`, `--presentation`, `--delivery`, `--pin`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
7373
- Shared presentation payloads: `--presentation` sends semantic blocks (`text`, `context`, `divider`, `buttons`, `select`) that core renders through the selected channel's declared capabilities. See [Message Presentation](/plugins/message-presentation).
7474
- Generic delivery preferences: `--delivery` accepts delivery hints such as `{ "pin": true }`; `--pin` is shorthand for pinned delivery when the channel supports it.
7575
- Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression)

src/infra/outbound/message-action-runner.send-validation.test.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import type { OpenClawConfig } from "../../config/config.js";
66
import { setActivePluginRegistry } from "../../plugins/runtime.js";
77
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -12,6 +12,20 @@ import {
1212
workspaceTestPlugin,
1313
} from "./message-action-runner.test-helpers.js";
1414

15+
// Spy on executeSendAction to capture the final message text that reaches the send layer.
16+
// Called through to the real implementation so all other test behaviour is unchanged.
17+
let _lastSendMessage: string | undefined;
18+
vi.mock("./outbound-send-service.js", async (importOriginal) => {
19+
const real = await importOriginal<typeof import("./outbound-send-service.js")>();
20+
return {
21+
...real,
22+
executeSendAction: vi.fn(async (...args: Parameters<typeof real.executeSendAction>) => {
23+
_lastSendMessage = args[0].message;
24+
return real.executeSendAction(...args);
25+
}),
26+
};
27+
});
28+
1529
describe("runMessageAction send validation", () => {
1630
beforeEach(() => {
1731
setActivePluginRegistry(
@@ -164,18 +178,20 @@ describe("runMessageAction send --message-file", () => {
164178
expect(result.kind).toBe("send");
165179
});
166180

167-
it("preserves multiline content and special characters without throwing", async () => {
181+
it("preserves file content exactly — no trim and no literal-backslash-n expansion", async () => {
182+
_lastSendMessage = undefined;
168183
const filePath = path.join(tmpDir, "report.txt");
169-
await fs.writeFile(
170-
filePath,
171-
"line 1\nline 2\n```code block```\n$VAR {interpolation} `backtick`",
172-
"utf8",
173-
);
184+
// Leading spaces prove no trimming; literal \n (backslash+n) proves no \\n→\n expansion.
185+
// Inline --message would both trim and expand these, corrupting JSON or indented content.
186+
await fs.writeFile(filePath, " hello\\nworld", "utf8");
174187
const result = await runDrySend({
175188
cfg: workspaceConfig,
176189
actionParams: { channel: "workspace", target: "#C12345678", messageFile: filePath },
177190
});
178191
expect(result.kind).toBe("send");
192+
expect(_lastSendMessage).toContain(" hello");
193+
expect(_lastSendMessage).toContain("hello\\nworld");
194+
expect(_lastSendMessage).not.toContain("hello\nworld");
179195
});
180196

181197
it("rejects when both --message and --message-file are provided", async () => {
@@ -194,6 +210,22 @@ describe("runMessageAction send --message-file", () => {
194210
).rejects.toThrow(/use --message or --message-file, not both/i);
195211
});
196212

213+
it("rejects when --message-file is combined with an empty --message", async () => {
214+
const filePath = path.join(tmpDir, "msg.txt");
215+
await fs.writeFile(filePath, "from file", "utf8");
216+
await expect(
217+
runDrySend({
218+
cfg: workspaceConfig,
219+
actionParams: {
220+
channel: "workspace",
221+
target: "#C12345678",
222+
message: "",
223+
messageFile: filePath,
224+
},
225+
}),
226+
).rejects.toThrow(/use --message or --message-file, not both/i);
227+
});
228+
197229
it("throws a clear error when the file does not exist", async () => {
198230
await expect(
199231
runDrySend({

src/infra/outbound/message-action-runner.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -623,13 +623,13 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
623623
const hasInteractive = hasInteractiveReplyBlocks(params.interactive);
624624
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
625625
const messageFile = readStringParam(params, "messageFile", { trim: false });
626-
if (messageFile !== null && messageFile !== undefined && messageFile !== "") {
627-
if (readStringParam(params, "message", { allowEmpty: true })) {
626+
let fileMessage: string | null = null;
627+
if (messageFile) {
628+
if (params.message !== undefined) {
628629
throw new Error("use --message or --message-file, not both");
629630
}
630-
let fileContent: string;
631631
try {
632-
fileContent = await fs.readFile(messageFile, "utf8");
632+
fileMessage = await fs.readFile(messageFile, "utf8");
633633
} catch (err) {
634634
const code = (err as NodeJS.ErrnoException).code;
635635
if (code === "ENOENT") {
@@ -640,14 +640,15 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
640640
}
641641
throw new Error(`failed to read message file: ${messageFile}`, { cause: err });
642642
}
643-
params.message = fileContent;
644643
}
645644
let message =
645+
fileMessage ??
646646
readStringParam(params, "message", {
647647
required: !mediaHint && !hasPresentation && !hasInteractive,
648648
allowEmpty: true,
649-
}) ?? "";
650-
if (message.includes("\\n")) {
649+
}) ??
650+
"";
651+
if (fileMessage === null && message.includes("\\n")) {
651652
message = message.replaceAll("\\n", "\n");
652653
}
653654
if (!message.trim() && caption.trim()) {

0 commit comments

Comments
 (0)