refactor!: rename chat providers to channels

This commit is contained in:
Peter Steinberger
2026-01-13 06:16:43 +00:00
parent 0cd632ba84
commit 90342a4f3a
393 changed files with 8004 additions and 6737 deletions

332
src/channels/dock.ts Normal file
View File

@@ -0,0 +1,332 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount } from "../slack/accounts.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
} from "./plugins/group-mentions.js";
import type {
ChannelCapabilities,
ChannelCommandAdapter,
ChannelElevatedAdapter,
ChannelGroupAdapter,
ChannelId,
ChannelMentionAdapter,
ChannelThreadingAdapter,
} from "./plugins/types.js";
import { CHAT_CHANNEL_ORDER } from "./registry.js";
export type ChannelDock = {
id: ChannelId;
capabilities: ChannelCapabilities;
commands?: ChannelCommandAdapter;
outbound?: {
textChunkLimit?: number;
};
streaming?: ChannelDockStreaming;
elevated?: ChannelElevatedAdapter;
config?: {
resolveAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => Array<string | number> | undefined;
formatAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
};
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter;
};
type ChannelDockStreaming = {
blockStreamingCoalesceDefaults?: {
minChars?: number;
idleMs?: number;
};
};
const formatLower = (allowFrom: Array<string | number>) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase());
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Channel docks: lightweight channel metadata/behavior for shared code paths.
//
// Rules:
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry
//
// Adding a channel:
// - add a new entry to `DOCKS`
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
const DOCKS: Record<ChannelId, ChannelDock> = {
telegram: {
id: "telegram",
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
nativeCommands: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()),
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.telegram?.replyToMode ?? "first",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
whatsapp: {
id: "whatsapp",
capabilities: {
chatTypes: ["direct", "group"],
polls: true,
reactions: true,
media: true,
},
commands: {
enforceOwnerForCommands: true,
skipWhenConfigEmpty: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) =>
entry === "*" ? entry : normalizeWhatsAppTarget(entry),
)
.filter((entry): entry is string => Boolean(entry)),
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
resolveGroupIntroHint: () =>
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
},
mentions: {
stripPatterns: ({ ctx }) => {
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
if (!selfE164) return [];
const escaped = escapeRegExp(selfE164);
return [escaped, `@${escaped}`];
},
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
discord: {
id: "discord",
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
media: true,
nativeCommands: true,
threads: true,
},
outbound: { textChunkLimit: 2000 },
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
elevated: {
allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom,
},
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.discord?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
slack: {
id: "slack",
capabilities: {
chatTypes: ["direct", "channel", "thread"],
reactions: true,
media: true,
nativeCommands: true,
threads: true,
},
outbound: { textChunkLimit: 4000 },
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
const configuredReplyToMode =
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
const effectiveReplyToMode = context.ThreadLabel
? "all"
: configuredReplyToMode;
return {
currentChannelId: context.To?.startsWith("channel:")
? context.To.slice("channel:".length)
: undefined,
currentThreadTs: context.ReplyToId,
replyToMode: effectiveReplyToMode,
hasRepliedRef,
};
},
},
},
signal: {
id: "signal",
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) =>
entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")),
)
.filter(Boolean),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
imessage: {
id: "imessage",
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
msteams: {
id: "msteams",
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
};
export function listChannelDocks(): ChannelDock[] {
return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]);
}
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
return DOCKS[id];
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { formatLocationText, toLocationContext } from "./location.js";
describe("provider location helpers", () => {
it("formats pin locations with accuracy", () => {
const text = formatLocationText({
latitude: 48.858844,
longitude: 2.294351,
accuracy: 12,
});
expect(text).toBe("📍 48.858844, 2.294351 ±12m");
});
it("formats named places with address and caption", () => {
const text = formatLocationText({
latitude: 40.689247,
longitude: -74.044502,
name: "Statue of Liberty",
address: "Liberty Island, NY",
accuracy: 8,
caption: "Bring snacks",
});
expect(text).toBe(
"📍 Statue of Liberty — Liberty Island, NY (40.689247, -74.044502 ±8m)\nBring snacks",
);
});
it("formats live locations with live label", () => {
const text = formatLocationText({
latitude: 37.819929,
longitude: -122.478255,
accuracy: 20,
caption: "On the move",
isLive: true,
source: "live",
});
expect(text).toBe(
"🛰 Live location: 37.819929, -122.478255 ±20m\nOn the move",
);
});
it("builds ctx fields with normalized source", () => {
const ctx = toLocationContext({
latitude: 1,
longitude: 2,
name: "Cafe",
address: "Main St",
});
expect(ctx).toEqual({
LocationLat: 1,
LocationLon: 2,
LocationAccuracy: undefined,
LocationName: "Cafe",
LocationAddress: "Main St",
LocationSource: "place",
LocationIsLive: false,
});
});
});

78
src/channels/location.ts Normal file
View File

@@ -0,0 +1,78 @@
export type LocationSource = "pin" | "place" | "live";
export type NormalizedLocation = {
latitude: number;
longitude: number;
accuracy?: number;
name?: string;
address?: string;
isLive?: boolean;
source?: LocationSource;
caption?: string;
};
type ResolvedLocation = NormalizedLocation & {
source: LocationSource;
isLive: boolean;
};
function resolveLocation(location: NormalizedLocation): ResolvedLocation {
const source =
location.source ??
(location.isLive
? "live"
: location.name || location.address
? "place"
: "pin");
const isLive = Boolean(location.isLive ?? source === "live");
return { ...location, source, isLive };
}
function formatAccuracy(accuracy?: number): string {
if (!Number.isFinite(accuracy)) return "";
return ` ±${Math.round(accuracy ?? 0)}m`;
}
function formatCoords(latitude: number, longitude: number): string {
return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`;
}
export function formatLocationText(location: NormalizedLocation): string {
const resolved = resolveLocation(location);
const coords = formatCoords(resolved.latitude, resolved.longitude);
const accuracy = formatAccuracy(resolved.accuracy);
const caption = resolved.caption?.trim();
let header = "";
if (resolved.source === "live" || resolved.isLive) {
header = `🛰 Live location: ${coords}${accuracy}`;
} else if (resolved.name || resolved.address) {
const label = [resolved.name, resolved.address].filter(Boolean).join(" — ");
header = `📍 ${label} (${coords}${accuracy})`;
} else {
header = `📍 ${coords}${accuracy}`;
}
return caption ? `${header}\n${caption}` : header;
}
export function toLocationContext(location: NormalizedLocation): {
LocationLat: number;
LocationLon: number;
LocationAccuracy?: number;
LocationName?: string;
LocationAddress?: string;
LocationSource: LocationSource;
LocationIsLive: boolean;
} {
const resolved = resolveLocation(location);
return {
LocationLat: resolved.latitude,
LocationLon: resolved.longitude,
LocationAccuracy: resolved.accuracy,
LocationName: resolved.name,
LocationAddress: resolved.address,
LocationSource: resolved.source,
LocationIsLive: resolved.isLive,
};
}

View File

@@ -0,0 +1,669 @@
import {
createActionGate,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "../../../agents/tools/common.js";
import { handleDiscordAction } from "../../../agents/tools/discord-actions.js";
import { listEnabledDiscordAccounts } from "../../../discord/accounts.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "../types.js";
const providerId = "discord";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export const discordMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.channels?.discord?.actions);
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("polls")) actions.add("poll");
if (gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (gate("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (gate("permissions")) actions.add("permissions");
if (gate("threads")) {
actions.add("thread-create");
actions.add("thread-list");
actions.add("thread-reply");
}
if (gate("search")) actions.add("search");
if (gate("stickers")) actions.add("sticker");
if (gate("memberInfo")) actions.add("member-info");
if (gate("roleInfo")) actions.add("role-info");
if (gate("reactions")) actions.add("emoji-list");
if (gate("emojiUploads")) actions.add("emoji-upload");
if (gate("stickerUploads")) actions.add("sticker-upload");
if (gate("roles", false)) {
actions.add("role-add");
actions.add("role-remove");
}
if (gate("channelInfo")) {
actions.add("channel-info");
actions.add("channel-list");
}
if (gate("channels", false)) {
actions.add("channel-create");
actions.add("channel-edit");
actions.add("channel-delete");
actions.add("channel-move");
actions.add("category-create");
actions.add("category-edit");
actions.add("category-delete");
}
if (gate("voiceStatus")) actions.add("voice-status");
if (gate("events")) {
actions.add("event-list");
actions.add("event-create");
}
if (gate("moderation", false)) {
actions.add("timeout");
actions.add("kick");
actions.add("ban");
}
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "sendMessage") {
const to = typeof args.to === "string" ? args.to : undefined;
return to ? { to } : null;
}
if (action === "threadReply") {
const channelId =
typeof args.channelId === "string" ? args.channelId.trim() : "";
return channelId ? { to: `channel:${channelId}` } : null;
}
return null;
},
handleAction: async ({ action, params, cfg }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
return await handleDiscordAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const answers =
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
return await handleDiscordAction(
{
action: "poll",
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
);
}
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;
return await handleDiscordAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await handleDiscordAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await handleDiscordAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{
action:
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins",
channelId: resolveChannelId(),
messageId,
},
cfg,
);
}
if (action === "permissions") {
return await handleDiscordAction(
{
action: "permissions",
channelId: resolveChannelId(),
},
cfg,
);
}
if (action === "thread-create") {
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(),
name,
messageId,
autoArchiveMinutes,
},
cfg,
);
}
if (action === "thread-list") {
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") {
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(),
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
const query = readStringParam(params, "query", { required: true });
return await handleDiscordAction(
{
action: "searchMessages",
guildId,
content: query,
channelId: readStringParam(params, "channelId"),
channelIds: readStringArrayParam(params, "channelIds"),
authorId: readStringParam(params, "authorId"),
authorIds: readStringArrayParam(params, "authorIds"),
limit: readNumberParam(params, "limit", { integer: true }),
},
cfg,
);
}
if (action === "sticker") {
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
return await handleDiscordAction(
{
action: "sticker",
to: readStringParam(params, "to", { required: true }),
stickerIds,
content: readStringParam(params, "message"),
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", guildId, userId },
cfg,
);
}
if (action === "role-info") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
}
if (action === "emoji-list") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "emojiList", guildId }, cfg);
}
if (action === "emoji-upload") {
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") {
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") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", { required: true });
const roleId = readStringParam(params, "roleId", { required: true });
return await handleDiscordAction(
{
action: action === "role-add" ? "roleAdd" : "roleRemove",
guildId,
userId,
roleId,
},
cfg,
);
}
if (action === "channel-info") {
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", channelId },
cfg,
);
}
if (action === "channel-list") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "channelList", guildId }, cfg);
}
if (action === "channel-create") {
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
return await handleDiscordAction(
{
action: "channelCreate",
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
},
cfg,
);
}
if (action === "channel-edit") {
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId = readParentIdParam(params);
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
return await handleDiscordAction(
{
action: "channelEdit",
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
},
cfg,
);
}
if (action === "channel-delete") {
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelDelete", channelId },
cfg,
);
}
if (action === "channel-move") {
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction(
{
action: "channelMove",
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-create") {
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction(
{
action: "categoryCreate",
guildId,
name,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-edit") {
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction(
{
action: "categoryEdit",
categoryId,
name: name ?? undefined,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-delete") {
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
return await handleDiscordAction(
{ action: "categoryDelete", categoryId },
cfg,
);
}
if (action === "voice-status") {
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") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
}
if (action === "event-create") {
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") {
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(
`Action ${String(action)} is not supported for provider ${providerId}.`,
);
},
};

View File

@@ -0,0 +1,108 @@
import {
createActionGate,
readStringParam,
} from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "../types.js";
const providerId = "telegram";
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
const caps = new Set<string>();
for (const entry of cfg.channels?.telegram?.capabilities ?? []) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
const accounts = cfg.channels?.telegram?.accounts;
if (accounts && typeof accounts === "object") {
for (const account of Object.values(accounts)) {
const accountCaps = (account as { capabilities?: unknown })?.capabilities;
if (!Array.isArray(accountCaps)) continue;
for (const entry of accountCaps) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
}
}
return caps.has("inlinebuttons");
}
export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledTelegramAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.channels?.telegram?.actions);
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("reactions")) actions.add("react");
return Array.from(actions);
},
supportsButtons: ({ cfg }) => hasTelegramInlineButtons(cfg),
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId =
typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
return await handleTelegramAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
accountId: accountId ?? undefined,
buttons,
},
cfg,
);
}
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;
return await handleTelegramAction(
{
action: "react",
chatId:
readStringParam(params, "chatId") ??
readStringParam(params, "to", { required: true }),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(
`Action ${action} is not supported for provider ${providerId}.`,
);
},
};

View File

@@ -0,0 +1,74 @@
import { Type } from "@sinclair/typebox";
import type { ChannelAgentTool } from "../types.js";
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",
description:
"Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
parameters: Type.Object({
action: Type.Unsafe<"start" | "wait">({
type: "string",
enum: ["start", "wait"],
}),
timeoutMs: Type.Optional(Type.Number()),
force: Type.Optional(Type.Boolean()),
}),
execute: async (_toolCallId, args) => {
const { startWebLoginWithQr, waitForWebLogin } = await import(
"../../../web/login-qr.js"
);
const action = (args as { action?: string })?.action ?? "start";
if (action === "wait") {
const result = await waitForWebLogin({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
});
return {
content: [{ type: "text", text: result.message }],
details: { connected: result.connected },
};
}
const result = await startWebLoginWithQr({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
force:
typeof (args as { force?: unknown }).force === "boolean"
? (args as { force?: boolean }).force
: false,
});
if (!result.qrDataUrl) {
return {
content: [
{
type: "text",
text: result.message,
},
],
details: { qr: false },
};
}
const text = [
result.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${result.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text", text }],
details: { qr: true },
};
},
};
}

View File

@@ -0,0 +1,121 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
type ChannelSection = {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
};
export function setAccountEnabledInConfigSection(params: {
cfg: ClawdbotConfig;
sectionKey: string;
accountId: string;
enabled: boolean;
allowTopLevel?: boolean;
}): ClawdbotConfig {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.sectionKey] as ChannelSection | undefined;
const hasAccounts = Boolean(base?.accounts);
if (
params.allowTopLevel &&
accountKey === DEFAULT_ACCOUNT_ID &&
!hasAccounts
) {
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
enabled: params.enabled,
},
},
} as ClawdbotConfig;
}
const baseAccounts = (base?.accounts ?? {}) as Record<
string,
Record<string, unknown>
>;
const existing = baseAccounts[accountKey] ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existing,
enabled: params.enabled,
},
},
},
},
} as ClawdbotConfig;
}
export function deleteAccountFromConfigSection(params: {
cfg: ClawdbotConfig;
sectionKey: string;
accountId: string;
clearBaseFields?: string[];
}): ClawdbotConfig {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.sectionKey] as ChannelSection | undefined;
if (!base) return params.cfg;
const baseAccounts =
base.accounts && typeof base.accounts === "object"
? { ...base.accounts }
: undefined;
if (accountKey !== DEFAULT_ACCOUNT_ID) {
const accounts = baseAccounts ? { ...baseAccounts } : {};
delete accounts[accountKey];
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
},
} as ClawdbotConfig;
}
if (baseAccounts && Object.keys(baseAccounts).length > 0) {
delete baseAccounts[accountKey];
const baseRecord = { ...(base as Record<string, unknown>) };
for (const field of params.clearBaseFields ?? []) {
if (field in baseRecord) baseRecord[field] = undefined;
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
},
},
} as ClawdbotConfig;
}
const nextChannels = { ...(params.cfg.channels ?? {}) } as Record<
string,
unknown
>;
delete nextChannels[params.sectionKey];
const nextCfg = { ...params.cfg } as ClawdbotConfig;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as ClawdbotConfig["channels"];
} else {
delete nextCfg.channels;
}
return nextCfg;
}

View File

@@ -0,0 +1,379 @@
import {
listDiscordAccountIds,
type ResolvedDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import {
auditDiscordChannelPermissions,
collectDiscordAuditChannelIds,
} from "../../discord/audit.js";
import { probeDiscord } from "../../discord/probe.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
import { discordMessageActions } from "./actions/discord.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { normalizeDiscordMessagingTarget } from "./normalize-target.js";
import { discordOnboardingAdapter } from "./onboarding/discord.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("discord");
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...meta,
},
onboarding: discordOnboardingAdapter,
pairing: {
idLabel: "discordUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
resolveDiscordAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "discord",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "discord",
accountId,
clearBaseFields: ["token", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.discord?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `channels.discord.accounts.${resolvedAccountId}.dm.`
: "channels.discord.dm.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("discord"),
normalizeEntry: (raw) =>
raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const channelAllowlistConfigured =
Boolean(account.config.guilds) &&
Object.keys(account.config.guilds ?? {}).length > 0;
if (channelAllowlistConfigured) {
return [
`- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
];
}
return [
`- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
];
},
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.discord?.replyToMode ?? "off",
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
},
actions: discordMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "discord",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "DISCORD_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token) {
return "Discord requires --token (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "discord",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "discord",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
...(input.useEnv
? {}
: input.token
? { token: input.token }
: {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[accountId]: {
...next.channels?.discord?.accounts?.[accountId],
enabled: true,
...(input.token ? { token: input.token } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Discord requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
}),
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectDiscordStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeDiscord(account.token, timeoutMs, { includeApplication: true }),
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,
accountId: account.accountId,
});
if (!channelIds.length && unresolvedChannels === 0) return undefined;
const botToken = account.token?.trim();
if (!botToken) {
return {
ok: unresolvedChannels === 0,
checkedChannels: 0,
unresolvedChannels,
channels: [],
elapsedMs: 0,
};
}
const audit = await auditDiscordChannelPermissions({
token: botToken,
accountId: account.accountId,
channelIds,
timeoutMs,
});
return { ...audit, unresolvedChannels };
},
buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
const configured = Boolean(account.token?.trim());
const app =
runtime?.application ??
(probe as { application?: unknown })?.application;
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
application: app ?? undefined,
bot: bot ?? undefined,
probe,
audit,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
let discordBotLabel = "";
try {
const probe = await probeDiscord(token, 2500, {
includeApplication: true,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`;
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
application: probe.application,
});
const messageContent = probe.application?.intents?.messageContent;
if (messageContent === "disabled") {
ctx.log?.warn(
`[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
);
} else if (messageContent === "limited") {
ctx.log?.info(
`[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
);
}
} catch (err) {
if (shouldLogVerbose()) {
ctx.log?.debug?.(
`[${account.accountId}] bot probe failed: ${String(err)}`,
);
}
}
ctx.log?.info(
`[${account.accountId}] starting provider${discordBotLabel}`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorDiscordProvider } = await import("../../discord/index.js");
return monitorDiscordProvider({
token,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
historyLimit: account.config.historyLimit,
});
},
},
};

