feat: add message tool and CLI

This commit is contained in:
Peter Steinberger
2026-01-09 06:43:40 +01:00
parent 48a1b07097
commit db22207014
25 changed files with 763 additions and 437 deletions

View File

@@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js";
import { messagePollCommand, messageSendCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
@@ -51,10 +51,10 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
...overrides,
});
describe("sendCommand", () => {
describe("messageSendCommand", () => {
it("skips send on dry-run", async () => {
const deps = makeDeps();
await sendCommand(
await messageSendCommand(
{
to: "+1",
message: "hi",
@@ -69,7 +69,7 @@ describe("sendCommand", () => {
it("sends via gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
await messageSendCommand(
{
to: "+1",
message: "hi",
@@ -87,7 +87,7 @@ describe("sendCommand", () => {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
const deps = makeDeps();
await sendCommand(
await messageSendCommand(
{
to: "+1",
message: "hi",
@@ -105,7 +105,7 @@ describe("sendCommand", () => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
await messageSendCommand(
{
to: "+1",
message: "hi",
@@ -129,7 +129,7 @@ describe("sendCommand", () => {
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
testConfig = { telegram: { botToken: "token-abc" } };
await sendCommand(
await messageSendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
@@ -150,7 +150,7 @@ describe("sendCommand", () => {
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
await sendCommand(
await messageSendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
@@ -168,7 +168,7 @@ describe("sendCommand", () => {
.fn()
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
});
await sendCommand(
await messageSendCommand(
{ to: "channel:chan", message: "hi", provider: "discord" },
deps,
runtime,
@@ -185,7 +185,7 @@ describe("sendCommand", () => {
const deps = makeDeps({
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
});
await sendCommand(
await messageSendCommand(
{ to: "+15551234567", message: "hi", provider: "signal" },
deps,
runtime,
@@ -204,7 +204,7 @@ describe("sendCommand", () => {
.fn()
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
});
await sendCommand(
await messageSendCommand(
{ to: "channel:C123", message: "hi", provider: "slack" },
deps,
runtime,
@@ -221,7 +221,7 @@ describe("sendCommand", () => {
const deps = makeDeps({
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
});
await sendCommand(
await messageSendCommand(
{ to: "chat_id:42", message: "hi", provider: "imessage" },
deps,
runtime,
@@ -237,7 +237,7 @@ describe("sendCommand", () => {
it("emits json output", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
const deps = makeDeps();
await sendCommand(
await messageSendCommand(
{
to: "+1",
message: "hi",
@@ -251,3 +251,88 @@ describe("sendCommand", () => {
);
});
});
describe("messagePollCommand", () => {
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
beforeEach(() => {
callGatewayMock.mockReset();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
testConfig = {};
});
it("routes through gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
await messagePollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "poll" }),
);
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
await messagePollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("emits json output with gateway metadata", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" });
await messagePollCommand(
{
to: "channel:C1",
question: "hi?",
option: ["y", "n"],
provider: "discord",
json: true,
},
deps,
runtime,
);
const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined;
expect(lastLog).toBeDefined();
const payload = JSON.parse(lastLog ?? "{}") as Record<string, unknown>;
expect(payload).toMatchObject({
provider: "discord",
via: "gateway",
to: "channel:C1",
messageId: "p1",
channelId: "C1",
mediaUrl: null,
question: "hi?",
options: ["y", "n"],
maxSelections: 1,
durationHours: null,
});
});
});

240
src/commands/message.ts Normal file
View File

@@ -0,0 +1,240 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { success } from "../globals.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import {
sendMessage,
sendPoll,
type MessagePollResult,
type MessageSendResult,
} from "../infra/outbound/message.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
type MessageSendOpts = {
to: string;
message: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
media?: string;
gifPlayback?: boolean;
account?: string;
};
type MessagePollOpts = {
to: string;
question: string;
option: string[];
maxSelections?: string;
durationHours?: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
};
function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${label} must be a number`);
}
return parsed;
}
function logSendDryRun(opts: MessageSendOpts, provider: string, runtime: RuntimeEnv) {
runtime.log(
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${
opts.media ? ` (media ${opts.media})` : ""
}`,
);
}
function logPollDryRun(
result: MessagePollResult,
runtime: RuntimeEnv,
) {
runtime.log(
`[dry-run] would send poll via ${result.provider} -> ${result.to}:\n Question: ${result.question}\n Options: ${result.options.join(
", ",
)}\n Max selections: ${result.maxSelections}`,
);
}
function logSendResult(
result: MessageSendResult,
opts: MessageSendOpts,
runtime: RuntimeEnv,
) {
if (result.via === "direct") {
const summary = formatOutboundDeliverySummary(
result.provider,
result.result,
);
runtime.log(success(summary));
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundDeliveryJson({
provider: result.provider,
via: "direct",
to: opts.to,
result: result.result,
mediaUrl: opts.media ?? null,
}),
null,
2,
),
);
}
return;
}
const gatewayResult = result.result as { messageId?: string } | undefined;
runtime.log(
success(
formatGatewaySummary({
provider: result.provider,
messageId: gatewayResult?.messageId ?? null,
}),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider: result.provider,
via: "gateway",
to: opts.to,
result: gatewayResult,
mediaUrl: opts.media ?? null,
}),
}),
null,
2,
),
);
}
}
export async function messageSendCommand(
opts: MessageSendOpts,
deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
if (opts.dryRun) {
logSendDryRun(opts, provider, runtime);
return;
}
const result = await withProgress(
{
label: `Sending via ${provider}...`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await sendMessage({
cfg: loadConfig(),
to: opts.to,
content: opts.message,
provider,
mediaUrl: opts.media,
gifPlayback: opts.gifPlayback,
accountId: opts.account,
dryRun: opts.dryRun,
deps,
gateway: { clientName: "cli", mode: "cli" },
}),
);
logSendResult(result, opts, runtime);
}
export async function messagePollCommand(
opts: MessagePollOpts,
_deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = (opts.provider ?? "whatsapp").toLowerCase();
const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
const durationHours = parseIntOption(opts.durationHours, "duration-hours");
if (opts.dryRun) {
const result = await sendPoll({
cfg: loadConfig(),
to: opts.to,
question: opts.question,
options: opts.option,
maxSelections,
durationHours,
provider,
dryRun: true,
gateway: { clientName: "cli", mode: "cli" },
});
logPollDryRun(result, runtime);
return;
}
const result = await withProgress(
{
label: `Sending poll via ${provider}...`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await sendPoll({
cfg: loadConfig(),
to: opts.to,
question: opts.question,
options: opts.option,
maxSelections,
durationHours,
provider,
dryRun: opts.dryRun,
gateway: { clientName: "cli", mode: "cli" },
}),
);
runtime.log(
success(
formatGatewaySummary({
action: "Poll sent",
provider,
messageId: result.result?.messageId ?? null,
}),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
...buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result: result.result,
mediaUrl: null,
}),
}),
question: result.question,
options: result.options,
maxSelections: result.maxSelections,
durationHours: result.durationHours,
},
null,
2,
),
);
}
}

View File

@@ -1,110 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import { pollCommand } from "./poll.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
describe("pollCommand", () => {
beforeEach(() => {
callGatewayMock.mockReset();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
testConfig = {};
});
it("routes through gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "poll" }),
);
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("emits json output with gateway metadata", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" });
await pollCommand(
{
to: "channel:C1",
question: "hi?",
option: ["y", "n"],
provider: "discord",
json: true,
},
deps,
runtime,
);
const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined;
expect(lastLog).toBeDefined();
const payload = JSON.parse(lastLog ?? "{}") as Record<string, unknown>;
expect(payload).toMatchObject({
provider: "discord",
via: "gateway",
to: "channel:C1",
messageId: "p1",
channelId: "C1",
mediaUrl: null,
question: "hi?",
options: ["y", "n"],
maxSelections: 1,
durationHours: null,
});
});
});

View File

@@ -1,121 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
} from "../infra/outbound/format.js";
import { normalizePollInput, type PollInput } from "../polls.js";
import type { RuntimeEnv } from "../runtime.js";
function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${label} must be a number`);
}
return parsed;
}
export async function pollCommand(
opts: {
to: string;
question: string;
option: string[];
maxSelections?: string;
durationHours?: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
},
_deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = (opts.provider ?? "whatsapp").toLowerCase();
if (provider !== "whatsapp" && provider !== "discord") {
throw new Error(`Unsupported poll provider: ${provider}`);
}
const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
const durationHours = parseIntOption(opts.durationHours, "duration-hours");
const pollInput: PollInput = {
question: opts.question,
options: opts.option,
maxSelections,
durationHours,
};
const maxOptions = provider === "discord" ? 10 : 12;
const normalized = normalizePollInput(pollInput, { maxOptions });
if (opts.dryRun) {
runtime.log(
`[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`,
);
return;
}
const result = await withProgress(
{
label: `Sending poll via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<{
messageId: string;
toJid?: string;
channelId?: string;
}>({
method: "poll",
params: {
to: opts.to,
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
}),
);
runtime.log(
success(
formatGatewaySummary({
action: "Poll sent",
provider,
messageId: result.messageId ?? null,
}),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
...buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: null,
}),
}),
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours ?? null,
},
null,
2,
),
);
}
}

View File

@@ -1,148 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
export async function sendCommand(
opts: {
to: string;
message: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
media?: string;
gifPlayback?: boolean;
account?: string;
},
deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
);
return;
}
if (
provider === "telegram" ||
provider === "discord" ||
provider === "slack" ||
provider === "signal" ||
provider === "imessage"
) {
const resolvedTarget = resolveOutboundTarget({
provider,
to: opts.to,
});
if (!resolvedTarget.ok) {
throw resolvedTarget.error;
}
const results = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await deliverOutboundPayloads({
cfg: loadConfig(),
provider,
to: resolvedTarget.to,
payloads: [{ text: opts.message, mediaUrl: opts.media }],
deps: {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
sendDiscord: deps.sendMessageDiscord,
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
},
}),
);
const last = results.at(-1);
const summary = formatOutboundDeliverySummary(provider, last);
runtime.log(success(summary));
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundDeliveryJson({
provider,
via: "direct",
to: opts.to,
result: last,
mediaUrl: opts.media,
}),
null,
2,
),
);
}
return;
}
// Always send via gateway over WS to avoid multi-session corruption.
const sendViaGateway = async () =>
callGateway<{
messageId: string;
}>({
method: "send",
params: {
to: opts.to,
message: opts.message,
mediaUrl: opts.media,
gifPlayback: opts.gifPlayback,
accountId: opts.account,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
});
const result = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () => await sendViaGateway(),
);
runtime.log(
success(
formatGatewaySummary({ provider, messageId: result.messageId ?? null }),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: opts.media ?? null,
}),
}),
null,
2,
),
);
}
}