feat: unify message cli and tools

This commit is contained in:
Peter Steinberger
2026-01-09 08:27:17 +01:00
parent 77d4bb8dfe
commit 721183e259
27 changed files with 2391 additions and 776 deletions

View File

@@ -4,7 +4,6 @@ import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
@@ -13,9 +12,6 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSlackTool } from "./tools/slack-tool.js";
import { createTelegramTool } from "./tools/telegram-tool.js";
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -35,14 +31,10 @@ export function createClawdbotTools(options?: {
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createMessageTool(),
createSlackTool({
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,

View File

@@ -141,36 +141,13 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("scopes discord tool to discord provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
});
it("scopes slack tool to slack provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false);
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
});
it("scopes telegram tool to telegram provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "telegram")).toBe(false);
const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
});
it("scopes whatsapp tool to whatsapp provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "slack" });
expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {

View File

@@ -613,37 +613,6 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
function normalizeMessageProvider(
messageProvider?: string,
): string | undefined {
const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
function shouldIncludeSlackTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:");
}
function shouldIncludeTelegramTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "telegram" || normalized.startsWith("telegram:");
}
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
}
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
@@ -724,20 +693,9 @@ export function createClawdbotCodingTools(options?: {
config: options?.config,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack;
if (tool.name === "telegram") return allowTelegram;
if (tool.name === "whatsapp") return allowWhatsApp;
return true;
});
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(filtered, effectiveToolsPolicy)
: filtered;
? filterToolsByPolicy(tools, effectiveToolsPolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;

View File

@@ -154,8 +154,37 @@
"emoji": "✉️",
"title": "Message",
"actions": {
"send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] },
"poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] }
"send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
"poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
"react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
"read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
"edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
"delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
"pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
"unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
"list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
"thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
"thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
"thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
"search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
"sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
"member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
"role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
"emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
"emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
"sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
"role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
"channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
"voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
"event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
"event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
"timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
}
},
"agents_list": {
@@ -190,77 +219,6 @@
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
},
"slack": {
"emoji": "💬",
"title": "Slack",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
"emojiList": { "label": "emoji list" }
}
},
"telegram": {
"emoji": "✈️",
"title": "Telegram",
"actions": {
"react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
}
},
"whatsapp": {
"emoji": "💬",
"title": "WhatsApp",
"actions": {
"react": {
"label": "react",
"detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
}
}
}
}
}

View File