View File

@@ -0,0 +1,197 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
cfg: ClawdbotConfig;
groupId?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
function normalizeDiscordSlug(value?: string | null) {
if (!value) return "";
let text = value.trim().toLowerCase();
if (!text) return "";
text = text.replace(/^[@#]+/, "");
text = text.replace(/[\s_]+/g, "-");
text = text.replace(/[^a-z0-9-]+/g, "-");
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return text;
}
function normalizeSlackSlug(raw?: string | null) {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
const dashed = trimmed.replace(/\s+/g, "-");
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function parseTelegramGroupId(value?: string | null) {
const raw = value?.trim() ?? "";
if (!raw) return { chatId: undefined, topicId: undefined };
const parts = raw.split(":").filter(Boolean);
if (
parts.length >= 3 &&
parts[1] === "topic" &&
/^-?\d+$/.test(parts[0]) &&
/^\d+$/.test(parts[2])
) {
return { chatId: parts[0], topicId: parts[2] };
}
if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
return { chatId: parts[0], topicId: parts[1] };
}
return { chatId: raw, topicId: undefined };
}
function resolveTelegramRequireMention(params: {
cfg: ClawdbotConfig;
chatId?: string;
topicId?: string;
}): boolean | undefined {
const { cfg, chatId, topicId } = params;
if (!chatId) return undefined;
const groupConfig = cfg.channels?.telegram?.groups?.[chatId];
const groupDefault = cfg.channels?.telegram?.groups?.["*"];
const topicConfig =
topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}
if (typeof defaultTopicConfig?.requireMention === "boolean") {
return defaultTopicConfig.requireMention;
}
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (typeof groupDefault?.requireMention === "boolean") {
return groupDefault.requireMention;
}
return undefined;
}
function resolveDiscordGuildEntry(
guilds: DiscordConfig["guilds"],
groupSpace?: string | null,
) {
if (!guilds || Object.keys(guilds).length === 0) return null;
const space = groupSpace?.trim() ?? "";
if (space && guilds[space]) return guilds[space];
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) return guilds[normalized];
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) return match;
}
return guilds["*"] ?? null;
}
export function resolveTelegramGroupRequireMention(
params: GroupMentionParams,
): boolean | undefined {
const { chatId, topicId } = parseTelegramGroupId(params.groupId);
const requireMention = resolveTelegramRequireMention({
cfg: params.cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
});
}
export function resolveWhatsAppGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveIMessageGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveDiscordGroupRequireMention(
params: GroupMentionParams,
): boolean {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const channelSlug = normalizeDiscordSlug(params.groupRoom);
const entry =
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(params.groupRoom
? channelEntries[normalizeDiscordSlug(params.groupRoom)]
: undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof guildEntry?.requireMention === "boolean") {
return guildEntry.requireMention;
}
return true;
}
export function resolveSlackGroupRequireMention(
params: GroupMentionParams,
): boolean {
const account = resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const channels = account.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = params.groupId?.trim();
const channelName = params.groupRoom?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
let matched: { requireMention?: boolean } | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
break;
}
}
const fallback = channels["*"];
const resolved = matched ?? fallback;
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}
return true;
}

View File

@@ -0,0 +1,22 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import type { ChannelPlugin } from "./types.js";
// Channel docking helper: use this when selecting the default account for a plugin.
export function resolveChannelDefaultAccountId<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: ClawdbotConfig;
accountIds?: string[];
}): string {
const accountIds =
params.accountIds ?? params.plugin.config.listAccountIds(params.cfg);
return (
params.plugin.config.defaultAccountId?.(params.cfg) ??
accountIds[0] ??
DEFAULT_ACCOUNT_ID
);
}
export function formatPairingApproveHint(channelId: string): string {
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
}

View File

