Move provider to a plugin-architecture (#661)

* refactor: introduce provider plugin registry

* refactor: move provider CLI to plugins

* docs: add provider plugin implementation notes

* refactor: shift provider runtime logic into plugins

* refactor: add plugin defaults and summaries

* docs: update provider plugin notes

* feat(commands): add /commands slash list

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* Tests: align google shared expectations

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* refactor: move provider routing into plugins

* test: align agent routing expectations

* docs: update provider plugin notes

* refactor: route replies via provider plugins

* docs: note route-reply plugin hooks

* refactor: extend provider plugin contract

* refactor: derive provider status from plugins

* refactor: unify gateway provider control

* refactor: use plugin metadata in auto-reply

* fix: parenthesize cron target selection

* refactor: derive gateway methods from plugins

* refactor: generalize provider logout

* refactor: route provider logout through plugins

* refactor: move WhatsApp web login methods into plugin

* refactor: generalize provider log prefixes

* refactor: centralize default chat provider

* refactor: derive provider lists from registry

* refactor: move provider reload noops into plugins

* refactor: resolve web login provider via alias

* refactor: derive CLI provider options from plugins

* refactor: derive prompt provider list from plugins

* style: apply biome lint fixes

* fix: resolve provider routing edge cases

* docs: update provider plugin refactor notes

* fix(gateway): harden agent provider routing

* refactor: move provider routing into plugins

* refactor: move provider CLI to plugins

* refactor: derive provider lists from registry

* fix: restore slash command parsing

* refactor: align provider ids for schema

* refactor: unify outbound target resolution

* fix: keep outbound labels stable

* feat: add msteams to cron surfaces

* fix: clean up lint build issues

* refactor: localize chat provider alias normalization

* refactor: drive gateway provider lists from plugins

* docs: update provider plugin notes

* style: format message-provider

* fix: avoid provider registry init cycles

* style: sort message-provider imports

* fix: relax provider alias map typing

* refactor: move provider routing into plugins

* refactor: add plugin pairing/config adapters

* refactor: route pairing and provider removal via plugins

* refactor: align auto-reply provider typing

* test: stabilize telegram media mocks

* docs: update provider plugin refactor notes

* refactor: pluginize outbound targets

* refactor: pluginize provider selection

* refactor: generalize text chunk limits

* docs: update provider plugin notes

* refactor: generalize group session/config

* fix: normalize provider id for room detection

* fix: avoid provider init in system prompt

* style: formatting cleanup

* refactor: normalize agent delivery targets

* test: update outbound delivery labels

* chore: fix lint regressions

* refactor: extend provider plugin adapters

* refactor: move elevated/block streaming defaults to plugins

* refactor: defer outbound send deps to plugins

* docs: note plugin-driven streaming/elevated defaults

* refactor: centralize webchat provider constant

* refactor: add provider setup adapters

* refactor: delegate provider add config to plugins

* docs: document plugin-driven provider add

* refactor: add plugin state/binding metadata

* refactor: build agent provider status from plugins

* docs: note plugin-driven agent bindings

* refactor: centralize internal provider constant usage

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize default chat provider

* refactor: centralize WhatsApp target normalization

* refactor: move provider routing into plugins

* refactor: normalize agent delivery targets

* chore: fix lint regressions

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* feat: expand provider plugin adapters

* refactor: route auto-reply via provider plugins

* fix: align WhatsApp target normalization

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize WhatsApp target normalization

* feat: add /config chat config updates

* docs: add /config get alias

* feat(commands): add /commands slash list

* refactor: centralize default chat provider

* style: apply biome lint fixes

* chore: fix lint regressions

* fix: clean up whatsapp allowlist typing

* style: format config command helpers

* refactor: pluginize tool threading context

* refactor: normalize session announce targets

* docs: note new plugin threading and announce hooks

* refactor: pluginize message actions

* docs: update provider plugin actions notes

* fix: align provider action adapters

* refactor: centralize webchat checks

* style: format message provider helpers

* refactor: move provider onboarding into adapters

* docs: note onboarding provider adapters

* feat: add msteams onboarding adapter

* style: organize onboarding imports

* fix: normalize msteams allowFrom types

* feat: add plugin text chunk limits

* refactor: use plugin chunk limit fallbacks

* feat: add provider mention stripping hooks

* style: organize provider plugin type imports

* refactor: generalize health snapshots

* refactor: update macOS health snapshot handling

* docs: refresh health snapshot notes

* style: format health snapshot updates

* refactor: drive security warnings via plugins

* docs: note provider security adapter

* style: format provider security adapters

* refactor: centralize provider account defaults

* refactor: type gateway client identity constants

* chore: regen gateway protocol swift

* fix: degrade health on failed provider probe

* refactor: centralize pairing approve hint

* docs: add plugin CLI command references

* refactor: route auth and tool sends through plugins

* docs: expand provider plugin hooks

* refactor: document provider docking touchpoints

* refactor: normalize internal provider defaults

* refactor: streamline outbound delivery wiring

* refactor: make provider onboarding plugin-owned

* refactor: support provider-owned agent tools

* refactor: move telegram draft chunking into telegram module

* refactor: infer provider tool sends via extractToolSend

* fix: repair plugin onboarding imports

* refactor: de-dup outbound target normalization

* style: tidy plugin and agent imports

* refactor: data-drive provider selection line

* fix: satisfy lint after provider plugin rebase

* test: deflake gateway-cli coverage

* style: format gateway-cli coverage test

* refactor(provider-plugins): simplify provider ids

* test(pairing-cli): avoid provider-specific ternary

* style(macos): swiftformat HealthStore

* refactor(sandbox): derive provider tool denylist

* fix(sandbox): avoid plugin init in defaults

* refactor(provider-plugins): centralize provider aliases

* style(test): satisfy biome

* refactor(protocol): v3 providers.status maps

* refactor(ui): adapt to protocol v3

* refactor(macos): adapt to protocol v3

* test: update providers.status v3 fixtures

* refactor(gateway): map provider runtime snapshot

* test(gateway): update reload runtime snapshot

* refactor(whatsapp): normalize heartbeat provider id

* docs(refactor): update provider plugin notes

* style: satisfy biome after rebase

* fix: describe sandboxed elevated in prompt

* feat(gateway): add agent image attachments + live probe

* refactor: derive CLI provider options from plugins

* fix(gateway): harden agent provider routing

* fix(gateway): harden agent provider routing

* refactor: align provider ids for schema

* fix(protocol): keep agent provider string

* fix(gateway): harden agent provider routing

* fix(protocol): keep agent provider string

* refactor: normalize agent delivery targets

* refactor: support provider-owned agent tools

* refactor(config): provider-keyed elevated allowFrom

* style: satisfy biome

* fix(gateway): appease provider narrowing

* style: satisfy biome

* refactor(reply): move group intro hints into plugin

* fix(reply): avoid plugin registry init cycle

* refactor(providers): add lightweight provider dock

* refactor(gateway): use typed client id in connect

* refactor(providers): document docks and avoid init cycles

* refactor(providers): make media limit helper generic

* fix(providers): break plugin registry import cycles

* style: satisfy biome

* refactor(status-all): build providers table from plugins

* refactor(gateway): delegate web login to provider plugin

* refactor(provider): drop web alias

* refactor(provider): lazy-load monitors

* style: satisfy lint/format

* style: format status-all providers table

* style: swiftformat gateway discovery model

* test: make reload plan plugin-driven

* fix: avoid token stringification in status-all

* refactor: make provider IDs explicit in status

* feat: warn on signal/imessage provider runtime errors

* test: cover gateway provider runtime warnings in status

* fix: add runtime kind to provider status issues

* test: cover health degradation on probe failure

* fix: keep routeReply lightweight

* style: organize routeReply imports

* refactor(web): extract auth-store helpers

* refactor(whatsapp): lazy login imports

* refactor(outbound): route replies via plugin outbound

* docs: update provider plugin notes

* style: format provider status issues

* fix: make sandbox scope warning wrap-safe

* refactor: load outbound adapters from provider plugins

* docs: update provider plugin outbound notes

* style(macos): fix swiftformat lint

* docs: changelog for provider plugins

* fix(macos): satisfy swiftformat

* fix(macos): open settings via menu action

* style: format after rebase

* fix(macos): open Settings via menu action

---------

Co-authored-by: LK <luke@kyohere.com>
Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com>
Co-authored-by: Xin <xin@imfing.com>
This commit is contained in:
Peter Steinberger
2026-01-11 11:45:25 +00:00
committed by GitHub
parent 23eec7d841
commit 7acd26a2fc
232 changed files with 13642 additions and 10809 deletions
+292
View File
@@ -0,0 +1,292 @@
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 {
ProviderCapabilities,
ProviderCommandAdapter,
ProviderElevatedAdapter,
ProviderGroupAdapter,
ProviderId,
ProviderMentionAdapter,
ProviderThreadingAdapter,
} from "./plugins/types.js";
import { CHAT_PROVIDER_ORDER } from "./registry.js";
export type ProviderDock = {
id: ProviderId;
capabilities: ProviderCapabilities;
commands?: ProviderCommandAdapter;
outbound?: {
textChunkLimit?: number;
};
streaming?: ProviderDockStreaming;
elevated?: ProviderElevatedAdapter;
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?: ProviderGroupAdapter;
mentions?: ProviderMentionAdapter;
threading?: ProviderThreadingAdapter;
};
type ProviderDockStreaming = {
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, "\\$&");
// Provider docks: lightweight provider 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/providers/registry.ts`), not from the plugins registry
//
// Adding a provider:
// - add a new entry to `DOCKS`
// - keep it cheap; push heavy logic into `src/providers/plugins/<id>.ts` or provider modules
const DOCKS: Record<ProviderId, ProviderDock> = {
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.telegram?.replyToMode ?? "first",
},
},
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}`];
},
},
},
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.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.discord?.replyToMode ?? "off",
},
},
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),
},
},
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,
},
},
msteams: {
id: "msteams",
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
},
};
export function listProviderDocks(): ProviderDock[] {
return CHAT_PROVIDER_ORDER.map((id) => DOCKS[id]);
}
export function getProviderDock(id: ProviderId): ProviderDock | undefined {
return DOCKS[id];
}
+12 -5
View File
@@ -93,10 +93,10 @@ describe("google-shared convertTools", () => {
const list = asRecord(properties.list);
const items = asRecord(list.items);
expect(params.patternProperties).toBeDefined();
expect(params.patternProperties).toEqual({ "^x-": { type: "string" } });
expect(params.additionalProperties).toBe(false);
expect(mode.const).toBe("fast");
expect(options.anyOf).toBeDefined();
expect(options.anyOf).toEqual([{ type: "string" }, { type: "number" }]);
expect(items.const).toBe("item");
expect(params.required).toEqual(["mode"]);
});
@@ -185,9 +185,8 @@ describe("google-shared convertMessages", () => {
const contents = convertMessages(model, context);
expect(contents).toHaveLength(1);
const parts = contents?.[0]?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
expect(contents[0].role).toBe("model");
expect(contents[0].parts?.[0]).toMatchObject({
thought: true,
thoughtSignature: "sig",
});
@@ -257,6 +256,8 @@ describe("google-shared convertMessages", () => {
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user");
expect(contents[0].parts).toHaveLength(1);
expect(contents[1].parts).toHaveLength(1);
});
it("does not merge consecutive user messages for non-Gemini Google models", () => {
@@ -278,6 +279,8 @@ describe("google-shared convertMessages", () => {
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user");
expect(contents[0].parts).toHaveLength(1);
expect(contents[1].parts).toHaveLength(1);
});
it("does not merge consecutive model messages for Gemini", () => {
@@ -342,6 +345,8 @@ describe("google-shared convertMessages", () => {
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model");
expect(contents[1].parts).toHaveLength(1);
expect(contents[2].parts).toHaveLength(1);
});
it("handles user message after tool result without model response in between", () => {
@@ -402,6 +407,7 @@ describe("google-shared convertMessages", () => {
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user");
expect(contents[3].role).toBe("user");
const toolResponsePart = contents[2].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionResponse" in part,
@@ -479,6 +485,7 @@ describe("google-shared convertMessages", () => {
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model");
const toolCallPart = contents[2].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionCall" in part,
+531
View File
@@ -0,0 +1,531 @@
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 {
ProviderMessageActionAdapter,
ProviderMessageActionName,
} from "../types.js";
const providerId = "discord";
export const discordMessageActions: ProviderMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.discord?.actions);
const actions = new Set<ProviderMessageActionName>(["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("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 === "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}.`,
);
},
};
+120
View File
@@ -0,0 +1,120 @@
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 {
ProviderMessageActionAdapter,
ProviderMessageActionName,
} from "../types.js";
const providerId = "telegram";
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
const caps = new Set<string>();
for (const entry of cfg.telegram?.capabilities ?? []) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
const accounts = cfg.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: ProviderMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledTelegramAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.telegram?.actions);
const actions = new Set<ProviderMessageActionName>(["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");
let buttons = params.buttons;
if (!buttons) {
const buttonsJson = readStringParam(params, "buttonsJson", {
trim: false,
});
if (buttonsJson) {
try {
buttons = JSON.parse(buttonsJson);
} catch {
throw new Error("buttons-json must be valid JSON");
}
}
}
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}.`,
);
},
};
@@ -0,0 +1,74 @@
import { Type } from "@sinclair/typebox";
import type { ProviderAgentTool } from "../types.js";
export function createWhatsAppLoginTool(): ProviderAgentTool {
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 },
};
},
};
}
+102
View File
@@ -0,0 +1,102 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
type ProviderSection = {
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 base = (params.cfg as Record<string, unknown>)[params.sectionKey] as
| ProviderSection
| undefined;
const hasAccounts = Boolean(base?.accounts);
if (
params.allowTopLevel &&
accountKey === DEFAULT_ACCOUNT_ID &&
!hasAccounts
) {
return {
...params.cfg,
[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,
[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 base = (params.cfg as Record<string, unknown>)[params.sectionKey] as
| ProviderSection
| 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,
[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,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
},
} as ClawdbotConfig;
}
const clone = { ...params.cfg } as Record<string, unknown>;
delete clone[params.sectionKey];
return clone as ClawdbotConfig;
}
+353
View File
@@ -0,0 +1,353 @@
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 { getChatProviderMeta } 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 {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
import type { ProviderPlugin } from "./types.js";
const meta = getChatProviderMeta("discord");
export const discordPlugin: ProviderPlugin<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: ["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.discord?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `discord.accounts.${resolvedAccountId}.dm.`
: "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"),
};
},
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.discord?.replyToMode ?? "off",
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
},
actions: discordMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "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 = applyAccountNameToProviderSection({
cfg,
providerKey: "discord",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "discord",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
discord: {
...next.discord,
enabled: true,
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
},
};
}
return {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[accountId]: {
...next.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 { provider: "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 { provider: "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,
buildProviderSummary: ({ 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,
});
},
},
};
+196
View File
@@ -0,0 +1,196 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderGroupRequireMention } from "../../config/group-policy.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.telegram?.groups?.[chatId];
const groupDefault = cfg.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: NonNullable<ClawdbotConfig["discord"]>["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 resolveProviderGroupRequireMention({
cfg: params.cfg,
provider: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
});
}
export function resolveWhatsAppGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveProviderGroupRequireMention({
cfg: params.cfg,
provider: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveIMessageGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveProviderGroupRequireMention({
cfg: params.cfg,
provider: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveDiscordGroupRequireMention(
params: GroupMentionParams,
): boolean {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.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;
}
+22
View File
@@ -0,0 +1,22 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import type { ProviderPlugin } from "./types.js";
// Provider docking helper: use this when selecting the default account for a plugin.
export function resolveProviderDefaultAccountId<ResolvedAccount>(params: {
plugin: ProviderPlugin<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(providerId: string): string {
return `Approve via: clawdbot pairing list ${providerId} / clawdbot pairing approve ${providerId} <code>`;
}
+288
View File
@@ -0,0 +1,288 @@
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 { getChatProviderMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveProviderMediaMaxBytes } from "./media-limits.js";
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ProviderPlugin } from "./types.js";
const meta = getChatProviderMeta("imessage");
export const imessagePlugin: ProviderPlugin<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: ["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.imessage?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `imessage.accounts.${resolvedAccountId}.`
: "imessage.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("imessage"),
};
},
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "imessage",
accountId,
name,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToProviderSection({
cfg,
providerKey: "imessage",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "imessage",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
imessage: {
...next.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,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[accountId]: {
...next.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 = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "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 [
{
provider: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Provider error: ${lastError}`,
},
];
}),
buildProviderSummary: ({ 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,
});
},
},
};
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_IDS } from "../registry.js";
import { listProviderPlugins } from "./index.js";
describe("provider plugin registry", () => {
it("stays in sync with provider ids", () => {
const pluginIds = listProviderPlugins()
.map((plugin) => plugin.id)
.slice()
.sort();
const providerIds = [...PROVIDER_IDS].slice().sort();
expect(pluginIds).toEqual(providerIds);
});
});
+67
View File
@@ -0,0 +1,67 @@
import {
CHAT_PROVIDER_ORDER,
type ChatProviderId,
normalizeChatProviderId,
} 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 { ProviderId, ProviderPlugin } from "./types.js";
import { whatsappPlugin } from "./whatsapp.js";
// Provider plugins registry (runtime).
//
// This module is intentionally "heavy" (plugins may import provider monitors, web login, etc).
// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/providers/dock.ts`
// instead, and only call `getProviderPlugin()` at execution boundaries.
//
// Adding a provider:
// - add `<id>Plugin` import + entry in `resolveProviders()`
// - add an entry to `src/providers/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
// - add ids/aliases in `src/providers/registry.ts`
function resolveProviders(): ProviderPlugin[] {
return [
telegramPlugin,
whatsappPlugin,
discordPlugin,
slackPlugin,
signalPlugin,
imessagePlugin,
msteamsPlugin,
];
}
export function listProviderPlugins(): ProviderPlugin[] {
return resolveProviders().sort((a, b) => {
const indexA = CHAT_PROVIDER_ORDER.indexOf(a.id as ChatProviderId);
const indexB = CHAT_PROVIDER_ORDER.indexOf(b.id as ChatProviderId);
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 getProviderPlugin(id: ProviderId): ProviderPlugin | undefined {
return resolveProviders().find((plugin) => plugin.id === id);
}
export function normalizeProviderId(raw?: string | null): ProviderId | null {
// Provider docking: keep input normalization centralized in src/providers/registry.ts
// so CLI/API/protocol can rely on stable aliases without plugin init side effects.
return normalizeChatProviderId(raw);
}
export {
discordPlugin,
imessagePlugin,
msteamsPlugin,
signalPlugin,
slackPlugin,
telegramPlugin,
whatsappPlugin,
};
export type { ProviderId, ProviderPlugin } from "./types.js";
+31
View File
@@ -0,0 +1,31 @@
import type { ProviderId, ProviderPlugin } from "./types.js";
type PluginLoader = () => Promise<ProviderPlugin>;
// Provider docking: load *one* plugin on-demand.
//
// This avoids importing `src/providers/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing.
const LOADERS: Record<ProviderId, 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<ProviderId, ProviderPlugin>();
export async function loadProviderPlugin(
id: ProviderId,
): Promise<ProviderPlugin | 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;
}
+26
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 resolveProviderMediaMaxBytes(params: {
cfg: ClawdbotConfig;
// Provider-specific config lives under different keys; keep this helper generic
// so shared plugin helpers don't need provider-id branching.
resolveProviderLimitMb: (params: {
cfg: ClawdbotConfig;
accountId: string;
}) => number | undefined;
accountId?: string | null;
}): number | undefined {
const accountId = normalizeAccountId(params.accountId);
const providerLimit = params.resolveProviderLimitMb({
cfg: params.cfg,
accountId,
});
if (providerLimit) return providerLimit * MB;
if (params.cfg.agents?.defaults?.mediaMaxMb) {
return params.cfg.agents.defaults.mediaMaxMb * MB;
}
return undefined;
}
+41
View File
@@ -0,0 +1,41 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { getProviderPlugin, listProviderPlugins } from "./index.js";
import type {
ProviderMessageActionContext,
ProviderMessageActionName,
} from "./types.js";
export function listProviderMessageActions(
cfg: ClawdbotConfig,
): ProviderMessageActionName[] {
const actions = new Set<ProviderMessageActionName>(["send"]);
for (const plugin of listProviderPlugins()) {
const list = plugin.actions?.listActions?.({ cfg });
if (!list) continue;
for (const action of list) actions.add(action);
}
return Array.from(actions);
}
export function supportsProviderMessageButtons(cfg: ClawdbotConfig): boolean {
for (const plugin of listProviderPlugins()) {
if (plugin.actions?.supportsButtons?.({ cfg })) return true;
}
return false;
}
export async function dispatchProviderMessageAction(
ctx: ProviderMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {
const plugin = getProviderPlugin(ctx.provider);
if (!plugin?.actions?.handleAction) return null;
if (
plugin.actions.supportsAction &&
!plugin.actions.supportsAction({ action: ctx.action })
) {
return null;
}
return await plugin.actions.handleAction(ctx);
}
+200
View File
@@ -0,0 +1,200 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.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 { ProviderMessageActionName, ProviderPlugin } 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: ProviderPlugin<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: ["msteams"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.msteams)),
}),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
msteams: {
...cfg.msteams,
enabled,
},
}),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as Record<string, unknown>;
delete next.msteams;
return next as typeof cfg;
},
isConfigured: (_account, cfg) =>
Boolean(resolveMSTeamsCredentials(cfg.msteams)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
msteams: {
...cfg.msteams,
enabled: true,
},
}),
},
actions: {
listActions: ({ cfg }) => {
const enabled =
cfg.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.msteams));
if (!enabled) return [];
return ["poll"] satisfies ProviderMessageActionName[];
},
},
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 { provider: "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 { provider: "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,
},
buildProviderSummary: ({ 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.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,
});
},
},
};
+119
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;
}
+89
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 { ChatProviderId } from "../registry.js";
export type SetupProvidersOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ChatProviderId[]) => void;
accountIds?: Partial<Record<ChatProviderId, string>>;
onAccountId?: (provider: ChatProviderId, accountId: string) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromProviders?: ChatProviderId[];
skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean;
quickstartDefaults?: boolean;
initialSelection?: ChatProviderId[];
};
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 ProviderOnboardingStatus = {
provider: ChatProviderId;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
export type ProviderOnboardingStatusContext = {
cfg: ClawdbotConfig;
options?: SetupProvidersOptions;
accountOverrides: Partial<Record<ChatProviderId, string>>;
};
export type ProviderOnboardingConfigureContext = {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: SetupProvidersOptions;
accountOverrides: Partial<Record<ChatProviderId, string>>;
shouldPromptAccountIds: boolean;
forceAllowFrom: boolean;
};
export type ProviderOnboardingResult = {
cfg: ClawdbotConfig;
accountId?: string;
};
export type ProviderOnboardingDmPolicy = {
label: string;
provider: ChatProviderId;
policyKey: string;
allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig;
};
export type ProviderOnboardingAdapter = {
provider: ChatProviderId;
getStatus: (
ctx: ProviderOnboardingStatusContext,
) => Promise<ProviderOnboardingStatus>;
configure: (
ctx: ProviderOnboardingConfigureContext,
) => Promise<ProviderOnboardingResult>;
dmPolicy?: ProviderOnboardingDmPolicy;
onAccountRecorded?: (
accountId: string,
options?: SetupProvidersOptions,
) => void;
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
};
+194
View File
@@ -0,0 +1,194 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "discord" as const;
function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom)
: undefined;
return {
...cfg,
discord: {
...cfg.discord,
dm: {
...cfg.discord?.dm,
enabled: cfg.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: ProviderOnboardingDmPolicy = {
label: "Discord",
provider,
policyKey: "discord.dm.policy",
allowFromKey: "discord.dm.allowFrom",
getCurrent: (cfg) => cfg.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
};
export const discordOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
getStatus: async ({ cfg }) => {
const configured = listDiscordAccountIds(cfg).some((accountId) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
);
return {
provider,
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,
discord: {
...next.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,
discord: {
...next.discord,
enabled: true,
token,
},
};
} else {
next = {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[discordAccountId]: {
...next.discord?.accounts?.[discordAccountId],
enabled:
next.discord?.accounts?.[discordAccountId]?.enabled ?? true,
token,
},
},
},
};
}
}
return { cfg: next, accountId: discordAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
discord: { ...cfg.discord, enabled: false },
}),
};
@@ -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;
}
@@ -0,0 +1,164 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "imessage" as const;
function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.imessage?.allowFrom)
: undefined;
return {
...cfg,
imessage: {
...cfg.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
label: "iMessage",
provider,
policyKey: "imessage.dmPolicy",
allowFromKey: "imessage.allowFrom",
getCurrent: (cfg) => cfg.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
};
export const imessageOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
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.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath);
return {
provider,
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,
imessage: {
...next.imessage,
enabled: true,
cliPath: resolvedCliPath,
},
};
} else {
next = {
...next,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[imessageAccountId]: {
...next.imessage?.accounts?.[imessageAccountId],
enabled:
next.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,
imessage: { ...cfg.imessage, enabled: false },
}),
};
+193
View File
@@ -0,0 +1,193 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom } from "./helpers.js";
const provider = "msteams" as const;
function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.msteams?.allowFrom)?.map((entry) =>
String(entry),
)
: undefined;
return {
...cfg,
msteams: {
...cfg.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: ProviderOnboardingDmPolicy = {
label: "MS Teams",
provider,
policyKey: "msteams.dmPolicy",
allowFromKey: "msteams.allowFrom",
getCurrent: (cfg) => cfg.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
};
export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
getStatus: async ({ cfg }) => {
const configured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
return {
provider,
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.msteams);
const hasConfigCreds = Boolean(
cfg.msteams?.appId?.trim() &&
cfg.msteams?.appPassword?.trim() &&
cfg.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,
msteams: {
...next.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,
msteams: {
...next.msteams,
enabled: true,
appId,
appPassword,
tenantId,
},
};
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
msteams: { ...cfg.msteams, enabled: false },
}),
};
+206
View File
@@ -0,0 +1,206 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "signal" as const;
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.signal?.allowFrom)
: undefined;
return {
...cfg,
signal: {
...cfg.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
label: "Signal",
provider,
policyKey: "signal.dmPolicy",
allowFromKey: "signal.allowFrom",
getCurrent: (cfg) => cfg.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
};
export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
getStatus: async ({ cfg }) => {
const configured = listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
);
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return {
provider,
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 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,
signal: {
...next.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
};
} else {
next = {
...next,
signal: {
...next.signal,
enabled: true,
accounts: {
...next.signal?.accounts,
[signalAccountId]: {
...next.signal?.accounts?.[signalAccountId],
enabled:
next.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 providers.status --params '{\"probe\":true}'",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal next steps",
);
return { cfg: next, accountId: signalAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
signal: { ...cfg.signal, enabled: false },
}),
};
+309
View File
@@ -0,0 +1,309 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "slack" as const;
function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom)
: undefined;
return {
...cfg,
slack: {
...cfg.slack,
dm: {
...cfg.slack?.dm,
enabled: cfg.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: ProviderOnboardingDmPolicy = {
label: "Slack",
provider,
policyKey: "slack.dm.policy",
allowFromKey: "slack.dm.allowFrom",
getCurrent: (cfg) => cfg.slack?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
};
export const slackOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
getStatus: async ({ cfg }) => {
const configured = listSlackAccountIds(cfg).some((accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
return Boolean(account.botToken && account.appToken);
});
return {
provider,
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,
slack: {
...next.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,
slack: {
...next.slack,
enabled: true,
botToken,
appToken,
},
};
} else {
next = {
...next,
slack: {
...next.slack,
enabled: true,
accounts: {
...next.slack?.accounts,
[slackAccountId]: {
...next.slack?.accounts?.[slackAccountId],
enabled:
next.slack?.accounts?.[slackAccountId]?.enabled ?? true,
botToken,
appToken,
},
},
},
};
}
}
return { cfg: next, accountId: slackAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
slack: { ...cfg.slack, enabled: false },
}),
};
@@ -0,0 +1,262 @@
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 {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "telegram" as const;
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.telegram?.allowFrom)
: undefined;
return {
...cfg,
telegram: {
...cfg.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,
telegram: {
...cfg.telegram,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
};
}
return {
...cfg,
telegram: {
...cfg.telegram,
enabled: true,
accounts: {
...cfg.telegram?.accounts,
[accountId]: {
...cfg.telegram?.accounts?.[accountId],
enabled: cfg.telegram?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
label: "Telegram",
provider,
policyKey: "telegram.dmPolicy",
allowFromKey: "telegram.allowFrom",
getCurrent: (cfg) => cfg.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
};
export const telegramOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) =>
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
);
return {
provider,
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,
telegram: {
...next.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,
telegram: {
...next.telegram,
enabled: true,
botToken: token,
},
};
} else {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
accounts: {
...next.telegram?.accounts,
[telegramAccountId]: {
...next.telegram?.accounts?.[telegramAccountId],
enabled:
next.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,
telegram: { ...cfg.telegram, enabled: false },
}),
};
@@ -0,0 +1,399 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ClawdbotConfig } from "../../../config/config.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import type { DmPolicy } from "../../../config/types.js";
import { loginWeb } from "../../../provider-web.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 { ProviderOnboardingAdapter } from "../onboarding-types.js";
import { promptAccountId } from "./helpers.js";
const provider = "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.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.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 `whatsapp.dmPolicy` + `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: ProviderOnboardingAdapter = {
provider,
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 {
provider,
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,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[accountId]: {
...next.whatsapp?.accounts?.[accountId],
enabled: next.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 providers login` later to link WhatsApp.",
"WhatsApp",
);
}
next = await promptWhatsAppAllowFrom(next, runtime, prompter, {
forceAllowlist: forceAllowFrom,
});
return { cfg: next, accountId };
},
onAccountRecorded: (accountId, options) => {
options?.onWhatsAppAccountId?.(accountId);
},
};
+44
View File
@@ -0,0 +1,44 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import type { ProviderOutboundAdapter } from "../types.js";
export const discordOutbound: ProviderOutboundAdapter = {
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 { provider: "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 { provider: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
}),
};
@@ -0,0 +1,53 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageIMessage } from "../../../imessage/send.js";
import { resolveProviderMediaMaxBytes } from "../media-limits.js";
import type { ProviderOutboundAdapter } from "../types.js";
export const imessageOutbound: ProviderOutboundAdapter = {
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 = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
},
};
+32
View File
@@ -0,0 +1,32 @@
import type { ProviderId, ProviderOutboundAdapter } from "../types.js";
type OutboundLoader = () => Promise<ProviderOutboundAdapter>;
// Provider docking: outbound sends should stay cheap to import.
//
// The full provider plugins (src/providers/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<ProviderId, 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<ProviderId, ProviderOutboundAdapter>();
export async function loadProviderOutboundAdapter(
id: ProviderId,
): Promise<ProviderOutboundAdapter | 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;
}
+60
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 { ProviderOutboundAdapter } from "../types.js";
export const msteamsOutbound: ProviderOutboundAdapter = {
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 { provider: "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 { provider: "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;
},
};
+51
View File
@@ -0,0 +1,51 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageSignal } from "../../../signal/send.js";
import { resolveProviderMediaMaxBytes } from "../media-limits.js";
import type { ProviderOutboundAdapter } from "../types.js";
export const signalOutbound: ProviderOutboundAdapter = {
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 = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "signal", ...result };
},
};
+37
View File
@@ -0,0 +1,37 @@
import { sendMessageSlack } from "../../../slack/send.js";
import type { ProviderOutboundAdapter } from "../types.js";
export const slackOutbound: ProviderOutboundAdapter = {
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 { provider: "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 { provider: "slack", ...result };
},
};
@@ -0,0 +1,56 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ProviderOutboundAdapter } 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: ProviderOutboundAdapter = {
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 { provider: "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 { provider: "telegram", ...result };
},
};
@@ -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 { ProviderOutboundAdapter } from "../types.js";
export const whatsappOutbound: ProviderOutboundAdapter = {
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 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 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 { provider: "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 { provider: "whatsapp", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
}),
};
+2
View File
@@ -0,0 +1,2 @@
export const PAIRING_APPROVED_MESSAGE =
"✅ Clawdbot access approved. Send a message to start chatting.";
+68
View File
@@ -0,0 +1,68 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
getProviderPlugin,
listProviderPlugins,
normalizeProviderId,
type ProviderId,
} from "./index.js";
import type { ProviderPairingAdapter } from "./types.js";
export function listPairingProviders(): ProviderId[] {
// Provider docking: pairing support is declared via plugin.pairing.
return listProviderPlugins()
.filter((plugin) => plugin.pairing)
.map((plugin) => plugin.id);
}
export function getPairingAdapter(
providerId: ProviderId,
): ProviderPairingAdapter | null {
const plugin = getProviderPlugin(providerId);
return plugin?.pairing ?? null;
}
export function requirePairingAdapter(
providerId: ProviderId,
): ProviderPairingAdapter {
const adapter = getPairingAdapter(providerId);
if (!adapter) {
throw new Error(`Provider ${providerId} does not support pairing`);
}
return adapter;
}
export function resolvePairingProvider(raw: unknown): ProviderId {
const value = (
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "boolean"
? String(raw)
: ""
)
.trim()
.toLowerCase();
const normalized = normalizeProviderId(value);
const providers = listPairingProviders();
if (!normalized || !providers.includes(normalized)) {
throw new Error(
`Invalid provider: ${value || "(empty)"} (expected one of: ${providers.join(", ")})`,
);
}
return normalized;
}
export async function notifyPairingApproved(params: {
providerId: ProviderId;
id: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
}): Promise<void> {
const adapter = requirePairingAdapter(params.providerId);
if (!adapter.notifyApproval) return;
await adapter.notifyApproval({
cfg: params.cfg,
id: params.id,
runtime: params.runtime,
});
}
+116
View File
@@ -0,0 +1,116 @@
import type { ClawdbotConfig } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
type ProviderSectionBase = {
name?: string;
accounts?: Record<string, Record<string, unknown>>;
};
function providerHasAccounts(
cfg: ClawdbotConfig,
providerKey: string,
): boolean {
const base = (cfg as Record<string, unknown>)[providerKey] as
| ProviderSectionBase
| undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(params: {
cfg: ClawdbotConfig;
providerKey: string;
accountId: string;
alwaysUseAccounts?: boolean;
}): boolean {
if (params.alwaysUseAccounts) return true;
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
return providerHasAccounts(params.cfg, params.providerKey);
}
export function applyAccountNameToProviderSection(params: {
cfg: ClawdbotConfig;
providerKey: string;
accountId: string;
name?: string;
alwaysUseAccounts?: boolean;
}): ClawdbotConfig {
const trimmed = params.name?.trim();
if (!trimmed) return params.cfg;
const accountId = normalizeAccountId(params.accountId);
const baseConfig = (params.cfg as Record<string, unknown>)[
params.providerKey
];
const base =
typeof baseConfig === "object" && baseConfig
? (baseConfig as ProviderSectionBase)
: undefined;
const useAccounts = shouldStoreNameInAccounts({
cfg: params.cfg,
providerKey: params.providerKey,
accountId,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const safeBase = base ?? {};
return {
...params.cfg,
[params.providerKey]: {
...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,
[params.providerKey]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
} as ClawdbotConfig;
}
export function migrateBaseNameToDefaultAccount(params: {
cfg: ClawdbotConfig;
providerKey: string;
alwaysUseAccounts?: boolean;
}): ClawdbotConfig {
if (params.alwaysUseAccounts) return params.cfg;
const base = (params.cfg as Record<string, unknown>)[params.providerKey] as
| ProviderSectionBase
| 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,
[params.providerKey]: {
...rest,
accounts,
},
} as ClawdbotConfig;
}
+314
View File
@@ -0,0 +1,314 @@
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 { getChatProviderMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveProviderMediaMaxBytes } 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 {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ProviderPlugin } from "./types.js";
const meta = getChatProviderMeta("signal");
export const signalPlugin: ProviderPlugin<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: ["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.signal?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `signal.accounts.${resolvedAccountId}.`
: "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()),
};
},
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "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 = applyAccountNameToProviderSection({
cfg,
providerKey: "signal",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "signal",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
signal: {
...next.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,
signal: {
...next.signal,
enabled: true,
accounts: {
...next.signal?.accounts,
[accountId]: {
...next.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 = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveProviderMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "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 [
{
provider: "signal",
accountId: account.accountId,
kind: "runtime",
message: `Provider error: ${lastError}`,
},
];
}),
buildProviderSummary: ({ 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,
});
},
},
};
+505
View File
@@ -0,0 +1,505 @@
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 { getChatProviderMeta } 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 {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ProviderMessageActionName, ProviderPlugin } from "./types.js";
const meta = getChatProviderMeta("slack");
export const slackPlugin: ProviderPlugin<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: ["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.slack?.accounts?.[resolvedAccountId]);
const allowFromPath = useAccountPath
? `slack.accounts.${resolvedAccountId}.dm.`
: "slack.dm.";
return {
policy: account.dm?.policy ?? "pairing",
allowFrom: account.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("slack"),
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
};
},
},
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.slack?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate(key, defaultValue)) return true;
}
return false;
};
const actions = new Set<ProviderMessageActionName>(["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 }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "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 = applyAccountNameToProviderSection({
cfg,
providerKey: "slack",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "slack",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
slack: {
...next.slack,
enabled: true,
...(input.useEnv
? {}
: {
...(input.botToken ? { botToken: input.botToken } : {}),
...(input.appToken ? { appToken: input.appToken } : {}),
}),
},
};
}
return {
...next,
slack: {
...next.slack,
enabled: true,
accounts: {
...next.slack?.accounts,
[accountId]: {
...next.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 { provider: "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 { provider: "slack", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildProviderSummary: ({ 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,
});
},
},
};
@@ -0,0 +1,145 @@
import type { ProviderAccountSnapshot, ProviderStatusIssue } 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: ProviderAccountSnapshot,
): 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: ProviderAccountSnapshot[],
): ProviderStatusIssue[] {
const issues: ProviderStatusIssue[] = [];
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({
provider: "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({
provider: "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 discord.guilds.*.channels (then rerun providers 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({
provider: "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;
}
@@ -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);
}
@@ -0,0 +1,123 @@
import type { ProviderAccountSnapshot, ProviderStatusIssue } 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: ProviderAccountSnapshot,
): 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: ProviderAccountSnapshot[],
): ProviderStatusIssue[] {
const issues: ProviderStatusIssue[] = [];
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({
provider: "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({
provider: "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 telegram.groups (or per-account groups) to enable probing.",
});
}
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
issues.push({
provider: "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 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({
provider: "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;
}
@@ -0,0 +1,70 @@
import type { ProviderAccountSnapshot, ProviderStatusIssue } 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: ProviderAccountSnapshot,
): 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: ProviderAccountSnapshot[],
): ProviderStatusIssue[] {
const issues: ProviderStatusIssue[] = [];
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({
provider: "whatsapp",
accountId,
kind: "auth",
message: "Not linked (no WhatsApp Web session).",
fix: "Run: clawdbot providers login (scan QR on the gateway host).",
});
continue;
}
if (running && !connected) {
issues.push({
provider: "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 providers login and check logs.",
});
}
}
return issues;
}
+39
View File
@@ -0,0 +1,39 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ProviderAccountSnapshot, ProviderPlugin } from "./types.js";
// Provider docking: status snapshots flow through plugin.status hooks here.
export async function buildProviderAccountSnapshot<ResolvedAccount>(params: {
plugin: ProviderPlugin<ResolvedAccount>;
cfg: ClawdbotConfig;
accountId: string;
runtime?: ProviderAccountSnapshot;
probe?: unknown;
audit?: unknown;
}): Promise<ProviderAccountSnapshot> {
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,
};
}
+465
View File
@@ -0,0 +1,465 @@
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 { getChatProviderMeta } 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 {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectTelegramStatusIssues } from "./status-issues/telegram.js";
import type { ProviderPlugin } from "./types.js";
const meta = getChatProviderMeta("telegram");
export const telegramPlugin: ProviderPlugin<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: ["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.telegram?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `telegram.accounts.${resolvedAccountId}.`
: "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 ?? "open";
const groupAllowlistConfigured =
account.config.groups && Object.keys(account.config.groups).length > 0;
if (groupPolicy !== "open" || groupAllowlistConfigured) return [];
return [
`- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`,
];
},
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.telegram?.replyToMode ?? "first",
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
},
actions: telegramMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "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 = applyAccountNameToProviderSection({
cfg,
providerKey: "telegram",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "telegram",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
telegram: {
...next.telegram,
enabled: true,
...(input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
};
}
return {
...next,
telegram: {
...next.telegram,
enabled: true,
accounts: {
...next.telegram?.accounts,
[accountId]: {
...next.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 { provider: "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 { provider: "telegram", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectTelegramStatusIssues,
buildProviderSummary: ({ 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.telegram?.accounts?.[account.accountId]?.groups ??
cfg.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.telegram?.accounts?.[account.accountId]?.groups ??
cfg.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.telegram ? { ...cfg.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.telegram = nextTelegram;
} else {
delete nextCfg.telegram;
}
}
const resolved = resolveTelegramAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
if (changed) {
await writeConfigFile(nextCfg);
}
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
};
+580
View File
@@ -0,0 +1,580 @@
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-provider.js";
import type { ChatProviderId } from "../registry.js";
import type { ProviderOnboardingAdapter } from "./onboarding-types.js";
export type ProviderId = ChatProviderId;
export type ProviderOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
export type ProviderAgentTool = AgentTool<TSchema, unknown>;
export type ProviderAgentToolFactory = (params: {
cfg?: ClawdbotConfig;
}) => ProviderAgentTool[];
export type ProviderSetupInput = {
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 ProviderStatusIssue = {
provider: ProviderId;
accountId: string;
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
message: string;
fix?: string;
};
export type ProviderAccountState =
| "linked"
| "not linked"
| "configured"
| "not configured"
| "enabled"
| "disabled";
export type ProviderSetupAdapter = {
resolveAccountId?: (params: {
cfg: ClawdbotConfig;
accountId?: string;
}) => string;
applyAccountName?: (params: {
cfg: ClawdbotConfig;
accountId: string;
name?: string;
}) => ClawdbotConfig;
applyAccountConfig: (params: {
cfg: ClawdbotConfig;
accountId: string;
input: ProviderSetupInput;
}) => ClawdbotConfig;
validateInput?: (params: {
cfg: ClawdbotConfig;
accountId: string;
input: ProviderSetupInput;
}) => string | null;
};
export type ProviderHeartbeatDeps = {
webAuthExists?: () => Promise<boolean>;
hasActiveWebListener?: () => boolean;
};
export type ProviderMeta = {
id: ProviderId;
label: string;
selectionLabel: string;
docsPath: string;
docsLabel?: string;
blurb: string;
order?: number;
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
};
export type ProviderAccountSnapshot = {
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 ProviderLogSink = {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
export type ProviderConfigAdapter<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,
) => ProviderAccountSnapshot;
resolveAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => string[] | undefined;
formatAllowFrom?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
};
export type ProviderGroupContext = {
cfg: ClawdbotConfig;
groupId?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
export type ProviderGroupAdapter = {
resolveRequireMention?: (params: ProviderGroupContext) => boolean | undefined;
resolveGroupIntroHint?: (params: ProviderGroupContext) => string | undefined;
};
export type ProviderOutboundContext = {
cfg: ClawdbotConfig;
to: string;
text: string;
mediaUrl?: string;
gifPlayback?: boolean;
replyToId?: string | null;
threadId?: number | null;
accountId?: string | null;
deps?: OutboundSendDeps;
};
export type ProviderPollResult = {
messageId: string;
toJid?: string;
channelId?: string;
conversationId?: string;
pollId?: string;
};
export type ProviderPollContext = {
cfg: ClawdbotConfig;
to: string;
poll: PollInput;
accountId?: string | null;
};
export type ProviderOutboundAdapter = {
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?: ProviderOutboundTargetMode;
}) => { ok: true; to: string } | { ok: false; error: Error };
sendText?: (ctx: ProviderOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ProviderOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ProviderPollContext) => Promise<ProviderPollResult>;
};
export type ProviderStatusAdapter<ResolvedAccount> = {
defaultRuntime?: ProviderAccountSnapshot;
buildProviderSummary?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
defaultAccountId: string;
snapshot: ProviderAccountSnapshot;
}) => 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?: ProviderAccountSnapshot;
probe?: unknown;
audit?: unknown;
}) => ProviderAccountSnapshot | Promise<ProviderAccountSnapshot>;
logSelfId?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
includeProviderPrefix?: boolean;
}) => void;
resolveAccountState?: (params: {
account: ResolvedAccount;
cfg: ClawdbotConfig;
configured: boolean;
enabled: boolean;
}) => ProviderAccountState;
collectStatusIssues?: (
accounts: ProviderAccountSnapshot[],
) => ProviderStatusIssue[];
};
export type ProviderGatewayContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId: string;
account: ResolvedAccount;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
log?: ProviderLogSink;
getStatus: () => ProviderAccountSnapshot;
setStatus: (next: ProviderAccountSnapshot) => void;
};
export type ProviderLogoutResult = {
cleared: boolean;
loggedOut?: boolean;
[key: string]: unknown;
};
export type ProviderLoginWithQrStartResult = {
qrDataUrl?: string;
message: string;
};
export type ProviderLoginWithQrWaitResult = {
connected: boolean;
message: string;
};
export type ProviderLogoutContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId: string;
account: ResolvedAccount;
runtime: RuntimeEnv;
log?: ProviderLogSink;
};
export type ProviderPairingAdapter = {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: {
cfg: ClawdbotConfig;
id: string;
runtime?: RuntimeEnv;
}) => Promise<void>;
};
export type ProviderGatewayAdapter<ResolvedAccount = unknown> = {
startAccount?: (
ctx: ProviderGatewayContext<ResolvedAccount>,
) => Promise<unknown>;
stopAccount?: (ctx: ProviderGatewayContext<ResolvedAccount>) => Promise<void>;
loginWithQrStart?: (params: {
accountId?: string;
force?: boolean;
timeoutMs?: number;
verbose?: boolean;
}) => Promise<ProviderLoginWithQrStartResult>;
loginWithQrWait?: (params: {
accountId?: string;
timeoutMs?: number;
}) => Promise<ProviderLoginWithQrWaitResult>;
logoutAccount?: (
ctx: ProviderLogoutContext<ResolvedAccount>,
) => Promise<ProviderLogoutResult>;
};
export type ProviderAuthAdapter = {
login?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
runtime: RuntimeEnv;
verbose?: boolean;
providerInput?: string | null;
}) => Promise<void>;
};
export type ProviderHeartbeatAdapter = {
checkReady?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
deps?: ProviderHeartbeatDeps;
}) => Promise<{ ok: boolean; reason: string }>;
resolveRecipients?: (params: {
cfg: ClawdbotConfig;
opts?: { to?: string; all?: boolean };
}) => { recipients: string[]; source: string };
};
export type ProviderCapabilities = {
chatTypes: Array<"direct" | "group" | "channel" | "thread">;
polls?: boolean;
reactions?: boolean;
threads?: boolean;
media?: boolean;
nativeCommands?: boolean;
blockStreaming?: boolean;
};
export type ProviderElevatedAdapter = {
allowFromFallback?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => Array<string | number> | undefined;
};
export type ProviderCommandAdapter = {
enforceOwnerForCommands?: boolean;
skipWhenConfigEmpty?: boolean;
};
export type ProviderSecurityDmPolicy = {
policy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
allowFromPath: string;
approveHint: string;
normalizeEntry?: (raw: string) => string;
};
export type ProviderSecurityContext<ResolvedAccount = unknown> = {
cfg: ClawdbotConfig;
accountId?: string | null;
account: ResolvedAccount;
};
export type ProviderSecurityAdapter<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ProviderSecurityContext<ResolvedAccount>,
) => ProviderSecurityDmPolicy | null;
collectWarnings?: (
ctx: ProviderSecurityContext<ResolvedAccount>,
) => Promise<string[]> | string[];
};
export type ProviderMentionAdapter = {
stripPatterns?: (params: {
ctx: MsgContext;
cfg: ClawdbotConfig | undefined;
agentId?: string;
}) => string[];
stripMentions?: (params: {
text: string;
ctx: MsgContext;
cfg: ClawdbotConfig | undefined;
agentId?: string;
}) => string;
};
export type ProviderStreamingAdapter = {
blockStreamingCoalesceDefaults?: {
minChars: number;
idleMs: number;
};
};
export type ProviderThreadingAdapter = {
resolveReplyToMode?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) => "off" | "first" | "all";
allowTagsWhenOff?: boolean;
buildToolContext?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
context: ProviderThreadingContext;
hasRepliedRef?: { value: boolean };
}) => ProviderThreadingToolContext | undefined;
};
export type ProviderThreadingContext = {
Provider?: string;
To?: string;
ReplyToId?: string;
ThreadLabel?: string;
};
export type ProviderThreadingToolContext = {
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
};
export type ProviderMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
};
export type ProviderMessageActionName =
| "send"
| "poll"
| "react"
| "reactions"
| "read"
| "edit"
| "delete"
| "pin"
| "unpin"
| "list-pins"
| "permissions"
| "thread-create"
| "thread-list"
| "thread-reply"
| "search"
| "sticker"
| "member-info"
| "role-info"
| "emoji-list"
| "emoji-upload"
| "sticker-upload"
| "role-add"
| "role-remove"
| "channel-info"
| "channel-list"
| "voice-status"
| "event-list"
| "event-create"
| "timeout"
| "kick"
| "ban";
export type ProviderMessageActionContext = {
provider: ProviderId;
action: ProviderMessageActionName;
cfg: ClawdbotConfig;
params: Record<string, unknown>;
accountId?: string | null;
gateway?: {
url?: string;
token?: string;
timeoutMs?: number;
clientName: GatewayClientName;
clientDisplayName?: string;
mode: GatewayClientMode;
};
toolContext?: ProviderThreadingToolContext;
dryRun?: boolean;
};
export type ProviderToolSend = {
to: string;
accountId?: string | null;
};
export type ProviderMessageActionAdapter = {
listActions?: (params: {
cfg: ClawdbotConfig;
}) => ProviderMessageActionName[];
supportsAction?: (params: { action: ProviderMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean;
extractToolSend?: (params: {
args: Record<string, unknown>;
}) => ProviderToolSend | null;
handleAction?: (
ctx: ProviderMessageActionContext,
) => Promise<AgentToolResult<unknown>>;
};
// Provider docking: implement this contract in src/providers/plugins/<id>.ts.
// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types.
export type ProviderPlugin<ResolvedAccount = any> = {
id: ProviderId;
meta: ProviderMeta;
capabilities: ProviderCapabilities;
reload?: { configPrefixes: string[]; noopPrefixes?: string[] };
// CLI onboarding wizard hooks for this provider.
onboarding?: ProviderOnboardingAdapter;
config: ProviderConfigAdapter<ResolvedAccount>;
setup?: ProviderSetupAdapter;
pairing?: ProviderPairingAdapter;
security?: ProviderSecurityAdapter<ResolvedAccount>;
groups?: ProviderGroupAdapter;
mentions?: ProviderMentionAdapter;
outbound?: ProviderOutboundAdapter;
status?: ProviderStatusAdapter<ResolvedAccount>;
gatewayMethods?: string[];
gateway?: ProviderGatewayAdapter<ResolvedAccount>;
auth?: ProviderAuthAdapter;
elevated?: ProviderElevatedAdapter;
commands?: ProviderCommandAdapter;
streaming?: ProviderStreamingAdapter;
threading?: ProviderThreadingAdapter;
messaging?: ProviderMessagingAdapter;
actions?: ProviderMessageActionAdapter;
heartbeat?: ProviderHeartbeatAdapter;
// Provider-owned agent tools (login flows, etc.).
agentTools?: ProviderAgentToolFactory | ProviderAgentTool[];
};
@@ -0,0 +1,77 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { normalizeChatProviderId } from "../../providers/registry.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:
normalizeChatProviderId(entry?.lastProvider) === "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.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
? cfg.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" };
}
+476
View File
@@ -0,0 +1,476 @@
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 { getChatProviderMeta } 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 {
applyAccountNameToProviderSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js";
import type { ProviderMessageActionName, ProviderPlugin } from "./types.js";
import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js";
const meta = getChatProviderMeta("whatsapp");
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const whatsappPlugin: ProviderPlugin<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: ["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.whatsapp?.accounts };
const existing = accounts[accountKey] ?? {};
return {
...cfg,
whatsapp: {
...cfg.whatsapp,
accounts: {
...accounts,
[accountKey]: {
...existing,
enabled,
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const accountKey = accountId || DEFAULT_ACCOUNT_ID;
const accounts = { ...cfg.whatsapp?.accounts };
delete accounts[accountKey];
return {
...cfg,
whatsapp: {
...cfg.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.whatsapp?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `whatsapp.accounts.${resolvedAccountId}.`
: "whatsapp.";
return {
policy: account.dmPolicy ?? "pairing",
allowFrom: account.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("whatsapp"),
normalizeEntry: (raw) => normalizeE164(raw),
};
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
cfg,
providerKey: "whatsapp",
accountId,
name,
alwaysUseAccounts: true,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToProviderSection({
cfg,
providerKey: "whatsapp",
accountId,
name: input.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "whatsapp",
alwaysUseAccounts: true,
});
const entry = {
...next.whatsapp?.accounts?.[accountId],
...(input.authDir ? { authDir: input.authDir } : {}),
enabled: true,
};
return {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.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.whatsapp) return [];
const gate = createActionGate(cfg.whatsapp.actions);
const actions = new Set<ProviderMessageActionName>();
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 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 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 { provider: "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 { provider: "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,
buildProviderSummary: 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, includeProviderPrefix }) => {
logWebSelfId(account.authDir, runtime, includeProviderPrefix);
},
},
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 { monitorWebProvider } = await import("../web/index.js");
return monitorWebProvider(
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 };
},
},
};
+1
View File
@@ -10,6 +10,7 @@ describe("provider registry", () => {
it("normalizes aliases", () => {
expect(normalizeChatProviderId("imsg")).toBe("imessage");
expect(normalizeChatProviderId("teams")).toBe("msteams");
expect(normalizeChatProviderId("web")).toBeNull();
});
it("keeps Telegram first in the default order", () => {
+48 -15
View File
@@ -1,5 +1,5 @@
import { normalizeMessageProvider } from "../utils/message-provider.js";
// Provider docking: add new providers here (order + meta + aliases), then
// register the plugin in src/providers/plugins/index.ts and keep protocol IDs in sync.
export const CHAT_PROVIDER_ORDER = [
"telegram",
"whatsapp",
@@ -12,6 +12,10 @@ export const CHAT_PROVIDER_ORDER = [
export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number];
export const PROVIDER_IDS = [...CHAT_PROVIDER_ORDER] as const;
export const DEFAULT_CHAT_PROVIDER: ChatProviderId = "whatsapp";
export type ChatProviderMeta = {
id: ChatProviderId;
label: string;
@@ -19,8 +23,15 @@ export type ChatProviderMeta = {
docsPath: string;
docsLabel?: string;
blurb: string;
// Provider docking: selection-line formatting for onboarding prompts.
// Keep this data-driven to avoid provider-specific branches in shared code.
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
};
const WEBSITE_URL = "https://clawd.bot";
const CHAT_PROVIDER_META: Record<ChatProviderId, ChatProviderMeta> = {
telegram: {
id: "telegram",
@@ -30,6 +41,9 @@ const CHAT_PROVIDER_META: Record<ChatProviderId, ChatProviderMeta> = {
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",
@@ -82,12 +96,24 @@ const CHAT_PROVIDER_META: Record<ChatProviderId, ChatProviderMeta> = {
},
};
const WEBSITE_URL = "https://clawd.bot";
export const CHAT_PROVIDER_ALIASES: Record<string, ChatProviderId> = {
imsg: "imessage",
teams: "msteams",
};
const normalizeProviderKey = (raw?: string | null): string | undefined => {
const normalized = raw?.trim().toLowerCase();
return normalized || undefined;
};
export function listChatProviders(): ChatProviderMeta[] {
return CHAT_PROVIDER_ORDER.map((id) => CHAT_PROVIDER_META[id]);
}
export function listChatProviderAliases(): string[] {
return Object.keys(CHAT_PROVIDER_ALIASES);
}
export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta {
return CHAT_PROVIDER_META[id];
}
@@ -95,13 +121,22 @@ export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta {
export function normalizeChatProviderId(
raw?: string | null,
): ChatProviderId | null {
const normalized = normalizeMessageProvider(raw);
const normalized = normalizeProviderKey(raw);
if (!normalized) return null;
return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId)
? (normalized as ChatProviderId)
const resolved = CHAT_PROVIDER_ALIASES[normalized] ?? normalized;
return CHAT_PROVIDER_ORDER.includes(resolved as ChatProviderId)
? (resolved as ChatProviderId)
: null;
}
// Provider docking: prefer this helper in shared code. Importing from
// `src/providers/plugins/*` can eagerly load provider implementations.
export function normalizeProviderId(
raw?: string | null,
): ChatProviderId | null {
return normalizeChatProviderId(raw);
}
export function formatProviderPrimerLine(meta: ChatProviderMeta): string {
return `${meta.label}: ${meta.blurb}`;
}
@@ -110,13 +145,11 @@ export function formatProviderSelectionLine(
meta: ChatProviderMeta,
docsLink: (path: string, label?: string) => string,
): string {
if (meta.id === "telegram") {
return `${meta.label}${meta.blurb} ${docsLink(
meta.docsPath,
)} ${WEBSITE_URL}`;
}
return `${meta.label}${meta.blurb} Docs: ${docsLink(
meta.docsPath,
meta.docsLabel ?? meta.id,
)}`;
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}` : ""}`;
}