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

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,
),
);
}
}