@@ -0,0 +1,301 @@
import { chunkText } from "../../auto-reply/chunk.js";
import {
listIMessageAccountIds,
type ResolvedIMessageAccount,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("imessage");
export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
...meta,
showConfigured: false,
},
onboarding: imessageOnboardingAdapter,
pairing: {
idLabel: "imessageSenderId",
notifyApproval: async ({ id }) => {
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "imessage",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "imessage",
accountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.imessage?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.imessage.accounts.${resolvedAccountId}.`
: "channels.imessage.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("imessage"),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "imessage",
accountId,
name,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "imessage",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "imessage",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
accounts: {
...next.channels?.imessage?.accounts,
[accountId]: {
...next.channels?.imessage?.accounts?.[accountId],
enabled: true,
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to iMessage requires --to <handle|chat_id:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
cliPath: null,
dbPath: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError =
typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
cliPath: snapshot.cliPath ?? null,
dbPath: snapshot.dbPath ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
cliPath: runtime?.cliPath ?? account.config.cliPath ?? null,
dbPath: runtime?.dbPath ?? account.config.dbPath ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
resolveAccountState: ({ enabled }) => (enabled ? "enabled" : "disabled"),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const cliPath = account.config.cliPath?.trim() || "imsg";
const dbPath = account.config.dbPath?.trim();
ctx.setStatus({
accountId: account.accountId,
cliPath,
dbPath: dbPath ?? null,
});
ctx.log?.info(
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorIMessageProvider } = await import(
"../../imessage/index.js"
);
return monitorIMessageProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
},
};

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { CHANNEL_IDS } from "../registry.js";
import { listChannelPlugins } from "./index.js";
describe("channel plugin registry", () => {
it("stays in sync with channel ids", () => {
const pluginIds = listChannelPlugins()
.map((plugin) => plugin.id)
.slice()
.sort();
const channelIds = [...CHANNEL_IDS].slice().sort();
expect(pluginIds).toEqual(channelIds);
});
});

View File

@@ -0,0 +1,67 @@
import {
CHAT_CHANNEL_ORDER,
type ChatChannelId,
normalizeChatChannelId,
} from "../registry.js";
import { discordPlugin } from "./discord.js";
import { imessagePlugin } from "./imessage.js";
import { msteamsPlugin } from "./msteams.js";
import { signalPlugin } from "./signal.js";
import { slackPlugin } from "./slack.js";
import { telegramPlugin } from "./telegram.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
import { whatsappPlugin } from "./whatsapp.js";
// Channel plugins registry (runtime).
//
// This module is intentionally "heavy" (plugins may import channel monitors, web login, etc).
// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts`
// instead, and only call `getChannelPlugin()` at execution boundaries.
//
// Adding a channel:
// - add `<id>Plugin` import + entry in `resolveChannels()`
// - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
// - add ids/aliases in `src/channels/registry.ts`
function resolveChannels(): ChannelPlugin[] {
return [
telegramPlugin,
whatsappPlugin,
discordPlugin,
slackPlugin,
signalPlugin,
imessagePlugin,
msteamsPlugin,
];
}
export function listChannelPlugins(): ChannelPlugin[] {
return resolveChannels().sort((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) return orderA - orderB;
return a.id.localeCompare(b.id);
});
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
return resolveChannels().find((plugin) => plugin.id === id);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {
// Channel docking: keep input normalization centralized in src/channels/registry.ts
// so CLI/API/protocol can rely on stable aliases without plugin init side effects.
return normalizeChatChannelId(raw);
}
export {
discordPlugin,
imessagePlugin,
msteamsPlugin,
signalPlugin,
slackPlugin,
telegramPlugin,
whatsappPlugin,
};
export type { ChannelId, ChannelPlugin } from "./types.js";

View File

@@ -0,0 +1,31 @@
import type { ChannelId, ChannelPlugin } from "./types.js";
type PluginLoader = () => Promise<ChannelPlugin>;
// Channel docking: load *one* plugin on-demand.
//
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing.
const LOADERS: Record<ChannelId, PluginLoader> = {
telegram: async () => (await import("./telegram.js")).telegramPlugin,
whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin,
discord: async () => (await import("./discord.js")).discordPlugin,
slack: async () => (await import("./slack.js")).slackPlugin,
signal: async () => (await import("./signal.js")).signalPlugin,
imessage: async () => (await import("./imessage.js")).imessagePlugin,
msteams: async () => (await import("./msteams.js")).msteamsPlugin,
};
const cache = new Map<ChannelId, ChannelPlugin>();
export async function loadChannelPlugin(
id: ChannelId,
): Promise<ChannelPlugin | undefined> {
const cached = cache.get(id);
if (cached) return cached;
const loader = LOADERS[id];
if (!loader) return undefined;
const plugin = await loader();
cache.set(id, plugin);
return plugin;
}

View File

@@ -0,0 +1,26 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
const MB = 1024 * 1024;
export function resolveChannelMediaMaxBytes(params: {
cfg: ClawdbotConfig;
// Channel-specific config lives under different keys; keep this helper generic
// so shared plugin helpers don't need channel-id branching.
resolveChannelLimitMb: (params: {
cfg: ClawdbotConfig;
accountId: string;
}) => number | undefined;
accountId?: string | null;
}): number | undefined {
const accountId = normalizeAccountId(params.accountId);
const channelLimit = params.resolveChannelLimitMb({
cfg: params.cfg,
accountId,
});
if (channelLimit) return channelLimit * MB;
if (params.cfg.agents?.defaults?.mediaMaxMb) {
return params.cfg.agents.defaults.mediaMaxMb * MB;
}
return undefined;
}

View File

@@ -0,0 +1,43 @@
export const CHANNEL_MESSAGE_ACTION_NAMES = [
"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",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
"timeout",
"kick",
"ban",
] as const;
export type ChannelMessageActionName =
(typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];

View File

@@ -0,0 +1,41 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type {
ChannelMessageActionContext,
ChannelMessageActionName,
} from "./types.js";
export function listChannelMessageActions(
cfg: ClawdbotConfig,
): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>(["send"]);
for (const plugin of listChannelPlugins()) {
const list = plugin.actions?.listActions?.({ cfg });
if (!list) continue;
for (const action of list) actions.add(action);
}
return Array.from(actions);
}
export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean {
for (const plugin of listChannelPlugins()) {
if (plugin.actions?.supportsButtons?.({ cfg })) return true;
}
return false;
}
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {
const plugin = getChannelPlugin(ctx.channel);
if (!plugin?.actions?.handleAction) return null;
if (
plugin.actions.supportsAction &&
!plugin.actions.supportsAction({ action: ctx.action })
) {
return null;
}
return await plugin.actions.handleAction(ctx);
}

View File

@@ -0,0 +1,222 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { createMSTeamsPollStoreFs } from "../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js";
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { msteamsOnboardingAdapter } from "./onboarding/msteams.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
type ResolvedMSTeamsAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
};
const meta = {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot)",
docsPath: "/msteams",
docsLabel: "msteams",
blurb: "bot via Microsoft Teams.",
} as const;
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
...meta,
},
onboarding: msteamsOnboardingAdapter,
pairing: {
idLabel: "msteamsUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""),
notifyApproval: async ({ cfg, id }) => {
await sendMessageMSTeams({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.msteams"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled,
},
},
}),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as ClawdbotConfig;
const nextChannels = { ...(cfg.channels ?? {}) };
delete nextChannels.msteams;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
isConfigured: (_account, cfg) =>
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
collectWarnings: ({ cfg }) => {
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
},
},
}),
},
actions: {
listActions: ({ cfg }) => {
const enabled =
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
if (!enabled) return [];
return ["poll"] satisfies ChannelMessageActionName[];
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text, opts) =>
sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
port: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const { monitorMSTeamsProvider } = await import("../../msteams/index.js");
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting provider (port ${port})`);
return monitorMSTeamsProvider({
cfg: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
},
};

View File

@@ -0,0 +1,119 @@
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
export function normalizeSlackMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("group:")) {
const id = trimmed.slice(6).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("slack:")) {
const id = trimmed.slice(6).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
export function normalizeDiscordMessagingTarget(
raw: string,
): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("group:")) {
const id = trimmed.slice(6).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("discord:")) {
const id = trimmed.slice(8).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
export function normalizeTelegramMessagingTarget(
raw: string,
): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("telegram:")) {
normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim();
} else if (normalized.startsWith("group:")) {
normalized = normalized.slice("group:".length).trim();
}
if (!normalized) return undefined;
const tmeMatch =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
if (!normalized) return undefined;
return `telegram:${normalized}`.toLowerCase();
}
export function normalizeSignalMessagingTarget(
raw: string,
): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.toLowerCase().startsWith("signal:")) {
normalized = normalized.slice("signal:".length).trim();
}
if (!normalized) return undefined;
const lower = normalized.toLowerCase();
if (lower.startsWith("group:")) {
const id = normalized.slice("group:".length).trim();
return id ? `group:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("username:")) {
const id = normalized.slice("username:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("u:")) {
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
export function normalizeWhatsAppMessagingTarget(
raw: string,
): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}

View File

@@ -0,0 +1,89 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { DmPolicy } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChatChannelId } from "../registry.js";
export type SetupChannelsOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ChatChannelId[]) => void;
accountIds?: Partial<Record<ChatChannelId, string>>;
onAccountId?: (channel: ChatChannelId, accountId: string) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromChannels?: ChatChannelId[];
skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean;
quickstartDefaults?: boolean;
initialSelection?: ChatChannelId[];
};
export type PromptAccountIdParams = {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
label: string;
currentId?: string;
listAccountIds: (cfg: ClawdbotConfig) => string[];
defaultAccountId: string;
};
export type PromptAccountId = (
params: PromptAccountIdParams,
) => Promise<string>;
export type ChannelOnboardingStatus = {
channel: ChatChannelId;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
export type ChannelOnboardingStatusContext = {
cfg: ClawdbotConfig;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>;
};
export type ChannelOnboardingConfigureContext = {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>;
shouldPromptAccountIds: boolean;
forceAllowFrom: boolean;
};
export type ChannelOnboardingResult = {
cfg: ClawdbotConfig;
accountId?: string;
};
export type ChannelOnboardingDmPolicy = {
label: string;
channel: ChatChannelId;
policyKey: string;
allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig;
};
export type ChannelOnboardingAdapter = {
channel: ChatChannelId;
getStatus: (
ctx: ChannelOnboardingStatusContext,
) => Promise<ChannelOnboardingStatus>;
configure: (
ctx: ChannelOnboardingConfigureContext,
) => Promise<ChannelOnboardingResult>;
dmPolicy?: ChannelOnboardingDmPolicy;
onAccountRecorded?: (
accountId: string,
options?: SetupChannelsOptions,
) => void;
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
};

View File

@@ -0,0 +1,203 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../../discord/accounts.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "discord" as const;
function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.discord?.dm?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
dm: {
...cfg.channels?.discord?.dm,
enabled: cfg.channels?.discord?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Discord Developer Portal → Applications → New Application",
"2) Bot → Add Bot → Reset Token → copy token",
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
"Tip: enable Message Content Intent if you need message text.",
`Docs: ${formatDocsLink("/discord", "discord")}`,
].join("\n"),
"Discord bot token",
);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Discord",
channel,
policyKey: "channels.discord.dm.policy",
allowFromKey: "channels.discord.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
};
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listDiscordAccountIds(cfg).some((accountId) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
);
return {
channel,
configured,
statusLines: [`Discord: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "configured" : "needs token",
quickstartScore: configured ? 2 : 1,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
}) => {
const discordOverride = accountOverrides.discord?.trim();
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
let discordAccountId = discordOverride
? normalizeAccountId(discordOverride)
: defaultDiscordAccountId;
if (shouldPromptAccountIds && !discordOverride) {
discordAccountId = await promptAccountId({
cfg,
prompter,
label: "Discord",
currentId: discordAccountId,
listAccountIds: listDiscordAccountIds,
defaultAccountId: defaultDiscordAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveDiscordAccount({
cfg: next,
accountId: discordAccountId,
});
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv =
allowEnv && Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
const hasConfigToken = Boolean(resolvedAccount.config.token);
let token: string | null = null;
if (!accountConfigured) {
await noteDiscordTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.token) {
const keepEnv = await prompter.confirm({
message: "DISCORD_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
discord: { ...next.channels?.discord, enabled: true },
},
};
} else {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Discord token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
discord: { ...next.channels?.discord, enabled: true, token },
},
};
} else {
next = {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[discordAccountId]: {
...next.channels?.discord?.accounts?.[discordAccountId],
enabled:
next.channels?.discord?.accounts?.[discordAccountId]
?.enabled ?? true,
token,
},
},
},
},
};
}
}
return { cfg: next, accountId: discordAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
discord: { ...cfg.channels?.discord, enabled: false },
},
}),
};

View File

@@ -0,0 +1,50 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import type {
PromptAccountId,
PromptAccountIdParams,
} from "../onboarding-types.js";
export const promptAccountId: PromptAccountId = async (
params: PromptAccountIdParams,
) => {
const existingIds = params.listAccountIds(params.cfg);
const initial =
params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
const choice = (await params.prompter.select({
message: `${params.label} account`,
options: [
...existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
{ value: "__new__", label: "Add a new account" },
],
initialValue: initial,
})) as string;
if (choice !== "__new__") return normalizeAccountId(choice);
const entered = await params.prompter.text({
message: `New ${params.label} account id`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const normalized = normalizeAccountId(String(entered));
if (String(entered).trim() !== normalized) {
await params.prompter.note(
`Normalized account id to "${normalized}".`,
`${params.label} account`,
);
}
return normalized;
};
export function addWildcardAllowFrom(
allowFrom?: Array<string | number> | null,
): Array<string | number> {
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (!next.includes("*")) next.push("*");
return next;
}

View File

@@ -0,0 +1,177 @@
import { detectBinary } from "../../../commands/onboard-helpers.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "../../../imessage/accounts.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "imessage" as const;
function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
};
export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listIMessageAccountIds(cfg).some((accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
return Boolean(
account.config.cliPath ||
account.config.dbPath ||
account.config.allowFrom ||
account.config.service ||
account.config.region,
);
});
const imessageCliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath);
return {
channel,
configured,
statusLines: [
`iMessage: ${configured ? "configured" : "needs setup"}`,
`imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`,
],
selectionHint: imessageCliDetected ? "imsg found" : "imsg missing",
quickstartScore: imessageCliDetected ? 1 : 0,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
}) => {
const imessageOverride = accountOverrides.imessage?.trim();
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
let imessageAccountId = imessageOverride
? normalizeAccountId(imessageOverride)
: defaultIMessageAccountId;
if (shouldPromptAccountIds && !imessageOverride) {
imessageAccountId = await promptAccountId({
cfg,
prompter,
label: "iMessage",
currentId: imessageAccountId,
listAccountIds: listIMessageAccountIds,
defaultAccountId: defaultIMessageAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveIMessageAccount({
cfg: next,
accountId: imessageAccountId,
});
let resolvedCliPath = resolvedAccount.config.cliPath ?? "imsg";
const cliDetected = await detectBinary(resolvedCliPath);
if (!cliDetected) {
const entered = await prompter.text({
message: "imsg CLI path",
initialValue: resolvedCliPath,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
resolvedCliPath = String(entered).trim();
if (!resolvedCliPath) {
await prompter.note(
"imsg CLI path required to enable iMessage.",
"iMessage",
);
}
}
if (resolvedCliPath) {
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
cliPath: resolvedCliPath,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
accounts: {
...next.channels?.imessage?.accounts,
[imessageAccountId]: {
...next.channels?.imessage?.accounts?.[imessageAccountId],
enabled:
next.channels?.imessage?.accounts?.[imessageAccountId]
?.enabled ?? true,
cliPath: resolvedCliPath,
},
},
},
},
};
}
}
await prompter.note(
[
"This is still a work in progress.",
"Ensure Clawdbot has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
].join("\n"),
"iMessage next steps",
);
return { cfg: next, accountId: imessageAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
imessage: { ...cfg.channels?.imessage, enabled: false },
},
}),
};

View File

@@ -0,0 +1,204 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { resolveMSTeamsCredentials } from "../../../msteams/token.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom } from "./helpers.js";
const channel = "msteams" as const;
function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) =>
String(entry),
)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
async function noteMSTeamsCredentialHelp(
prompter: WizardPrompter,
): Promise<void> {
await prompter.note(
[
"1) Azure Bot registration → get App ID + Tenant ID",
"2) Add a client secret (App Password)",
"3) Set webhook URL + messaging endpoint",
"Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
`Docs: ${formatDocsLink("/msteams", "msteams")}`,
].join("\n"),
"MS Teams credentials",
);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "MS Teams",
channel,
policyKey: "channels.msteams.dmPolicy",
allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
};
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = Boolean(
resolveMSTeamsCredentials(cfg.channels?.msteams),
);
return {
channel,
configured,
statusLines: [
`MS Teams: ${configured ? "configured" : "needs app credentials"}`,
],
selectionHint: configured ? "configured" : "needs app creds",
quickstartScore: configured ? 2 : 0,
};
},
configure: async ({ cfg, prompter }) => {
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
const hasConfigCreds = Boolean(
cfg.channels?.msteams?.appId?.trim() &&
cfg.channels?.msteams?.appPassword?.trim() &&
cfg.channels?.msteams?.tenantId?.trim(),
);
const canUseEnv = Boolean(
!hasConfigCreds &&
process.env.MSTEAMS_APP_ID?.trim() &&
process.env.MSTEAMS_APP_PASSWORD?.trim() &&
process.env.MSTEAMS_TENANT_ID?.trim(),
);
let next = cfg;
let appId: string | null = null;
let appPassword: string | null = null;
let tenantId: string | null = null;
if (!resolved) {
await noteMSTeamsCredentialHelp(prompter);
}
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message:
"MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
msteams: { ...next.channels?.msteams, enabled: true },
},
};
} else {
appId = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: "MS Teams credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
appId = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
appId = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (appId && appPassword && tenantId) {
next = {
...next,
channels: {
...next.channels,
msteams: {
...next.channels?.msteams,
enabled: true,
appId,
appPassword,
tenantId,
},
},
};
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
msteams: { ...cfg.channels?.msteams, enabled: false },
},
}),
};

View File

@@ -0,0 +1,219 @@
import { detectBinary } from "../../../commands/onboard-helpers.js";
import { installSignalCli } from "../../../commands/signal-install.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../../../signal/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "signal" as const;
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
};
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
);
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return {
channel,
configured,
statusLines: [
`Signal: ${configured ? "configured" : "needs setup"}`,
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
],
selectionHint: signalCliDetected
? "signal-cli found"
: "signal-cli missing",
quickstartScore: signalCliDetected ? 1 : 0,
};
},
configure: async ({
cfg,
runtime,
prompter,
accountOverrides,
shouldPromptAccountIds,
options,
}) => {
const signalOverride = accountOverrides.signal?.trim();
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
let signalAccountId = signalOverride
? normalizeAccountId(signalOverride)
: defaultSignalAccountId;
if (shouldPromptAccountIds && !signalOverride) {
signalAccountId = await promptAccountId({
cfg,
prompter,
label: "Signal",
currentId: signalAccountId,
listAccountIds: listSignalAccountIds,
defaultAccountId: defaultSignalAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveSignalAccount({
cfg: next,
accountId: signalAccountId,
});
const accountConfig = resolvedAccount.config;
let resolvedCliPath = accountConfig.cliPath ?? "signal-cli";
let cliDetected = await detectBinary(resolvedCliPath);
if (options?.allowSignalInstall) {
const wantsInstall = await prompter.confirm({
message: cliDetected
? "signal-cli detected. Reinstall/update now?"
: "signal-cli not found. Install now?",
initialValue: !cliDetected,
});
if (wantsInstall) {
try {
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
cliDetected = true;
resolvedCliPath = result.cliPath;
await prompter.note(
`Installed signal-cli at ${result.cliPath}`,
"Signal",
);
} else if (!result.ok) {
await prompter.note(
result.error ?? "signal-cli install failed.",
"Signal",
);
}
} catch (err) {
await prompter.note(
`signal-cli install failed: ${String(err)}`,
"Signal",
);
}
}
}
if (!cliDetected) {
await prompter.note(
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
"Signal",
);
}
let account = accountConfig.account ?? "";
if (account) {
const keep = await prompter.confirm({
message: `Signal account set (${account}). Keep it?`,
initialValue: true,
});
if (!keep) account = "";
}
if (!account) {
account = String(
await prompter.text({
message: "Signal bot number (E.164)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (account) {
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
accounts: {
...next.channels?.signal?.accounts,
[signalAccountId]: {
...next.channels?.signal?.accounts?.[signalAccountId],
enabled:
next.channels?.signal?.accounts?.[signalAccountId]
?.enabled ?? true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
},
},
};
}
}
await prompter.note(
[
'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call channels.status --params '{\"probe\":true}'",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal next steps",
);
return { cfg: next, accountId: signalAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
signal: { ...cfg.channels?.signal, enabled: false },
},
}),
};

View File

@@ -0,0 +1,322 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../../slack/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "slack" as const;
function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.slack?.dm?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
dm: {
...cfg.channels?.slack?.dm,
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
function buildSlackManifest(botName: string) {
const safeName = botName.trim() || "Clawdbot";
const manifest = {
display_information: {
name: safeName,
description: `${safeName} connector for Clawdbot`,
},
features: {
bot_user: {
display_name: safeName,
always_online: false,
},
app_home: {
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
slash_commands: [
{
command: "/clawd",
description: "Send a message to Clawdbot",
should_escape: false,
},
],
},
oauth_config: {
scopes: {
bot: [
"chat:write",
"channels:history",
"channels:read",
"groups:history",
"im:history",
"mpim:history",
"users:read",
"app_mentions:read",
"reactions:read",
"reactions:write",
"pins:read",
"pins:write",
"emoji:read",
"commands",
"files:read",
"files:write",
],
},
},
settings: {
socket_mode_enabled: true,
event_subscriptions: {
bot_events: [
"app_mention",
"message.channels",
"message.groups",
"message.im",
"message.mpim",
"reaction_added",
"reaction_removed",
"member_joined_channel",
"member_left_channel",
"channel_rename",
"pin_added",
"pin_removed",
],
},
},
};
return JSON.stringify(manifest, null, 2);
}
async function noteSlackTokenHelp(
prompter: WizardPrompter,
botName: string,
): Promise<void> {
const manifest = buildSlackManifest(botName);
await prompter.note(
[
"1) Slack API → Create App → From scratch",
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
"3) OAuth & Permissions → install app to workspace (xoxb- bot token)",
"4) Enable Event Subscriptions (socket) for message events",
"5) App Home → enable the Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
"",
"Manifest (JSON):",
manifest,
].join("\n"),
"Slack socket mode tokens",
);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Slack",
channel,
policyKey: "channels.slack.dm.policy",
allowFromKey: "channels.slack.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
};
export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSlackAccountIds(cfg).some((accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
return Boolean(account.botToken && account.appToken);
});
return {
channel,
configured,
statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`],
selectionHint: configured ? "configured" : "needs tokens",
quickstartScore: configured ? 2 : 1,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
}) => {
const slackOverride = accountOverrides.slack?.trim();
const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
let slackAccountId = slackOverride
? normalizeAccountId(slackOverride)
: defaultSlackAccountId;
if (shouldPromptAccountIds && !slackOverride) {
slackAccountId = await promptAccountId({
cfg,
prompter,
label: "Slack",
currentId: slackAccountId,
listAccountIds: listSlackAccountIds,
defaultAccountId: defaultSlackAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveSlackAccount({
cfg: next,
accountId: slackAccountId,
});
const accountConfigured = Boolean(
resolvedAccount.botToken && resolvedAccount.appToken,
);
const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv =
allowEnv &&
Boolean(process.env.SLACK_BOT_TOKEN?.trim()) &&
Boolean(process.env.SLACK_APP_TOKEN?.trim());
const hasConfigTokens = Boolean(
resolvedAccount.config.botToken && resolvedAccount.config.appToken,
);
let botToken: string | null = null;
let appToken: string | null = null;
const slackBotName = String(
await prompter.text({
message: "Slack bot display name (used for manifest)",
initialValue: "Clawdbot",
}),
).trim();
if (!accountConfigured) {
await noteSlackTokenHelp(prompter, slackBotName);
}
if (
canUseEnv &&
(!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
) {
const keepEnv = await prompter.confirm({
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
slack: { ...next.channels?.slack, enabled: true },
},
};
} else {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigTokens) {
const keep = await prompter.confirm({
message: "Slack tokens already configured. Keep them?",
initialValue: true,
});
if (!keep) {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (botToken && appToken) {
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
botToken,
appToken,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
accounts: {
...next.channels?.slack?.accounts,
[slackAccountId]: {
...next.channels?.slack?.accounts?.[slackAccountId],
enabled:
next.channels?.slack?.accounts?.[slackAccountId]?.enabled ??
true,
botToken,
appToken,
},
},
},
},
};
}
}
return { cfg: next, accountId: slackAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
slack: { ...cfg.channels?.slack, enabled: false },
},
}),
};