@@ -1,11 +1,15 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
@@ -13,36 +17,131 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.js";
import { handleDiscordAction } from "./discord-actions.js";
import { handleSlackAction } from "./slack-actions.js";
import { handleTelegramAction } from "./telegram-actions.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js";
const MessageActionSchema = Type.Union([
Type.Literal("send"),
Type.Literal("poll"),
Type.Literal("react"),
Type.Literal("reactions"),
Type.Literal("read"),
Type.Literal("edit"),
Type.Literal("delete"),
Type.Literal("pin"),
Type.Literal("unpin"),
Type.Literal("list-pins"),
Type.Literal("permissions"),
Type.Literal("thread-create"),
Type.Literal("thread-list"),
Type.Literal("thread-reply"),
Type.Literal("search"),
Type.Literal("sticker"),
Type.Literal("member-info"),
Type.Literal("role-info"),
Type.Literal("emoji-list"),
Type.Literal("emoji-upload"),
Type.Literal("sticker-upload"),
Type.Literal("role-add"),
Type.Literal("role-remove"),
Type.Literal("channel-info"),
Type.Literal("channel-list"),
Type.Literal("voice-status"),
Type.Literal("event-list"),
Type.Literal("event-create"),
Type.Literal("timeout"),
Type.Literal("kick"),
Type.Literal("ban"),
]);
const MessageToolSchema = Type.Object({
action: Type.Union([Type.Literal("send"), Type.Literal("poll")]),
to: Type.Optional(Type.String()),
content: Type.Optional(Type.String()),
mediaUrl: Type.Optional(Type.String()),
gifPlayback: Type.Optional(Type.Boolean()),
action: MessageActionSchema,
provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
question: Type.Optional(Type.String()),
options: Type.Optional(Type.Array(Type.String())),
maxSelections: Type.Optional(Type.Number()),
durationHours: Type.Optional(Type.Number()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export function createMessageTool(): AnyAgentTool {
type MessageToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Message",
name: "message",
description:
"Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.",
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).",
parameters: MessageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { required: true });
const providerSelection = await resolveMessageProviderSelection({
cfg,
provider: readStringParam(params, "provider"),
});
const provider = providerSelection.provider;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
@@ -54,13 +153,13 @@ export function createMessageTool(): AnyAgentTool {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
const message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl", { trim: false });
const provider = readStringParam(params, "provider");
const accountId = readStringParam(params, "accountId");
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
@@ -68,12 +167,66 @@ export function createMessageTool(): AnyAgentTool {
? params.bestEffort
: undefined;
if (dryRun) {
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
},
cfg,
);
}
const result: MessageSendResult = await sendMessage({
to,
content,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
@@ -84,32 +237,679 @@ export function createMessageTool(): AnyAgentTool {
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "question", {
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "options", { required: true }) ?? [];
const maxSelections = readNumberParam(params, "maxSelections", {
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
const durationHours = readNumberParam(params, "durationHours", {
integer: true,
});
const provider = readStringParam(params, "provider");
if (dryRun) {
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "poll",
to,
question,
answers: options,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
);
}
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours,
provider: provider || undefined,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const resolveChannelId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
const resolveChatId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove =
typeof params.remove === "boolean" ? params.remove : undefined;
if (provider === "discord") {
return await handleDiscordAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "react",
chatId: resolveChatId("chatId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "whatsapp") {
return await handleWhatsAppAction(
{
action: "react",
chatJid: resolveChatId("chatJid"),
messageId,
emoji,
remove,
participant: readStringParam(params, "participant"),
accountId: accountId ?? undefined,
fromMe:
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
},
cfg,
);
}
throw new Error(`React is not supported for provider ${provider}.`);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(
`Reactions are not supported for provider ${provider}.`,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const around = readStringParam(params, "around");
if (provider === "discord") {
return await handleDiscordAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
around,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Read is not supported for provider ${provider}.`);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Edit is not supported for provider ${provider}.`);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (provider === "discord") {
return await handleDiscordAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Delete is not supported for provider ${provider}.`);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
const channelId = resolveChannelId("channelId");
if (provider === "discord") {
const discordAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleDiscordAction(
{
action: discordAction,
channelId,
messageId,
},
cfg,
);
}
if (provider === "slack") {
const slackAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleSlackAction(
{
action: slackAction,
channelId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Pins are not supported for provider ${provider}.`);
}
if (action === "permissions") {
if (provider !== "discord") {
throw new Error(
`Permissions are only supported for Discord (provider=${provider}).`,
);
}
return await handleDiscordAction(
{
action: "permissions",
channelId: resolveChannelId("channelId"),
},
cfg,
);
}
if (action === "thread-create") {
if (provider !== "discord") {
throw new Error(
`Thread create is only supported for Discord (provider=${provider}).`,
);
}
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true,
});
return await handleDiscordAction(
{
action: "threadCreate",
channelId: resolveChannelId("channelId"),
name,
messageId,
autoArchiveMinutes,
},
cfg,
);
}
if (action === "thread-list") {
if (provider !== "discord") {
throw new Error(
`Thread list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "threadList",
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
if (provider !== "discord") {
throw new Error(
`Thread reply is only supported for Discord (provider=${provider}).`,
);
}
const content = readStringParam(params, "message", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
return await handleDiscordAction(
{
action: "threadReply",
channelId: resolveChannelId("channelId"),
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
if (provider !== "discord") {
throw new Error(
`Search is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const query = readStringParam(params, "query", { required: true });
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "searchMessages",
guildId,
content: query,
channelId,
channelIds,
authorId,
authorIds,
limit,
},
cfg,
);
}
if (action === "sticker") {
if (provider !== "discord") {
throw new Error(
`Sticker send is only supported for Discord (provider=${provider}).`,
);
}
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
const content = readStringParam(params, "message");
return await handleDiscordAction(
{
action: "sticker",
to: readStringParam(params, "to", { required: true }),
stickerIds,
content,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", guildId, userId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Member info is not supported for provider ${provider}.`,
);
}
if (action === "role-info") {
if (provider !== "discord") {
throw new Error(
`Role info is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
}
if (action === "emoji-list") {
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", guildId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Emoji list is not supported for provider ${provider}.`,
);
}
if (action === "emoji-upload") {
if (provider !== "discord") {
throw new Error(
`Emoji upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "emojiName", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(params, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
if (action === "sticker-upload") {
if (provider !== "discord") {
throw new Error(
`Sticker upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "stickerName", { required: true });
const description = readStringParam(params, "stickerDesc", {
required: true,
});
const tags = readStringParam(params, "stickerTags", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
if (provider !== "discord") {
throw new Error(
`Role changes are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const roleId = readStringParam(params, "roleId", { required: true });
const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
return await handleDiscordAction(
{ action: discordAction, guildId, userId, roleId },
cfg,
);
}
if (action === "channel-info") {
if (provider !== "discord") {
throw new Error(
`Channel info is only supported for Discord (provider=${provider}).`,
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", channelId },
cfg,
);
}
if (action === "channel-list") {
if (provider !== "discord") {
throw new Error(
`Channel list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction(
{ action: "channelList", guildId },
cfg,
);
}
if (action === "voice-status") {
if (provider !== "discord") {
throw new Error(
`Voice status is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", guildId, userId },
cfg,
);
}
if (action === "event-list") {
if (provider !== "discord") {
throw new Error(
`Event list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
}
if (action === "event-create") {
if (provider !== "discord") {
throw new Error(
`Event create is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "eventName", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "desc");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityType = readStringParam(params, "eventType");
return await handleDiscordAction(
{
action: "eventCreate",
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
},
cfg,
);
}
if (action === "timeout" || action === "kick" || action === "ban") {
if (provider !== "discord") {
throw new Error(
`Moderation actions are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const durationMinutes = readNumberParam(params, "durationMin", {
integer: true,
});
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const deleteMessageDays = readNumberParam(params, "deleteDays", {
integer: true,
});
const discordAction = action as "timeout" | "kick" | "ban";
return await handleDiscordAction(
{
action: discordAction,
guildId,
userId,
durationMinutes,
until,
reason,
deleteMessageDays,
},
cfg,
);
}
throw new Error(`Unknown action: ${action}`);
},
};

View File

@@ -91,9 +91,11 @@ export async function handleSlackAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const threadTs = readStringParam(params, "threadTs");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined,
});
return jsonResult({ ok: true, result });
}

View File

@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
threadTs: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({

View File

@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const messageSendCommand = vi.fn();
const messageCommand = vi.fn();
const statusCommand = vi.fn();
const configureCommand = vi.fn();
const setupCommand = vi.fn();
@@ -19,8 +19,7 @@ const runtime = {
};
vi.mock("../commands/message.js", () => ({
messageSendCommand,
messagePollCommand: vi.fn(),
messageCommand,
}));
vi.mock("../commands/status.js", () => ({ statusCommand }));
vi.mock("../commands/configure.js", () => ({ configureCommand }));
@@ -46,15 +45,12 @@ describe("cli program", () => {
vi.clearAllMocks();
});
it("runs message send with required options", async () => {
it("runs message with required options", async () => {
const program = buildProgram();
await program.parseAsync(
["message", "send", "--to", "+1", "--message", "hi"],
{
from: "user",
},
);
expect(messageSendCommand).toHaveBeenCalled();
await program.parseAsync(["message", "--to", "+1", "--message", "hi"], {
from: "user",
});
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {

View File

@@ -8,7 +8,7 @@ import {
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
import { messagePollCommand, messageSendCommand } from "../commands/message.js";
import { messageCommand } from "../commands/message.js";
import { onboardCommand } from "../commands/onboard.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
@@ -408,41 +408,100 @@ export function buildProgram() {
}
});
const message = program
program
.command("message")
.description("Send messages and polls across providers")
.action(() => {
message.outputHelp();
defaultRuntime.error(
danger('Missing subcommand. Try: "clawdbot message send"'),
);
defaultRuntime.exit(1);
});
message
.command("send")
.description(
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
.description("Send messages and provider actions")
.option(
"-a, --action <action>",
"Action: send|poll|react|reactions|read|edit|delete|pin|unpin|list-pins|permissions|thread-create|thread-list|thread-reply|search|sticker|member-info|role-info|emoji-list|emoji-upload|sticker-upload|role-add|role-remove|channel-info|channel-list|voice-status|event-list|event-create|timeout|kick|ban",
"send",
)
.requiredOption(
"-t, --to <number>",
"Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id",
.option(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
)
.requiredOption("-m, --message <text>", "Message body")
.option("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--message-id <id>", "Message id (edit/delete/react/pin)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--account <id>", "Provider account id")
.option(
"--provider <provider>",
"Provider: whatsapp|telegram|discord|slack|signal|imessage",
)
.option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false)
.option("--limit <n>", "Result limit for read/reactions/search")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id (Discord)")
.option("--poll-question <text>", "Poll question")
.option(
"--poll-option <choice>",
"Poll option (repeat 2-12 times)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("--channel-id <id>", "Channel id")
.option(
"--channel-ids <id>",
"Channel id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--guild-id <id>", "Guild id")
.option("--user-id <id>", "User id")
.option("--author-id <id>", "Author id")
.option(
"--author-ids <id>",
"Author id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--role-id <id>", "Role id")
.option(
"--role-ids <id>",
"Role id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--emoji-name <name>", "Emoji name")
.option(
"--sticker-id <id>",
"Sticker id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--sticker-name <name>", "Sticker name")
.option("--sticker-desc <text>", "Sticker description")
.option("--sticker-tags <tags>", "Sticker tags")
.option("--thread-name <name>", "Thread name")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.option("--query <text>", "Search query")
.option("--event-name <name>", "Event name")
.option("--event-type <stage|external|voice>", "Event type")
.option("--start-time <iso>", "Event start time")
.option("--end-time <iso>", "Event end time")
.option("--desc <text>", "Event description")
.option("--location <text>", "Event location")
.option("--duration-min <n>", "Timeout duration minutes")
.option("--until <iso>", "Timeout until")
.option("--reason <text>", "Moderation reason")
.option("--delete-days <n>", "Ban delete message days")
.option("--include-archived", "Include archived threads", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
)
.option("--account <id>", "WhatsApp account id (accountId)")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
@@ -450,16 +509,16 @@ export function buildProgram() {
"after",
`
Examples:
clawdbot message send --to +15555550123 --message "Hi"
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message send --to +15555550123 --message "Hi" --dry-run # print payload only
clawdbot message send --to +15555550123 --message "Hi" --json # machine-readable result`,
clawdbot message --to +15555550123 --message "Hi"
clawdbot message --action send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message --action poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message --action react --provider discord --to 123 --message-id 456 --emoji "✅"`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await messageSendCommand(
await messageCommand(
{
...opts,
account: opts.account as string | undefined,
@@ -473,55 +532,6 @@ Examples:
}
});
message
.command("poll")
.description("Create a poll via WhatsApp or Discord")
.requiredOption(
"-t, --to <id>",
"Recipient: WhatsApp JID/number or Discord channel/user",
)
.requiredOption("-q, --question <text>", "Poll question")
.requiredOption(
"-o, --option <choice>",
"Poll option (use multiple times, 2-12 required)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option(
"-s, --max-selections <n>",
"How many options can be selected (default: 1)",
)
.option(
"--duration-hours <n>",
"Poll duration in hours (Discord only, default: 24)",
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|discord (default: whatsapp)",
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdbot message poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
clawdbot message poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
clawdbot message poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
clawdbot message poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await messagePollCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")

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 { messagePollCommand, messageSendCommand } from "./message.js";
import { messageCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
@@ -19,13 +19,44 @@ vi.mock("../gateway/call.js", () => ({
randomIdempotencyKey: () => "idem-1",
}));
const webAuthExists = vi.fn(async () => false);
vi.mock("../web/session.js", () => ({
webAuthExists: (...args: unknown[]) => webAuthExists(...args),
}));
const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/discord-actions.js", () => ({
handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args),
}));
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
}));
const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/whatsapp-actions.js", () => ({
handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args),
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
process.env.TELEGRAM_BOT_TOKEN = "";
process.env.DISCORD_BOT_TOKEN = "";
testConfig = {};
callGatewayMock.mockReset();
webAuthExists.mockReset().mockResolvedValue(false);
handleDiscordAction.mockReset();
handleSlackAction.mockReset();
handleTelegramAction.mockReset();
handleWhatsAppAction.mockReset();
});
afterAll(() => {
@@ -51,26 +82,44 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
...overrides,
});
describe("messageSendCommand", () => {
it("skips send on dry-run", async () => {
describe("messageCommand", () => {
it("defaults provider when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
await messageSendCommand(
await messageCommand(
{
to: "+1",
to: "123",
message: "hi",
dryRun: true,
},
deps,
runtime,
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
expect(handleTelegramAction).toHaveBeenCalled();
});
it("sends via gateway", async () => {
it("requires provider when multiple configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
const deps = makeDeps();
await expect(
messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
),
).rejects.toThrow(/Provider is required/);
});
it("sends via gateway for WhatsApp", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await messageSendCommand(
await messageCommand(
{
action: "send",
provider: "whatsapp",
to: "+1",
message: "hi",
},
@@ -78,261 +127,27 @@ describe("messageSendCommand", () => {
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1"));
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g2" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
it("routes discord polls through message action", async () => {
const deps = makeDeps();
await messageSendCommand(
await messageCommand(
{
to: "+1",
message: "hi",
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("passes gifPlayback to gateway send", async () => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await messageSendCommand(
{
to: "+1",
message: "hi",
gifPlayback: true,
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("routes to telegram provider", async () => {
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
testConfig = { telegram: { botToken: "token-abc" } };
await messageSendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("uses config token for telegram when env is missing", async () => {
process.env.TELEGRAM_BOT_TOKEN = "";
testConfig = { telegram: { botToken: "cfg-token" } };
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
await messageSendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
});
it("routes to discord provider", async () => {
const deps = makeDeps({
sendMessageDiscord: vi
.fn()
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
});
await messageSendCommand(
{ to: "channel:chan", message: "hi", provider: "discord" },
deps,
runtime,
);
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:chan",
"hi",
expect.objectContaining({ verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to signal provider", async () => {
const deps = makeDeps({
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
});
await messageSendCommand(
{ to: "+15551234567", message: "hi", provider: "signal" },
deps,
runtime,
);
expect(deps.sendMessageSignal).toHaveBeenCalledWith(
"+15551234567",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to slack provider", async () => {
const deps = makeDeps({
sendMessageSlack: vi
.fn()
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
});
await messageSendCommand(
{ to: "channel:C123", message: "hi", provider: "slack" },
deps,
runtime,
);
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ accountId: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to imessage provider", async () => {
const deps = makeDeps({
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
});
await messageSendCommand(
{ to: "chat_id:42", message: "hi", provider: "imessage" },
deps,
runtime,
);
expect(deps.sendMessageIMessage).toHaveBeenCalledWith(
"chat_id:42",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("emits json output", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
const deps = makeDeps();
await messageSendCommand(
{
to: "+1",
message: "hi",
json: true,
},
deps,
runtime,
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining('"provider": "whatsapp"'),
);
});
});
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"],
action: "poll",
provider: "discord",
json: true,
to: "channel:123",
pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"],
},
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,
});
expect(handleDiscordAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
to: "channel:123",
}),
expect.any(Object),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
import { listEnabledIMessageAccounts } from "../../imessage/accounts.js";
import { listEnabledSignalAccounts } from "../../signal/accounts.js";
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
import { normalizeMessageProvider } from "../../utils/message-provider.js";
import {
listEnabledWhatsAppAccounts,
resolveWhatsAppAccount,
} from "../../web/accounts.js";
import { webAuthExists } from "../../web/session.js";
export type MessageProviderId =
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
const MESSAGE_PROVIDERS: MessageProviderId[] = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
];
function isKnownProvider(value: string): value is MessageProviderId {
return (MESSAGE_PROVIDERS as string[]).includes(value);
}
async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise<boolean> {
const accounts = listEnabledWhatsAppAccounts(cfg);
if (accounts.length === 0) {
const fallback = resolveWhatsAppAccount({ cfg });
return await webAuthExists(fallback.authDir);
}
for (const account of accounts) {
if (await webAuthExists(account.authDir)) return true;
}
return false;
}
function isTelegramConfigured(cfg: ClawdbotConfig): boolean {
return listEnabledTelegramAccounts(cfg).some(
(account) => account.token.trim().length > 0,
);
}
function isDiscordConfigured(cfg: ClawdbotConfig): boolean {
return listEnabledDiscordAccounts(cfg).some(
(account) => account.token.trim().length > 0,
);
}
function isSlackConfigured(cfg: ClawdbotConfig): boolean {
return listEnabledSlackAccounts(cfg).some(
(account) => (account.botToken ?? "").trim().length > 0,
);
}
function isSignalConfigured(cfg: ClawdbotConfig): boolean {
return listEnabledSignalAccounts(cfg).some((account) => account.configured);
}
function isIMessageConfigured(cfg: ClawdbotConfig): boolean {
return listEnabledIMessageAccounts(cfg).some((account) => account.configured);
}
export async function listConfiguredMessageProviders(
cfg: ClawdbotConfig,
): Promise<MessageProviderId[]> {
const providers: MessageProviderId[] = [];
if (await isWhatsAppConfigured(cfg)) providers.push("whatsapp");
if (isTelegramConfigured(cfg)) providers.push("telegram");
if (isDiscordConfigured(cfg)) providers.push("discord");
if (isSlackConfigured(cfg)) providers.push("slack");
if (isSignalConfigured(cfg)) providers.push("signal");
if (isIMessageConfigured(cfg)) providers.push("imessage");
return providers;
}
export async function resolveMessageProviderSelection(params: {
cfg: ClawdbotConfig;
provider?: string | null;
}): Promise<{ provider: MessageProviderId; configured: MessageProviderId[] }> {
const normalized = normalizeMessageProvider(params.provider);
if (normalized) {
if (!isKnownProvider(normalized)) {
throw new Error(`Unknown provider: ${normalized}`);
}
return {
provider: normalized,
configured: await listConfiguredMessageProviders(params.cfg),
};
}
const configured = await listConfiguredMessageProviders(params.cfg);
if (configured.length === 1) {
return { provider: configured[0], configured };
}
if (configured.length === 0) {
throw new Error("Provider is required (no configured providers detected).");
}
throw new Error(
`Provider is required when multiple providers are configured: ${configured.join(
", ",
)}`,
);
}

View File

@@ -145,13 +145,14 @@ export async function listSlackReactions(
export async function sendSlackMessage(
to: string,
content: string,
opts: SlackActionClientOpts & { mediaUrl?: string } = {},
opts: SlackActionClientOpts & { mediaUrl?: string; threadTs?: string } = {},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token,
mediaUrl: opts.mediaUrl,
client: opts.client,
threadTs: opts.threadTs,
});
}