View File

@@ -0,0 +1,285 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../../telegram/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "telegram" as const;
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.telegram?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Open Telegram and chat with @BotFather",
"2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
`Docs: ${formatDocsLink("/telegram")}`,
"Website: https://clawd.bot",
].join("\n"),
"Telegram bot token",
);
}
async function promptTelegramAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveTelegramAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({
message: "Telegram allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0]
? String(existingAllowFrom[0])
: undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
enabled: true,
accounts: {
...cfg.channels?.telegram?.accounts,
[accountId]: {
...cfg.channels?.telegram?.accounts?.[accountId],
enabled:
cfg.channels?.telegram?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Telegram",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
};
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) =>
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
);
return {
channel,
configured,
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
selectionHint: configured
? "recommended · configured"
: "recommended · newcomer-friendly",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const telegramOverride = accountOverrides.telegram?.trim();
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
let telegramAccountId = telegramOverride
? normalizeAccountId(telegramOverride)
: defaultTelegramAccountId;
if (shouldPromptAccountIds && !telegramOverride) {
telegramAccountId = await promptAccountId({
cfg,
prompter,
label: "Telegram",
currentId: telegramAccountId,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveTelegramAccount({
cfg: next,
accountId: telegramAccountId,
});
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv =
allowEnv && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
const hasConfigToken = Boolean(
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
);
let token: string | null = null;
if (!accountConfigured) {
await noteTelegramTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botToken) {
const keepEnv = await prompter.confirm({
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
},
},
};
} else {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Telegram token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
botToken: token,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
accounts: {
...next.channels?.telegram?.accounts,
[telegramAccountId]: {
...next.channels?.telegram?.accounts?.[telegramAccountId],
enabled:
next.channels?.telegram?.accounts?.[telegramAccountId]
?.enabled ?? true,
botToken: token,
},
},
},
},
};
}
}
if (forceAllowFrom) {
next = await promptTelegramAllowFrom({
cfg: next,
prompter,
accountId: telegramAccountId,
});
}
return { cfg: next, accountId: telegramAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, enabled: false },
},
}),
};

View File

@@ -0,0 +1,404 @@
import fs from "node:fs/promises";
import path from "node:path";
import { loginWeb } from "../../../channel-web.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import type { DmPolicy } from "../../../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../../routing/session-key.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164 } from "../../../utils.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "../../../web/accounts.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import { promptAccountId } from "./helpers.js";
const channel = "whatsapp" as const;
function setWhatsAppDmPolicy(
cfg: ClawdbotConfig,
dmPolicy: DmPolicy,
): ClawdbotConfig {
return mergeWhatsAppConfig(cfg, { dmPolicy });
}
function setWhatsAppAllowFrom(
cfg: ClawdbotConfig,
allowFrom?: string[],
): ClawdbotConfig {
return mergeWhatsAppConfig(
cfg,
{ allowFrom },
{ unsetOnUndefined: ["allowFrom"] },
);
}
function setMessagesResponsePrefix(
cfg: ClawdbotConfig,
responsePrefix?: string,
): ClawdbotConfig {
return {
...cfg,
messages: {
...cfg.messages,
responsePrefix,
},
};
}
function setWhatsAppSelfChatMode(
cfg: ClawdbotConfig,
selfChatMode: boolean,
): ClawdbotConfig {
return mergeWhatsAppConfig(cfg, { selfChatMode });
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function detectWhatsAppLinked(
cfg: ClawdbotConfig,
accountId: string,
): Promise<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function promptWhatsAppAllowFrom(
cfg: ClawdbotConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<ClawdbotConfig> {
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const existingResponsePrefix = cfg.messages?.responsePrefix;
if (options?.forceAllowlist) {
await prompter.note(
"We need the sender/owner number so Clawdbot can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message:
"Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const normalized = normalizeE164(raw);
if (!normalized) return `Invalid number: ${raw}`;
return undefined;
},
});
const normalized = normalizeE164(String(entry).trim());
const merged = [
...existingAllowFrom
.filter((item) => item !== "*")
.map((item) => normalizeE164(item))
.filter(Boolean),
normalized,
];
const unique = [...new Set(merged.filter(Boolean))];
let next = setWhatsAppSelfChatMode(cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, unique);
if (existingResponsePrefix === undefined) {
next = setMessagesResponsePrefix(next, "[clawdbot]");
}
await prompter.note(
[
"Allowlist mode enabled.",
`- allowFrom includes ${normalized}`,
existingResponsePrefix === undefined
? "- responsePrefix set to [clawdbot]"
: "- responsePrefix left unchanged",
].join("\n"),
"WhatsApp allowlist",
);
return next;
}
await prompter.note(
[
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
"- disabled: ignore WhatsApp DMs",
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp DM access",
);
const phoneMode = (await prompter.select({
message: "WhatsApp phone setup",
options: [
{ value: "personal", label: "This is my personal phone number" },
{ value: "separate", label: "Separate phone just for Clawdbot" },
],
})) as "personal" | "separate";
if (phoneMode === "personal") {
await prompter.note(
"We need the sender/owner number so Clawdbot can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message:
"Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const normalized = normalizeE164(raw);
if (!normalized) return `Invalid number: ${raw}`;
return undefined;
},
});
const normalized = normalizeE164(String(entry).trim());
const merged = [
...existingAllowFrom
.filter((item) => item !== "*")
.map((item) => normalizeE164(item))
.filter(Boolean),
normalized,
];
const unique = [...new Set(merged.filter(Boolean))];
let next = setWhatsAppSelfChatMode(cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, unique);
if (existingResponsePrefix === undefined) {
next = setMessagesResponsePrefix(next, "[clawdbot]");
}
await prompter.note(
[
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
`- allowFrom includes ${normalized}`,
existingResponsePrefix === undefined
? "- responsePrefix set to [clawdbot]"
: "- responsePrefix left unchanged",
].join("\n"),
"WhatsApp personal phone",
);
return next;
}
const policy = (await prompter.select({
message: "WhatsApp DM policy",
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist only (block unknown senders)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore WhatsApp DMs)" },
],
})) as DmPolicy;
let next = setWhatsAppSelfChatMode(cfg, false);
next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
next = setWhatsAppAllowFrom(next, ["*"]);
}
if (policy === "disabled") return next;
const allowOptions =
existingAllowFrom.length > 0
? ([
{ value: "keep", label: "Keep current allowFrom" },
{
value: "unset",
label: "Unset allowFrom (use pairing approvals only)",
},
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const)
: ([
{ value: "unset", label: "Unset allowFrom (default)" },
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const);
const mode = (await prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)",
options: allowOptions.map((opt) => ({
value: opt.value,
label: opt.label,
})),
})) as (typeof allowOptions)[number]["value"];
if (mode === "keep") {
// Keep allowFrom as-is.
} else if (mode === "unset") {
next = setWhatsAppAllowFrom(next, undefined);
} else {
const allowRaw = await prompter.text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = raw
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) return "Required";
for (const part of parts) {
if (part === "*") continue;
const normalized = normalizeE164(part);
if (!normalized) return `Invalid number: ${part}`;
}
return undefined;
},
});
const parts = String(allowRaw)
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
const normalized = parts.map((part) =>
part === "*" ? "*" : normalizeE164(part),
);
const unique = [...new Set(normalized.filter(Boolean))];
next = setWhatsAppAllowFrom(next, unique);
}
return next;
}
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const overrideId = accountOverrides.whatsapp?.trim();
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
const accountId = overrideId
? normalizeAccountId(overrideId)
: defaultAccountId;
const linked = await detectWhatsAppLinked(cfg, accountId);
const accountLabel =
accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
channel,
configured: linked,
statusLines: [
`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`,
],
selectionHint: linked ? "linked" : "not linked",
quickstartScore: linked ? 5 : 4,
};
},
configure: async ({
cfg,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const overrideId = accountOverrides.whatsapp?.trim();
let accountId = overrideId
? normalizeAccountId(overrideId)
: resolveDefaultWhatsAppAccountId(cfg);
if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
if (!overrideId) {
accountId = await promptAccountId({
cfg,
prompter,
label: "WhatsApp",
currentId: accountId,
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
}
}
let next = cfg;
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: {
...next.channels?.whatsapp?.accounts?.[accountId],
enabled:
next.channels?.whatsapp?.accounts?.[accountId]?.enabled ??
true,
},
},
},
},
};
}
const linked = await detectWhatsAppLinked(next, accountId);
const { authDir } = resolveWhatsAppAuthDir({
cfg: next,
accountId,
});
if (!linked) {
await prompter.note(
[
"Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp linking",
);
}
const wantsLink = await prompter.confirm({
message: linked
? "WhatsApp already linked. Re-link now?"
: "Link WhatsApp now (QR)?",
initialValue: !linked,
});
if (wantsLink) {
try {
await loginWeb(false, undefined, runtime, accountId);
} catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note(
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
"WhatsApp help",
);
}
} else if (!linked) {
await prompter.note(
"Run `clawdbot channels login` later to link WhatsApp.",
"WhatsApp",
);
}
next = await promptWhatsAppAllowFrom(next, runtime, prompter, {
forceAllowlist: forceAllowFrom,
});
return { cfg: next, accountId };
},
onAccountRecorded: (accountId, options) => {
options?.onWhatsAppAccountId?.(accountId);
},
};

View File

@@ -0,0 +1,44 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Discord requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
}),
};

View File

@@ -0,0 +1,53 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageIMessage } from "../../../imessage/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to iMessage requires --to <handle|chat_id:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
};

View File

@@ -0,0 +1,32 @@
import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// Channel docking: outbound sends should stay cheap to import.
//
// The full channel plugins (src/channels/plugins/*.ts) pull in status,
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
// send primitives, so we keep a dedicated, lightweight loader here.
const LOADERS: Record<ChannelId, OutboundLoader> = {
telegram: async () => (await import("./telegram.js")).telegramOutbound,
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
discord: async () => (await import("./discord.js")).discordOutbound,
slack: async () => (await import("./slack.js")).slackOutbound,
signal: async () => (await import("./signal.js")).signalOutbound,
imessage: async () => (await import("./imessage.js")).imessageOutbound,
msteams: async () => (await import("./msteams.js")).msteamsOutbound,
};
const cache = new Map<ChannelId, ChannelOutboundAdapter>();
export async function loadChannelOutboundAdapter(
id: ChannelId,
): Promise<ChannelOutboundAdapter | undefined> {
const cached = cache.get(id);
if (cached) return cached;
const loader = LOADERS[id];
if (!loader) return undefined;
const outbound = await loader();
cache.set(id, outbound);
return outbound;
}

View File

@@ -0,0 +1,60 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { createMSTeamsPollStoreFs } from "../../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../../msteams/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text, opts) =>
sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
};

View File

@@ -0,0 +1,53 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageSignal } from "../../../signal/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
};

View File

@@ -0,0 +1,37 @@
import { sendMessageSlack } from "../../../slack/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Slack requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
};

View File

@@ -0,0 +1,56 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
function parseReplyToMessageId(replyToId?: string | null) {
if (!replyToId) return undefined;
const parsed = Number.parseInt(replyToId, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Telegram requires --to <chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId);
const result = await send(to, text, {
verbose: false,
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
to,
text,
mediaUrl,
accountId,
deps,
replyToId,
threadId,
}) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId);
const result = await send(to, text, {
verbose: false,
mediaUrl,
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
};

View File

@@ -0,0 +1,94 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { shouldLogVerbose } from "../../../globals.js";
import {
sendMessageWhatsApp,
sendPollWhatsApp,
} from "../../../web/outbound.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../../whatsapp/normalize.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw
.filter((entry) => entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
if (
(mode === "implicit" || mode === "heartbeat") &&
allowList.length > 0
) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
}
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return { ok: true, to: allowList[0] };
}
return { ok: true, to: normalizedTo };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
},
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
}),
};

View File

@@ -0,0 +1,2 @@
export const PAIRING_APPROVED_MESSAGE =
"✅ Clawdbot access approved. Send a message to start chatting.";

View File

@@ -0,0 +1,68 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
type ChannelId,
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "./index.js";
import type { ChannelPairingAdapter } from "./types.js";
export function listPairingChannels(): ChannelId[] {
// Channel docking: pairing support is declared via plugin.pairing.
return listChannelPlugins()
.filter((plugin) => plugin.pairing)
.map((plugin) => plugin.id);
}
export function getPairingAdapter(
channelId: ChannelId,
): ChannelPairingAdapter | null {
const plugin = getChannelPlugin(channelId);
return plugin?.pairing ?? null;
}
export function requirePairingAdapter(
channelId: ChannelId,
): ChannelPairingAdapter {
const adapter = getPairingAdapter(channelId);
if (!adapter) {
throw new Error(`Channel ${channelId} does not support pairing`);
}
return adapter;
}
export function resolvePairingChannel(raw: unknown): ChannelId {
const value = (
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "boolean"
? String(raw)
: ""
)
.trim()
.toLowerCase();
const normalized = normalizeChannelId(value);
const channels = listPairingChannels();
if (!normalized || !channels.includes(normalized)) {
throw new Error(
`Invalid channel: ${value || "(empty)"} (expected one of: ${channels.join(", ")})`,
);
}
return normalized;
}
export async function notifyPairingApproved(params: {
channelId: ChannelId;
id: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
}): Promise<void> {
const adapter = requirePairingAdapter(params.channelId);
if (!adapter.notifyApproval) return;
await adapter.notifyApproval({
cfg: params.cfg,
id: params.id,
runtime: params.runtime,
});
}

View File

@@ -0,0 +1,119 @@
import type { ClawdbotConfig } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
type ChannelSectionBase = {
name?: string;
accounts?: Record<string, Record<string, unknown>>;
};
function channelHasAccounts(cfg: ClawdbotConfig, channelKey: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(params: {
cfg: ClawdbotConfig;
channelKey: string;
accountId: string;
alwaysUseAccounts?: boolean;
}): boolean {
if (params.alwaysUseAccounts) return true;
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
return channelHasAccounts(params.cfg, params.channelKey);
}
export function applyAccountNameToChannelSection(params: {
cfg: ClawdbotConfig;
channelKey: string;
accountId: string;
name?: string;
alwaysUseAccounts?: boolean;
}): ClawdbotConfig {
const trimmed = params.name?.trim();
if (!trimmed) return params.cfg;
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig
? (baseConfig as ChannelSectionBase)
: undefined;
const useAccounts = shouldStoreNameInAccounts({
cfg: params.cfg,
channelKey: params.channelKey,
accountId,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const safeBase = base ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...safeBase,
name: trimmed,
},
},
} as ClawdbotConfig;
}
const baseAccounts: Record<
string,
Record<string, unknown>
> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
},
} as ClawdbotConfig;
}
export function migrateBaseNameToDefaultAccount(params: {
cfg: ClawdbotConfig;
channelKey: string;
alwaysUseAccounts?: boolean;
}): ClawdbotConfig {
if (params.alwaysUseAccounts) return params.cfg;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
const baseName = base?.name?.trim();
if (!baseName) return params.cfg;
const accounts: Record<string, Record<string, unknown>> = {
...base?.accounts,
};
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
if (!defaultAccount.name) {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...rest,
accounts,
},
},
} as ClawdbotConfig;
}

View File

@@ -0,0 +1,329 @@
import { chunkText } from "../../auto-reply/chunk.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import {
listSignalAccountIds,
type ResolvedSignalAccount,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../../signal/accounts.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import { normalizeE164 } from "../../utils.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
import { normalizeSignalMessagingTarget } from "./normalize-target.js";
import { signalOnboardingAdapter } from "./onboarding/signal.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("signal");
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
id: "signal",
meta: {
...meta,
},
onboarding: signalOnboardingAdapter,
pairing: {
idLabel: "signalNumber",
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
resolveSignalAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "signal",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "signal",
accountId,
clearBaseFields: [
"account",
"httpUrl",
"httpHost",
"httpPort",
"cliPath",
"name",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) =>
entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")),
)
.filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.signal?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.signal.accounts.${resolvedAccountId}.`
: "channels.signal.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("signal"),
normalizeEntry: (raw) =>
normalizeE164(raw.replace(/^signal:/i, "").trim()),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Signal groups: groupPolicy="open" allows any member to trigger the bot. Set channels.signal.groupPolicy="allowlist" + channels.signal.groupAllowFrom to restrict senders.`,
];
},
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "signal",
accountId,
name,
}),
validateInput: ({ input }) => {
if (
!input.signalNumber &&
!input.httpUrl &&
!input.httpHost &&
!input.httpPort &&
!input.cliPath
) {
return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "signal",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "signal",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
accounts: {
...next.channels?.signal?.accounts,
[accountId]: {
...next.channels?.signal?.accounts?.[accountId],
enabled: true,
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError =
typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "signal",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const baseUrl = account.baseUrl;
return await probeSignal(baseUrl, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(
`[${account.accountId}] starting provider (${account.baseUrl})`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorSignalProvider } = await import("../../signal/index.js");
return monitorSignalProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
});
},
},
};

View File

@@ -0,0 +1,528 @@
import {
createActionGate,
readNumberParam,
readStringParam,
} from "../../agents/tools/common.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import {
listEnabledSlackAccounts,
listSlackAccountIds,
type ResolvedSlackAccount,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../slack/accounts.js";
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveSlackGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { normalizeSlackMessagingTarget } from "./normalize-target.js";
import { slackOnboardingAdapter } from "./onboarding/slack.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("slack");
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: "slack",
meta: {
...meta,
},
onboarding: slackOnboardingAdapter,
pairing: {
idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.slack"] },
config: {
listAccountIds: (cfg) => listSlackAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "slack",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "slack",
accountId,
clearBaseFields: ["botToken", "appToken", "name"],
}),
isConfigured: (account) => Boolean(account.botToken && account.appToken),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.appToken),
botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.slack?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `channels.slack.accounts.${resolvedAccountId}.dm.`
: "channels.slack.dm.";
return {
policy: account.dm?.policy ?? "pairing",
allowFrom: account.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("slack"),
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const channelAllowlistConfigured =
Boolean(account.config.channels) &&
Object.keys(account.config.channels ?? {}).length > 0;
if (channelAllowlistConfigured) {
return [
`- Slack channels: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels.`,
];
}
return [
`- Slack channels: groupPolicy="open" with no channel allowlist; any channel can trigger (mention-gated). Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels.`,
];
},
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
const configuredReplyToMode =
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
const effectiveReplyToMode = context.ThreadLabel
? "all"
: configuredReplyToMode;
return {
currentChannelId: context.To?.startsWith("channel:")
? context.To.slice("channel:".length)
: undefined,
currentThreadTs: context.ReplyToId,
replyToMode: effectiveReplyToMode,
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
},
actions: {
listActions: ({ cfg }) => {
const accounts = listEnabledSlackAccounts(cfg).filter(
(account) => account.botTokenSource !== "none",
);
if (accounts.length === 0) return [];
const isActionEnabled = (key: string, defaultValue = true) => {
for (const account of accounts) {
const gate = createActionGate(
(account.actions ?? cfg.channels?.slack?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate(key, defaultValue)) return true;
}
return false;
};
const actions = new Set<ChannelMessageActionName>(["send"]);
if (isActionEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (isActionEnabled("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (isActionEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (isActionEnabled("memberInfo")) actions.add("member-info");
if (isActionEnabled("emojiList")) actions.add("emoji-list");
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId =
typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
return await handleSlackAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
toolContext,
);
}
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;
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleSlackAction(
{
action:
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
if (action === "emoji-list") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Action ${action} is not supported for provider ${meta.id}.`,
);
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "slack",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "Slack env tokens can only be used for the default account.";
}
if (!input.useEnv && (!input.botToken || !input.appToken)) {
return "Slack requires --bot-token and --app-token (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "slack",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "slack",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
...(input.useEnv
? {}
: {
...(input.botToken ? { botToken: input.botToken } : {}),
...(input.appToken ? { appToken: input.appToken } : {}),
}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
accounts: {
...next.channels?.slack?.accounts,
[accountId]: {
...next.channels?.slack?.accounts?.[accountId],
enabled: true,
...(input.botToken ? { botToken: input.botToken } : {}),
...(input.appToken ? { appToken: input.appToken } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Slack requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
botTokenSource: snapshot.botTokenSource ?? "none",
appTokenSource: snapshot.appTokenSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const token = account.botToken?.trim();
if (!token) return { ok: false, error: "missing token" };
return await probeSlack(token, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.botToken && account.appToken);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const botToken = account.botToken?.trim();
const appToken = account.appToken?.trim();
ctx.log?.info(`[${account.accountId}] starting provider`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorSlackProvider } = await import("../../slack/index.js");
return monitorSlackProvider({
botToken: botToken ?? "",
appToken: appToken ?? "",
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
slashCommand: account.config.slashCommand,
});
},
},
};

View File

@@ -0,0 +1,145 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled";
};
type DiscordApplicationSummary = {
intents?: DiscordIntentSummary;
};
type DiscordAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
application?: unknown;
audit?: unknown;
};
type DiscordPermissionsAuditSummary = {
unresolvedChannels?: number;
channels?: Array<{
channelId: string;
ok?: boolean;
missing?: string[];
error?: string | null;
}>;
};
function readDiscordAccountStatus(
value: ChannelAccountSnapshot,
): DiscordAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
application: value.application,
audit: value.audit,
};
}
function readDiscordApplicationSummary(
value: unknown,
): DiscordApplicationSummary {
if (!isRecord(value)) return {};
const intentsRaw = value.intents;
if (!isRecord(intentsRaw)) return {};
return {
intents: {
messageContent:
intentsRaw.messageContent === "enabled" ||
intentsRaw.messageContent === "limited" ||
intentsRaw.messageContent === "disabled"
? intentsRaw.messageContent
: undefined,
},
};
}
function readDiscordPermissionsAuditSummary(
value: unknown,
): DiscordPermissionsAuditSummary {
if (!isRecord(value)) return {};
const unresolvedChannels =
typeof value.unresolvedChannels === "number" &&
Number.isFinite(value.unresolvedChannels)
? value.unresolvedChannels
: undefined;
const channelsRaw = value.channels;
const channels = Array.isArray(channelsRaw)
? (channelsRaw
.map((entry) => {
if (!isRecord(entry)) return null;
const channelId = asString(entry.channelId);
if (!channelId) return null;
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const missing = Array.isArray(entry.missing)
? entry.missing.map((v) => asString(v)).filter(Boolean)
: undefined;
const error = asString(entry.error) ?? null;
return {
channelId,
ok,
missing: missing?.length ? missing : undefined,
error,
};
})
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
: undefined;
return { unresolvedChannels, channels };
}
export function collectDiscordStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readDiscordAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
const app = readDiscordApplicationSummary(account.application);
const messageContent = app.intents?.messageContent;
if (messageContent === "disabled") {
issues.push({
channel: "discord",
accountId,
kind: "intent",
message:
"Message Content Intent is disabled. Bot may not see normal channel messages.",
fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.",
});
}
const audit = readDiscordPermissionsAuditSummary(account.audit);
if (audit.unresolvedChannels && audit.unresolvedChannels > 0) {
issues.push({
channel: "discord",
accountId,
kind: "config",
message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`,
fix: "Use numeric channel IDs as keys in channels.discord.guilds.*.channels (then rerun channels status --probe).",
});
}
for (const channel of audit.channels ?? []) {
if (channel.ok === true) continue;
const missing = channel.missing?.length
? ` missing ${channel.missing.join(", ")}`
: "";
const error = channel.error ? `: ${channel.error}` : "";
issues.push({
channel: "discord",
accountId,
kind: "permissions",
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
});
}
}
return issues;
}

View File

@@ -0,0 +1,9 @@
export function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -0,0 +1,123 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
type TelegramAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
allowUnmentionedGroups?: unknown;
audit?: unknown;
};
type TelegramGroupMembershipAuditSummary = {
unresolvedGroups?: number;
hasWildcardUnmentionedGroups?: boolean;
groups?: Array<{
chatId: string;
ok?: boolean;
status?: string | null;
error?: string | null;
}>;
};
function readTelegramAccountStatus(
value: ChannelAccountSnapshot,
): TelegramAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
allowUnmentionedGroups: value.allowUnmentionedGroups,
audit: value.audit,
};
}
function readTelegramGroupMembershipAuditSummary(
value: unknown,
): TelegramGroupMembershipAuditSummary {
if (!isRecord(value)) return {};
const unresolvedGroups =
typeof value.unresolvedGroups === "number" &&
Number.isFinite(value.unresolvedGroups)
? value.unresolvedGroups
: undefined;
const hasWildcardUnmentionedGroups =
typeof value.hasWildcardUnmentionedGroups === "boolean"
? value.hasWildcardUnmentionedGroups
: undefined;
const groupsRaw = value.groups;
const groups = Array.isArray(groupsRaw)
? (groupsRaw
.map((entry) => {
if (!isRecord(entry)) return null;
const chatId = asString(entry.chatId);
if (!chatId) return null;
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const status = asString(entry.status) ?? null;
const error = asString(entry.error) ?? null;
return { chatId, ok, status, error };
})
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
: undefined;
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
}
export function collectTelegramStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readTelegramAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
if (account.allowUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
});
}
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
if (audit.hasWildcardUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.",
});
}
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.",
});
}
for (const group of audit.groups ?? []) {
if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}
}
return issues;
}

View File

@@ -0,0 +1,70 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
type WhatsAppAccountStatus = {
accountId?: unknown;
enabled?: unknown;
linked?: unknown;
connected?: unknown;
running?: unknown;
reconnectAttempts?: unknown;
lastError?: unknown;
};
function readWhatsAppAccountStatus(
value: ChannelAccountSnapshot,
): WhatsAppAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
linked: value.linked,
connected: value.connected,
running: value.running,
reconnectAttempts: value.reconnectAttempts,
lastError: value.lastError,
};
}
export function collectWhatsAppStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readWhatsAppAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
if (!enabled) continue;
const linked = account.linked === true;
const running = account.running === true;
const connected = account.connected === true;
const reconnectAttempts =
typeof account.reconnectAttempts === "number"
? account.reconnectAttempts
: null;
const lastError = asString(account.lastError);
if (!linked) {
issues.push({
channel: "whatsapp",
accountId,
kind: "auth",
message: "Not linked (no WhatsApp Web session).",
fix: "Run: clawdbot channels login (scan QR on the gateway host).",
});
continue;
}
if (running && !connected) {
issues.push({
channel: "whatsapp",
accountId,
kind: "runtime",
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via channels login and check logs.",
});
}
}
return issues;
}

View File

@@ -0,0 +1,39 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./types.js";
// Channel docking: status snapshots flow through plugin.status hooks here.
export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: ClawdbotConfig;
accountId: string;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
}): Promise<ChannelAccountSnapshot> {
const account = params.plugin.config.resolveAccount(
params.cfg,
params.accountId,
);
if (params.plugin.status?.buildAccountSnapshot) {
return await params.plugin.status.buildAccountSnapshot({
account,
cfg: params.cfg,
runtime: params.runtime,
probe: params.probe,
audit: params.audit,
});
}
const enabled = params.plugin.config.isEnabled
? params.plugin.config.isEnabled(account, params.cfg)
: account && typeof account === "object"
? (account as { enabled?: boolean }).enabled
: undefined;
const configured = params.plugin.config.isConfigured
? await params.plugin.config.isConfigured(account, params.cfg)
: undefined;
return {
accountId: params.accountId,
enabled,
configured,
};
}

View File

@@ -0,0 +1,485 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { writeConfigFile } from "../../config/config.js";
import { shouldLogVerbose } from "../../globals.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import {
listTelegramAccountIds,
type ResolvedTelegramAccount,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { getChatChannelMeta } from "../registry.js";
import { telegramMessageActions } from "./actions/telegram.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { normalizeTelegramMessagingTarget } from "./normalize-target.js";
import { telegramOnboardingAdapter } from "./onboarding/telegram.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectTelegramStatusIssues } from "./status-issues/telegram.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("telegram");
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
id: "telegram",
meta: {
...meta,
quickstartAllowFrom: true,
},
onboarding: telegramOnboardingAdapter,
pairing: {
idLabel: "telegramUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const { token } = resolveTelegramToken(cfg);
if (!token) throw new Error("telegram token not configured");
await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token });
},
},
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.telegram"] },
config: {
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
resolveTelegramAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "telegram",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "telegram",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.telegram?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.telegram.accounts.${resolvedAccountId}.`
: "channels.telegram.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("telegram"),
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const groupAllowlistConfigured =
account.config.groups && Object.keys(account.config.groups).length > 0;
if (groupAllowlistConfigured) {
return [
`- Telegram groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom to restrict senders.`,
];
}
return [
`- Telegram groups: groupPolicy="open" with no channels.telegram.groups allowlist; any group can add + ping (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom or configure channels.telegram.groups.`,
];
},
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.telegram?.replyToMode ?? "first",
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
},
actions: telegramMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "telegram",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "TELEGRAM_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Telegram requires --token or --token-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "telegram",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "telegram",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
...(input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
accounts: {
...next.channels?.telegram?.accounts,
[accountId]: {
...next.channels?.telegram?.accounts?.[accountId],
enabled: true,
...(input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Telegram requires --to <chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = replyToId
? Number.parseInt(replyToId, 10)
: undefined;
const resolvedReplyToMessageId = Number.isFinite(replyToMessageId)
? replyToMessageId
: undefined;
const result = await send(to, text, {
verbose: false,
messageThreadId: threadId ?? undefined,
replyToMessageId: resolvedReplyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
to,
text,
mediaUrl,
accountId,
deps,
replyToId,
threadId,
}) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = replyToId
? Number.parseInt(replyToId, 10)
: undefined;
const resolvedReplyToMessageId = Number.isFinite(replyToMessageId)
? replyToMessageId
: undefined;
const result = await send(to, text, {
verbose: false,
mediaUrl,
messageThreadId: threadId ?? undefined,
replyToMessageId: resolvedReplyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectTelegramStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeTelegram(account.token, timeoutMs, account.config.proxy),
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups;
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
collectTelegramUnmentionedGroupIds(groups);
if (
!groupIds.length &&
unresolvedGroups === 0 &&
!hasWildcardUnmentionedGroups
) {
return undefined;
}
const botId =
(probe as { ok?: boolean; bot?: { id?: number } })?.ok &&
(probe as { bot?: { id?: number } }).bot?.id != null
? (probe as { bot: { id: number } }).bot.id
: null;
if (!botId) {
return {
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
checkedGroups: 0,
unresolvedGroups,
hasWildcardUnmentionedGroups,
groups: [],
elapsedMs: 0,
};
}
const audit = await auditTelegramGroupMembership({
token: account.token,
botId,
groupIds,
proxyUrl: account.config.proxy,
timeoutMs,
});
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
},
buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => {
const configured = Boolean(account.token?.trim());
const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups;
const allowUnmentionedGroups =
Boolean(
groups?.["*"] &&
(groups["*"] as { requireMention?: boolean }).requireMention ===
false,
) ||
Object.entries(groups ?? {}).some(
([key, value]) =>
key !== "*" &&
Boolean(value) &&
typeof value === "object" &&
(value as { requireMention?: boolean }).requireMention === false,
);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode:
runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"),
probe,
audit,
allowUnmentionedGroups,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
let telegramBotLabel = "";
try {
const probe = await probeTelegram(token, 2500, account.config.proxy);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) telegramBotLabel = ` (@${username})`;
} catch (err) {
if (shouldLogVerbose()) {
ctx.log?.debug?.(
`[${account.accountId}] bot probe failed: ${String(err)}`,
);
}
}
ctx.log?.info(
`[${account.accountId}] starting provider${telegramBotLabel}`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTelegramProvider } = await import(
"../../telegram/monitor.js"
);
return monitorTelegramProvider({
token,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
webhookUrl: account.config.webhookUrl,
webhookSecret: account.config.webhookSecret,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as ClawdbotConfig;
const nextTelegram = cfg.channels?.telegram
? { ...cfg.channels.telegram }
: undefined;
let cleared = false;
let changed = false;
if (nextTelegram) {
if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) {
delete nextTelegram.botToken;
cleared = true;
changed = true;
}
const accounts =
nextTelegram.accounts && typeof nextTelegram.accounts === "object"
? { ...nextTelegram.accounts }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if ("botToken" in nextEntry) {
const token = nextEntry.botToken;
if (typeof token === "string" ? token.trim() : token) {
cleared = true;
}
delete nextEntry.botToken;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextTelegram.accounts;
changed = true;
} else {
nextTelegram.accounts = accounts;
}
}
}
if (changed) {
if (nextTelegram && Object.keys(nextTelegram).length > 0) {
nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram };
} else {
const nextChannels = { ...(nextCfg.channels ?? {}) };
delete nextChannels.telegram;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
}
const resolved = resolveTelegramAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
if (changed) {
await writeConfigFile(nextCfg);
}
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
};

View File

@@ -0,0 +1,550 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type {
OutboundDeliveryResult,
OutboundSendDeps,
} from "../../infra/outbound/deliver.js";
import type { PollInput } from "../../polls.js";
import type { RuntimeEnv } from "../../runtime.js";
import type {
GatewayClientMode,
GatewayClientName,
} from "../../utils/message-channel.js";
import type { ChatChannelId } from "../registry.js";
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
import type { ChannelOnboardingAdapter } from "./onboarding-types.js";
export { CHANNEL_MESSAGE_ACTION_NAMES } from "./message-action-names.js";
export type ChannelId = ChatChannelId;
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
export type ChannelAgentTool = AgentTool<TSchema, unknown>;
export type ChannelAgentToolFactory = (params: {
cfg?: ClawdbotConfig;
}) => ChannelAgentTool[];
export type ChannelSetupInput = {
name?: string;
token?: string;
tokenFile?: string;
botToken?: string;
appToken?: string;
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
};
export type ChannelStatusIssue = {
channel: ChannelId;
accountId: string;
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
message: string;
fix?: string;
};
export type ChannelAccountState =
| "linked"
| "not linked"
| "configured"
| "not configured"
| "enabled"
| "disabled";
export type ChannelSetupAdapter = {
resolveAccountId?: (params: {
cfg: ClawdbotConfig;
accountId?: string;
}) => string;
applyAccountName?: (params: {
cfg: ClawdbotConfig;
accountId: string;
name?: string;
}) => ClawdbotConfig;
applyAccountConfig: (params: {
cfg: ClawdbotConfig;
accountId: string;
input: ChannelSetupInput;
}) => ClawdbotConfig;
validateInput?: (params: {
cfg: ClawdbotConfig;
accountId: string;
input: ChannelSetupInput;
}) => string | null;
};
export type ChannelHeartbeatDeps = {
webAuthExists?: () => Promise<boolean>;
hasActiveWebListener?: () => boolean;
};
export type ChannelMeta = {
id: ChannelId;
label: string;
selectionLabel: string;
docsPath: string;
docsLabel?: string;
blurb: string;
order?: number;
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
};
export type ChannelAccountSnapshot = {
accountId: string;
name?: string;
enabled?: boolean;
configured?: boolean;
linked?: boolean;
running?: boolean;
connected?: boolean;
reconnectAttempts?: number;
lastConnectedAt?: number | null;
lastDisconnect?:
| string
| {
at: number;
status?: number;
error?: string;
loggedOut?: boolean;
}
| null;
lastMessageAt?: number | null;
lastEventAt?: number | null;
lastError?: string | null;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
mode?: string;
dmPolicy?: string;
allowFrom?: string[];
tokenSource?: string;
botTokenSource?: string;
appTokenSource?: string;
baseUrl?: string;
allowUnmentionedGroups?: boolean;
cliPath?: string | null;
dbPath?: string | null;
port?: number | null;
probe?: unknown;
lastProbeAt?: number | null;
audit?: unknown;
application?: unknown;
bot?: unknown;
};
export type ChannelLogSink = {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
export type ChannelConfigAdapter<ResolvedAccount> = {
listAccountIds: (cfg: ClawdbotConfig) => string[];
resolveAccount: (
cfg: ClawdbotConfig,
accountId?: string | null,
) => ResolvedAccount;
defaultAccountId?: (cfg: ClawdbotConfig) => string;
setAccountEnabled?: (params: {
cfg: ClawdbotConfig;
accountId: string;
enabled: boolean;
}) => ClawdbotConfig;
deleteAccount?: (params: {
cfg: ClawdbotConfig;
accountId: string;
}) => ClawdbotConfig;
isEnabled?: (account: ResolvedAccount, cfg: ClawdbotConfig) => boolean;
disabledReason?: (account: ResolvedAccount, cfg: ClawdbotConfig) => string;
isConfigured?: (
account: ResolvedAccount,
cfg: ClawdbotConfig,
) => boolean | Promise<boolean>;
unconfiguredReason?: (
account: ResolvedAccount,
cfg: ClawdbotConfig,
) => string;
describeAccount?: (
account: ResolvedAccount,
cfg: ClawdbotConfig,
) => ChannelAccountSnapshot;
resolveAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => string[] | undefined;
formatAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
};
export type ChannelGroupContext = {
cfg: ClawdbotConfig;
groupId?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
export type ChannelGroupAdapter = {
resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;
};
export type ChannelOutboundContext = {
cfg: ClawdbotConfig;
to: string;
text: string;
mediaUrl?: string;
gifPlayback?: boolean;
replyToId?: string | null;
threadId?: number | null;
accountId?: string | null;
deps?: OutboundSendDeps;
};
export type ChannelPollResult = {
messageId: string;
toJid?: string;
channelId?: string;
conversationId?: string;
pollId?: string;
};
export type ChannelPollContext = {
cfg: ClawdbotConfig;
to: string;
poll: PollInput;
accountId?: string | null;
};
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
textChunkLimit?: number;
pollMaxOptions?: number;
resolveTarget?: (params: {
cfg?: ClawdbotConfig;
to?: string;
allowFrom?: string[];
accountId?: string | null;
mode?: ChannelOutboundTargetMode;
}) => { ok: true; to: string } | { ok: false; error: Error };
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
};
export type ChannelStatusAdapter<ResolvedAccount> = {
defaultRuntime?: ChannelAccountSnapshot;
buildChannelSummary?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
defaultAccountId: string;
snapshot: ChannelAccountSnapshot;
}) => Record<string, unknown> | Promise<Record<string, unknown>>;
probeAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: ClawdbotConfig;
}) => Promise<unknown>;
auditAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: ClawdbotConfig;
probe?: unknown;
}) => Promise<unknown>;
buildAccountSnapshot?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
}) => ChannelAccountSnapshot | Promise<ChannelAccountSnapshot>;
logSelfId?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
includeChannelPrefix?: boolean;
}) => void;
resolveAccountState?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
configured: boolean;
enabled: boolean;
}) => ChannelAccountState;
collectStatusIssues?: (
accounts: ChannelAccountSnapshot[],
) => ChannelStatusIssue[];
};
export type ChannelGatewayContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId: string;
account: ResolvedAccount;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
log?: ChannelLogSink;
getStatus: () => ChannelAccountSnapshot;
setStatus: (next: ChannelAccountSnapshot) => void;
};
export type ChannelLogoutResult = {
cleared: boolean;
loggedOut?: boolean;
[key: string]: unknown;
};
export type ChannelLoginWithQrStartResult = {
qrDataUrl?: string;
message: string;
};
export type ChannelLoginWithQrWaitResult = {
connected: boolean;
message: string;
};
export type ChannelLogoutContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId: string;
account: ResolvedAccount;
runtime: RuntimeEnv;
log?: ChannelLogSink;
};
export type ChannelPairingAdapter = {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: {
cfg: ClawdbotConfig;
id: string;
runtime?: RuntimeEnv;
}) => Promise<void>;
};
export type ChannelGatewayAdapter<ResolvedAccount = unknown> = {
startAccount?: (
ctx: ChannelGatewayContext<ResolvedAccount>,
) => Promise<unknown>;
stopAccount?: (ctx: ChannelGatewayContext<ResolvedAccount>) => Promise<void>;
loginWithQrStart?: (params: {
accountId?: string;
force?: boolean;
timeoutMs?: number;
verbose?: boolean;
}) => Promise<ChannelLoginWithQrStartResult>;
loginWithQrWait?: (params: {
accountId?: string;
timeoutMs?: number;
}) => Promise<ChannelLoginWithQrWaitResult>;
logoutAccount?: (
ctx: ChannelLogoutContext<ResolvedAccount>,
) => Promise<ChannelLogoutResult>;
};
export type ChannelAuthAdapter = {
login?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
runtime: RuntimeEnv;
verbose?: boolean;
channelInput?: string | null;
}) => Promise<void>;
};
export type ChannelHeartbeatAdapter = {
checkReady?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
deps?: ChannelHeartbeatDeps;
}) => Promise<{ ok: boolean; reason: string }>;
resolveRecipients?: (params: {
cfg: ClawdbotConfig;
opts?: { to?: string; all?: boolean };
}) => { recipients: string[]; source: string };
};
export type ChannelCapabilities = {
chatTypes: Array<"direct" | "group" | "channel" | "thread">;
polls?: boolean;
reactions?: boolean;
threads?: boolean;
media?: boolean;
nativeCommands?: boolean;
blockStreaming?: boolean;
};
export type ChannelElevatedAdapter = {
allowFromFallback?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => Array<string | number> | undefined;
};
export type ChannelCommandAdapter = {
enforceOwnerForCommands?: boolean;
skipWhenConfigEmpty?: boolean;
};
export type ChannelSecurityDmPolicy = {
policy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
allowFromPath: string;
approveHint: string;
normalizeEntry?: (raw: string) => string;
};
export type ChannelSecurityContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId?: string | null;
account: ResolvedAccount;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ChannelSecurityContext<ResolvedAccount>,
) => ChannelSecurityDmPolicy | null;
collectWarnings?: (
ctx: ChannelSecurityContext<ResolvedAccount>,
) => Promise<string[]> | string[];
};
export type ChannelMentionAdapter = {
stripPatterns?: (params: {
ctx: MsgContext;
cfg: ClawdbotConfig | undefined;
agentId?: string;
}) => string[];
stripMentions?: (params: {
text: string;
ctx: MsgContext;
cfg: ClawdbotConfig | undefined;
agentId?: string;
}) => string;
};
export type ChannelStreamingAdapter = {
blockStreamingCoalesceDefaults?: {
minChars: number;
idleMs: number;
};
};
export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => "off" | "first" | "all";
allowTagsWhenOff?: boolean;
buildToolContext?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}) => ChannelThreadingToolContext | undefined;
};
export type ChannelThreadingContext = {
Channel?: string;
To?: string;
ReplyToId?: string;
ThreadLabel?: string;
};
export type ChannelThreadingToolContext = {
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
};
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
};
export type ChannelMessageActionName = ChannelMessageActionNameFromList;
export type ChannelMessageActionContext = {
channel: ChannelId;
action: ChannelMessageActionName;
cfg: ClawdbotConfig;
params: Record<string, unknown>;
accountId?: string | null;
gateway?: {
url?: string;
token?: string;
timeoutMs?: number;
clientName: GatewayClientName;
clientDisplayName?: string;
mode: GatewayClientMode;
};
toolContext?: ChannelThreadingToolContext;
dryRun?: boolean;
};
export type ChannelToolSend = {
to: string;
accountId?: string | null;
};
export type ChannelMessageActionAdapter = {
listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean;
extractToolSend?: (params: {
args: Record<string, unknown>;
}) => ChannelToolSend | null;
handleAction?: (
ctx: ChannelMessageActionContext,
) => Promise<AgentToolResult<unknown>>;
};
// Channel docking: implement this contract in src/channels/plugins/<id>.ts.
// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types.
export type ChannelPlugin<ResolvedAccount = any> = {
id: ChannelId;
meta: ChannelMeta;
capabilities: ChannelCapabilities;
reload?: { configPrefixes: string[]; noopPrefixes?: string[] };
// CLI onboarding wizard hooks for this channel.
onboarding?: ChannelOnboardingAdapter;
config: ChannelConfigAdapter<ResolvedAccount>;
setup?: ChannelSetupAdapter;
pairing?: ChannelPairingAdapter;
security?: ChannelSecurityAdapter<ResolvedAccount>;
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
outbound?: ChannelOutboundAdapter;
status?: ChannelStatusAdapter<ResolvedAccount>;
gatewayMethods?: string[];
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
auth?: ChannelAuthAdapter;
elevated?: ChannelElevatedAdapter;
commands?: ChannelCommandAdapter;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
actions?: ChannelMessageActionAdapter;
heartbeat?: ChannelHeartbeatAdapter;
// Channel-owned agent tools (login flows, etc.).
agentTools?: ChannelAgentToolFactory | ChannelAgentTool[];
};

View File

@@ -0,0 +1,80 @@
import { normalizeChatChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { normalizeE164 } from "../../utils.js";
type HeartbeatRecipientsResult = { recipients: string[]; source: string };
type HeartbeatRecipientsOpts = { to?: string; all?: boolean };
function getSessionRecipients(cfg: ClawdbotConfig) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
if (scope === "global") return [];
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
const isGroupKey = (key: string) =>
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:") ||
key.includes("@g.us");
const isCronKey = (key: string) => key.startsWith("cron:");
const recipients = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
.map(([_, entry]) => ({
to:
normalizeChatChannelId(entry?.lastChannel) === "whatsapp" &&
entry?.lastTo
? normalizeE164(entry.lastTo)
: "",
updatedAt: entry?.updatedAt ?? 0,
}))
.filter(({ to }) => to.length > 1)
.sort((a, b) => b.updatedAt - a.updatedAt);
// Dedupe while preserving recency ordering.
const seen = new Set<string>();
return recipients.filter((r) => {
if (seen.has(r.to)) return false;
seen.add(r.to);
return true;
});
}
export function resolveWhatsAppHeartbeatRecipients(
cfg: ClawdbotConfig,
opts: HeartbeatRecipientsOpts = {},
): HeartbeatRecipientsResult {
if (opts.to) {
return { recipients: [normalizeE164(opts.to)], source: "flag" };
}
const sessionRecipients = getSessionRecipients(cfg);
const allowFrom =
Array.isArray(cfg.channels?.whatsapp?.allowFrom) &&
cfg.channels.whatsapp.allowFrom.length > 0
? cfg.channels.whatsapp.allowFrom
.filter((v) => v !== "*")
.map(normalizeE164)
: [];
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
if (opts.all) {
const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]);
return { recipients: all, source: "all" };
}
if (sessionRecipients.length === 1) {
return { recipients: [sessionRecipients[0].to], source: "session-single" };
}
if (sessionRecipients.length > 1) {
return {
recipients: sessionRecipients.map((s) => s.to),
source: "session-ambiguous",
};
}
return { recipients: allowFrom, source: "allowFrom" };
}

View File

@@ -0,0 +1,499 @@
import {
createActionGate,
readStringParam,
} from "../../agents/tools/common.js";
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
import { chunkText } from "../../auto-reply/chunk.js";
import { shouldLogVerbose } from "../../globals.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { normalizeE164 } from "../../utils.js";
import {
listWhatsAppAccountIds,
type ResolvedWhatsAppAccount,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
} from "../../web/accounts.js";
import { getActiveWebListener } from "../../web/active-listener.js";
import {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
} from "../../web/auth-store.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../whatsapp/normalize.js";
import { getChatChannelMeta } from "../registry.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { normalizeWhatsAppMessagingTarget } from "./normalize-target.js";
import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js";
const meta = getChatChannelMeta("whatsapp");
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
id: "whatsapp",
meta: {
...meta,
showConfigured: false,
quickstartAllowFrom: true,
forceAccountBinding: true,
preferSessionLookupForAnnounceTarget: true,
},
onboarding: whatsappOnboardingAdapter,
agentTools: () => [createWhatsAppLoginTool()],
pairing: {
idLabel: "whatsappSenderId",
},
capabilities: {
chatTypes: ["direct", "group"],
polls: true,
reactions: true,
media: true,
},
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
gatewayMethods: ["web.login.start", "web.login.wait"],
config: {
listAccountIds: (cfg) => listWhatsAppAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
resolveWhatsAppAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const accountKey = accountId || DEFAULT_ACCOUNT_ID;
const accounts = { ...cfg.channels?.whatsapp?.accounts };
const existing = accounts[accountKey] ?? {};
return {
...cfg,
channels: {
...cfg.channels,
whatsapp: {
...cfg.channels?.whatsapp,
accounts: {
...accounts,
[accountKey]: {
...existing,
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const accountKey = accountId || DEFAULT_ACCOUNT_ID;
const accounts = { ...cfg.channels?.whatsapp?.accounts };
delete accounts[accountKey];
return {
...cfg,
channels: {
...cfg.channels,
whatsapp: {
...cfg.channels?.whatsapp,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
},
};
},
isEnabled: (account, cfg) =>
account.enabled !== false && cfg.web?.enabled !== false,
disabledReason: () => "disabled",
isConfigured: async (account) => await webAuthExists(account.authDir),
unconfiguredReason: () => "not linked",
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.authDir),
dmPolicy: account.dmPolicy,
allowFrom: account.allowFrom,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) =>
entry === "*" ? entry : normalizeWhatsAppTarget(entry),
)
.filter((entry): entry is string => Boolean(entry)),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.whatsapp?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.whatsapp.accounts.${resolvedAccountId}.`
: "channels.whatsapp.";
return {
policy: account.dmPolicy ?? "pairing",
allowFrom: account.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("whatsapp"),
normalizeEntry: (raw) => normalizeE164(raw),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const groupAllowlistConfigured =
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0;
if (groupAllowlistConfigured) {
return [
`- WhatsApp groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.whatsapp.groupPolicy="allowlist" + channels.whatsapp.groupAllowFrom to restrict senders.`,
];
}
return [
`- WhatsApp groups: groupPolicy="open" with no channels.whatsapp.groups allowlist; any group can add + ping (mention-gated). Set channels.whatsapp.groupPolicy="allowlist" + channels.whatsapp.groupAllowFrom or configure channels.whatsapp.groups.`,
];
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "whatsapp",
accountId,
name,
alwaysUseAccounts: true,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "whatsapp",
accountId,
name: input.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "whatsapp",
alwaysUseAccounts: true,
});
const entry = {
...next.channels?.whatsapp?.accounts?.[accountId],
...(input.authDir ? { authDir: input.authDir } : {}),
enabled: true,
};
return {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: entry,
},
},
},
};
},
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
resolveGroupIntroHint: () =>
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
},
mentions: {
stripPatterns: ({ ctx }) => {
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
if (!selfE164) return [];
const escaped = escapeRegExp(selfE164);
return [escaped, `@${escaped}`];
},
},
commands: {
enforceOwnerForCommands: true,
skipWhenConfigEmpty: true,
},
messaging: {
normalizeTarget: normalizeWhatsAppMessagingTarget,
},
actions: {
listActions: ({ cfg }) => {
if (!cfg.channels?.whatsapp) return [];
const gate = createActionGate(cfg.channels.whatsapp.actions);
const actions = new Set<ChannelMessageActionName>();
if (gate("reactions")) actions.add("react");
if (gate("polls")) actions.add("poll");
return Array.from(actions);
},
supportsAction: ({ action }) => action === "react",
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(
`Action ${action} is not supported for provider ${meta.id}.`,
);
}
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove =
typeof params.remove === "boolean" ? params.remove : undefined;
return await handleWhatsAppAction(
{
action: "react",
chatJid:
readStringParam(params, "chatJid") ??
readStringParam(params, "to", { required: true }),
messageId,
emoji,
remove,
participant: readStringParam(params, "participant"),
accountId: accountId ?? undefined,
fromMe:
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
},
cfg,
);
},
},
outbound: {
deliveryMode: "gateway",
chunker: chunkText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw
.filter((entry) => entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
if (
(mode === "implicit" || mode === "heartbeat") &&
allowList.length > 0
) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
}
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return { ok: true, to: allowList[0] };
}
return { ok: true, to: normalizedTo };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
},
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
}),
},
auth: {
login: async ({ cfg, accountId, runtime, verbose }) => {
const resolvedAccountId =
accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
const { loginWeb } = await import("../../web/login.js");
await loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId);
},
},
heartbeat: {
checkReady: async ({ cfg, accountId, deps }) => {
if (cfg.web?.enabled === false) {
return { ok: false, reason: "whatsapp-disabled" };
}
const account = resolveWhatsAppAccount({ cfg, accountId });
const authExists = await (deps?.webAuthExists ?? webAuthExists)(
account.authDir,
);
if (!authExists) {
return { ok: false, reason: "whatsapp-not-linked" };
}
const listenerActive = deps?.hasActiveWebListener
? deps.hasActiveWebListener()
: Boolean(getActiveWebListener());
if (!listenerActive) {
return { ok: false, reason: "whatsapp-not-running" };
}
return { ok: true, reason: "ok" };
},
resolveRecipients: ({ cfg, opts }) =>
resolveWhatsAppHeartbeatRecipients(cfg, opts),
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
},
collectStatusIssues: collectWhatsAppStatusIssues,
buildChannelSummary: async ({ account, snapshot }) => {
const authDir = account.authDir;
const linked =
typeof snapshot.linked === "boolean"
? snapshot.linked
: authDir
? await webAuthExists(authDir)
: false;
const authAgeMs = linked && authDir ? getWebAuthAgeMs(authDir) : null;
const self =
linked && authDir ? readWebSelfId(authDir) : { e164: null, jid: null };
return {
configured: linked,
linked,
authAgeMs,
self,
running: snapshot.running ?? false,
connected: snapshot.connected ?? false,
lastConnectedAt: snapshot.lastConnectedAt ?? null,
lastDisconnect: snapshot.lastDisconnect ?? null,
reconnectAttempts: snapshot.reconnectAttempts,
lastMessageAt: snapshot.lastMessageAt ?? null,
lastEventAt: snapshot.lastEventAt ?? null,
lastError: snapshot.lastError ?? null,
};
},
buildAccountSnapshot: async ({ account, runtime }) => {
const linked = await webAuthExists(account.authDir);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: true,
linked,
running: runtime?.running ?? false,
connected: runtime?.connected ?? false,
reconnectAttempts: runtime?.reconnectAttempts,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
lastMessageAt: runtime?.lastMessageAt ?? null,
lastEventAt: runtime?.lastEventAt ?? null,
lastError: runtime?.lastError ?? null,
dmPolicy: account.dmPolicy,
allowFrom: account.allowFrom,
};
},
resolveAccountState: ({ configured }) =>
configured ? "linked" : "not linked",
logSelfId: ({ account, runtime, includeChannelPrefix }) => {
logWebSelfId(account.authDir, runtime, includeChannelPrefix);
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const { e164, jid } = readWebSelfId(account.authDir);
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
ctx.log?.info(`[${account.accountId}] starting provider (${identity})`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorWebChannel } = await import("../web/index.js");
return monitorWebChannel(
shouldLogVerbose(),
undefined,
true,
undefined,
ctx.runtime,
ctx.abortSignal,
{
statusSink: (next) =>
ctx.setStatus({ accountId: ctx.accountId, ...next }),
accountId: account.accountId,
},
);
},
loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) =>
await (async () => {
const { startWebLoginWithQr } = await import("../../web/login-qr.js");
return await startWebLoginWithQr({
accountId,
force,
timeoutMs,
verbose,
});
})(),
loginWithQrWait: async ({ accountId, timeoutMs }) =>
await (async () => {
const { waitForWebLogin } = await import("../../web/login-qr.js");
return await waitForWebLogin({ accountId, timeoutMs });
})(),
logoutAccount: async ({ account, runtime }) => {
const cleared = await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
runtime,
});
return { cleared, loggedOut: cleared };
},
},
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
formatChannelSelectionLine,
listChatChannels,
normalizeChatChannelId,
} from "./registry.js";
describe("channel registry", () => {
it("normalizes aliases", () => {
expect(normalizeChatChannelId("imsg")).toBe("imessage");
expect(normalizeChatChannelId("teams")).toBe("msteams");
expect(normalizeChatChannelId("web")).toBeNull();
});
it("keeps Telegram first in the default order", () => {
const channels = listChatChannels();
expect(channels[0]?.id).toBe("telegram");
});
it("formats selection lines with docs labels", () => {
const channels = listChatChannels();
const first = channels[0];
if (!first) throw new Error("Missing channel metadata.");
const line = formatChannelSelectionLine(first, (path, label) =>
[label, path].filter(Boolean).join(":"),
);
expect(line).not.toContain("Docs:");
expect(line).toContain("/channels/telegram");
expect(line).toContain("https://clawd.bot");
});
});

153
src/channels/registry.ts Normal file
View File

@@ -0,0 +1,153 @@
// Channel docking: add new channels here (order + meta + aliases), then
// register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync.
export const CHAT_CHANNEL_ORDER = [
"telegram",
"whatsapp",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp";
export type ChatChannelMeta = {
id: ChatChannelId;
label: string;
selectionLabel: string;
docsPath: string;
docsLabel?: string;
blurb: string;
// Channel docking: selection-line formatting for onboarding prompts.
// Keep this data-driven to avoid channel-specific branches in shared code.
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
};
const WEBSITE_URL = "https://clawd.bot";
const CHAT_CHANNEL_META: Record<ChatChannelId, ChatChannelMeta> = {
telegram: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram (Bot API)",
docsPath: "/channels/telegram",
docsLabel: "telegram",
blurb:
"simplest way to get started — register a bot with @BotFather and get going.",
selectionDocsPrefix: "",
selectionDocsOmitLabel: true,
selectionExtras: [WEBSITE_URL],
},
whatsapp: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
docsPath: "/channels/whatsapp",
docsLabel: "whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
discord: {
id: "discord",
label: "Discord",
selectionLabel: "Discord (Bot API)",
docsPath: "/channels/discord",
docsLabel: "discord",
blurb: "very well supported right now.",
},
slack: {
id: "slack",
label: "Slack",
selectionLabel: "Slack (Socket Mode)",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supported (Socket Mode).",
},
signal: {
id: "signal",
label: "Signal",
selectionLabel: "Signal (signal-cli)",
docsPath: "/channels/signal",
docsLabel: "signal",
blurb:
'signal-cli linked device; more setup (David Reagans: "Hop on Discord.").',
},
imessage: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
docsLabel: "imessage",
blurb: "this is still a work in progress.",
},
msteams: {
id: "msteams",
label: "MS Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "supported (Bot Framework).",
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
imsg: "imessage",
teams: "msteams",
};
const normalizeChannelKey = (raw?: string | null): string | undefined => {
const normalized = raw?.trim().toLowerCase();
return normalized || undefined;
};
export function listChatChannels(): ChatChannelMeta[] {
return CHAT_CHANNEL_ORDER.map((id) => CHAT_CHANNEL_META[id]);
}
export function listChatChannelAliases(): string[] {
return Object.keys(CHAT_CHANNEL_ALIASES);
}
export function getChatChannelMeta(id: ChatChannelId): ChatChannelMeta {
return CHAT_CHANNEL_META[id];
}
export function normalizeChatChannelId(
raw?: string | null,
): ChatChannelId | null {
const normalized = normalizeChannelKey(raw);
if (!normalized) return null;
const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized;
return CHAT_CHANNEL_ORDER.includes(resolved as ChatChannelId)
? (resolved as ChatChannelId)
: null;
}
// Channel docking: prefer this helper in shared code. Importing from
// `src/channels/plugins/*` can eagerly load channel implementations.
export function normalizeChannelId(raw?: string | null): ChatChannelId | null {
return normalizeChatChannelId(raw);
}
export function formatChannelPrimerLine(meta: ChatChannelMeta): string {
return `${meta.label}: ${meta.blurb}`;
}
export function formatChannelSelectionLine(
meta: ChatChannelMeta,
docsLink: (path: string, label?: string) => string,
): string {
const docsPrefix = meta.selectionDocsPrefix ?? "Docs:";
const docsLabel = meta.docsLabel ?? meta.id;
const docs = meta.selectionDocsOmitLabel
? docsLink(meta.docsPath)
: docsLink(meta.docsPath, docsLabel);
const extras = (meta.selectionExtras ?? []).filter(Boolean).join(" ");
return `${meta.label}${meta.blurb} ${docsPrefix ? `${docsPrefix} ` : ""}${docs}${extras ? ` ${extras}` : ""}`;
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import * as impl from "../../channel-web.js";
import * as entry from "./index.js";
describe("channels/web entrypoint", () => {
it("re-exports web channel helpers", () => {
expect(entry.createWaSocket).toBe(impl.createWaSocket);
expect(entry.loginWeb).toBe(impl.loginWeb);
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
expect(entry.monitorWebChannel).toBe(impl.monitorWebChannel);
expect(entry.pickWebChannel).toBe(impl.pickWebChannel);
expect(entry.sendMessageWhatsApp).toBe(impl.sendMessageWhatsApp);
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
expect(entry.webAuthExists).toBe(impl.webAuthExists);
});
});

13
src/channels/web/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/* istanbul ignore file */
export {
createWaSocket,
loginWeb,
logWebSelfId,
monitorWebChannel,
monitorWebInbox,
pickWebChannel,
sendMessageWhatsApp,
WA_WEB_AUTH_DIR,
waitForWaConnection,
webAuthExists,
} from "../../channel-web.js";