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

View File

@@ -12,6 +12,7 @@ import {
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveSessionTranscriptPath,
@@ -26,6 +27,9 @@ import {
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { isAudioFileName } from "../../media/mime.js";
import { getProviderDock } from "../../providers/dock.js";
import type { ProviderThreadingToolContext } from "../../providers/plugins/types.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { defaultRuntime } from "../../runtime.js";
import {
estimateUsageCost,
@@ -70,47 +74,32 @@ const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
/**
* Build Slack-specific threading context for tool auto-injection.
* Returns undefined values for non-Slack providers.
* Build provider-specific threading context for tool auto-injection.
*/
function buildSlackThreadingContext(params: {
function buildThreadingToolContext(params: {
sessionCtx: TemplateContext;
config: { slack?: { replyToMode?: "off" | "first" | "all" } } | undefined;
config: ClawdbotConfig | undefined;
hasRepliedRef: { value: boolean } | undefined;
}): {
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
replyToMode: "off" | "first" | "all" | undefined;
hasRepliedRef: { value: boolean } | undefined;
} {
}): ProviderThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params;
const isSlack = sessionCtx.Provider?.toLowerCase() === "slack";
if (!isSlack) {
return {
currentChannelId: undefined,
currentThreadTs: undefined,
replyToMode: undefined,
hasRepliedRef: undefined,
};
}
// If we're already inside a thread, never jump replies out of it (even in
// replyToMode="off"/"first"). This keeps tool calls consistent with the
// auto-reply path.
const configuredReplyToMode = config?.slack?.replyToMode ?? "off";
const effectiveReplyToMode = sessionCtx.ThreadLabel
? ("all" as const)
: configuredReplyToMode;
return {
// Extract channel from "channel:C123" format
currentChannelId: sessionCtx.To?.startsWith("channel:")
? sessionCtx.To.slice("channel:".length)
: undefined,
currentThreadTs: sessionCtx.ReplyToId,
replyToMode: effectiveReplyToMode,
hasRepliedRef,
};
if (!config) return {};
const provider = normalizeProviderId(sessionCtx.Provider);
if (!provider) return {};
const dock = getProviderDock(provider);
if (!dock?.threading?.buildToolContext) return {};
return (
dock.threading.buildToolContext({
cfg: config,
accountId: sessionCtx.AccountId,
context: {
Provider: sessionCtx.Provider,
To: sessionCtx.To,
ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel,
},
hasRepliedRef,
}) ?? {}
);
}
const isBunFetchSocketError = (message?: string) =>
@@ -282,6 +271,7 @@ export async function runReplyAgent(params: {
const replyToMode = resolveReplyToMode(
followupRun.run.config,
replyToChannel,
sessionCtx.AccountId,
);
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
@@ -437,8 +427,8 @@ export async function runReplyAgent(params: {
messageProvider:
sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
// Slack threading context for tool auto-injection
...buildSlackThreadingContext({
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx,
config: followupRun.run.config,
hasRepliedRef: opts?.hasRepliedRef,

View File

@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramDraftStreamingChunking } from "./block-streaming.js";
describe("resolveTelegramDraftStreamingChunking", () => {
it("uses smaller defaults than block streaming", () => {
const chunking = resolveTelegramDraftStreamingChunking(
undefined,
"default",
);
expect(chunking).toEqual({
minChars: 200,
maxChars: 800,
breakPreference: "paragraph",
});
});
it("clamps to telegram.textChunkLimit", () => {
const cfg: ClawdbotConfig = {
telegram: { allowFrom: ["*"], textChunkLimit: 150 },
};
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
expect(chunking).toEqual({
minChars: 150,
maxChars: 150,
breakPreference: "paragraph",
});
});
it("supports per-account overrides", () => {
const cfg: ClawdbotConfig = {
telegram: {
allowFrom: ["*"],
accounts: {
default: {
allowFrom: ["*"],
draftChunk: {
minChars: 10,
maxChars: 20,
breakPreference: "sentence",
},
},
},
},
};
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
expect(chunking).toEqual({
minChars: 10,
maxChars: 20,
breakPreference: "sentence",
});
});
});

View File

@@ -1,29 +1,17 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId, PROVIDER_IDS } from "../../providers/registry.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200;
const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800;
const PROVIDER_COALESCE_DEFAULTS: Partial<
Record<TextChunkProvider, { minChars: number; idleMs: number }>
> = {
signal: { minChars: 1500, idleMs: 1000 },
slack: { minChars: 1500, idleMs: 1000 },
discord: { minChars: 1500, idleMs: 1000 },
};
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"webchat",
"msteams",
...PROVIDER_IDS,
INTERNAL_MESSAGE_PROVIDER,
]);
function normalizeChunkProvider(
@@ -36,6 +24,29 @@ function normalizeChunkProvider(
: undefined;
}
type ProviderBlockStreamingConfig = {
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
accounts?: Record<
string,
{ blockStreamingCoalesce?: BlockStreamingCoalesceConfig }
>;
};
function resolveProviderBlockStreamingCoalesce(params: {
cfg: ClawdbotConfig | undefined;
providerKey?: TextChunkProvider;
accountId?: string | null;
}): BlockStreamingCoalesceConfig | undefined {
const { cfg, providerKey, accountId } = params;
if (!cfg || !providerKey) return undefined;
const providerCfg = (cfg as Record<string, unknown>)[providerKey];
if (!providerCfg || typeof providerCfg !== "object") return undefined;
const normalizedAccountId = normalizeAccountId(accountId);
const typed = providerCfg as ProviderBlockStreamingConfig;
const accountCfg = typed.accounts?.[normalizedAccountId];
return accountCfg?.blockStreamingCoalesce ?? typed.blockStreamingCoalesce;
}
export type BlockStreamingCoalescing = {
minChars: number;
maxChars: number;
@@ -53,7 +64,13 @@ export function resolveBlockStreamingChunking(
breakPreference: "paragraph" | "newline" | "sentence";
} {
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
const providerId = providerKey ? normalizeProviderId(providerKey) : null;
const providerChunkLimit = providerId
? getProviderDock(providerId)?.outbound?.textChunkLimit
: undefined;
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
fallbackLimit: providerChunkLimit,
});
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max(
1,
@@ -74,39 +91,6 @@ export function resolveBlockStreamingChunking(
return { minChars, maxChars, breakPreference };
}
export function resolveTelegramDraftStreamingChunking(
cfg: ClawdbotConfig | undefined,
accountId?: string | null,
): {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
} {
const providerKey: TextChunkProvider = "telegram";
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
const normalizedAccountId = normalizeAccountId(accountId);
const draftCfg =
cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ??
cfg?.telegram?.draftChunk;
const maxRequested = Math.max(
1,
Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX),
);
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minRequested = Math.max(
1,
Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN),
);
const minChars = Math.min(minRequested, maxChars);
const breakPreference =
draftCfg?.breakPreference === "newline" ||
draftCfg?.breakPreference === "sentence"
? draftCfg.breakPreference
: "paragraph";
return { minChars, maxChars, breakPreference };
}
export function resolveBlockStreamingCoalescing(
cfg: ClawdbotConfig | undefined,
provider?: string,
@@ -118,54 +102,21 @@ export function resolveBlockStreamingCoalescing(
},
): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
const normalizedAccountId = normalizeAccountId(accountId);
const providerDefaults = providerKey
? PROVIDER_COALESCE_DEFAULTS[providerKey]
const providerId = providerKey ? normalizeProviderId(providerKey) : null;
const providerChunkLimit = providerId
? getProviderDock(providerId)?.outbound?.textChunkLimit
: undefined;
const providerCfg = (() => {
if (!cfg || !providerKey) return undefined;
if (providerKey === "whatsapp") {
return (
cfg.whatsapp?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.whatsapp?.blockStreamingCoalesce
);
}
if (providerKey === "telegram") {
return (
cfg.telegram?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.telegram?.blockStreamingCoalesce
);
}
if (providerKey === "discord") {
return (
cfg.discord?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.discord?.blockStreamingCoalesce
);
}
if (providerKey === "slack") {
return (
cfg.slack?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.slack?.blockStreamingCoalesce
);
}
if (providerKey === "signal") {
return (
cfg.signal?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.signal?.blockStreamingCoalesce
);
}
if (providerKey === "imessage") {
return (
cfg.imessage?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
cfg.imessage?.blockStreamingCoalesce
);
}
if (providerKey === "msteams") {
return cfg.msteams?.blockStreamingCoalesce;
}
return undefined;
})();
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
fallbackLimit: providerChunkLimit,
});
const providerDefaults = providerId
? getProviderDock(providerId)?.streaming?.blockStreamingCoalesceDefaults
: undefined;
const providerCfg = resolveProviderBlockStreamingCoalesce({
cfg,
providerKey,
accountId,
});
const coalesceCfg =
providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
const minRequested = Math.max(

View File

@@ -54,9 +54,9 @@ import {
triggerClawdbotRestart,
} from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { ProviderId } from "../../providers/plugins/types.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import {
normalizeCommandBody,
@@ -108,10 +108,10 @@ function resolveSessionEntryForKey(
export type CommandContext = {
surface: string;
provider: string;
isWhatsAppProvider: boolean;
providerId?: ProviderId;
ownerList: string[];
isAuthorizedSender: boolean;
senderE164?: string;
senderId?: string;
abortKey?: string;
rawBodyNormalized: string;
commandBodyNormalized: string;
@@ -155,7 +155,7 @@ export async function buildStatusReply(params: {
} = params;
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return undefined;
}
@@ -359,10 +359,10 @@ export function buildCommandContext(params: {
return {
surface,
provider,
isWhatsAppProvider: auth.isWhatsAppProvider,
providerId: auth.providerId,
ownerList: auth.ownerList,
isAuthorizedSender: auth.isAuthorizedSender,
senderE164: auth.senderE164,
senderId: auth.senderId,
abortKey,
rawBodyNormalized,
commandBodyNormalized,
@@ -448,7 +448,7 @@ export async function handleCommands(params: {
command.commandBodyNormalized === "/new";
if (resetRequested && !command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /reset from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -472,22 +472,9 @@ export async function handleCommands(params: {
reply: { text: "⚙️ Group activation only applies to group chats." },
};
}
const activationOwnerList = command.ownerList;
const activationSenderE164 = command.senderE164
? normalizeE164(command.senderE164)
: "";
const isActivationOwner =
!command.isWhatsAppProvider || activationOwnerList.length === 0
? command.isAuthorizedSender
: Boolean(activationSenderE164) &&
activationOwnerList.includes(activationSenderE164);
if (
!command.isAuthorizedSender ||
(command.isWhatsAppProvider && !isActivationOwner)
) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /activation from unauthorized sender in group: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -515,7 +502,7 @@ export async function handleCommands(params: {
if (allowTextCommands && sendPolicyCommand.hasCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /send from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -552,7 +539,7 @@ export async function handleCommands(params: {
if (allowTextCommands && command.commandBodyNormalized === "/restart") {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /restart from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -598,7 +585,7 @@ export async function handleCommands(params: {
if (allowTextCommands && helpRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /help from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -609,7 +596,7 @@ export async function handleCommands(params: {
if (allowTextCommands && commandsRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /commands from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /commands from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -673,7 +660,7 @@ export async function handleCommands(params: {
if (configCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /config from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /config from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -805,7 +792,7 @@ export async function handleCommands(params: {
if (debugCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /debug from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /debug from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -832,13 +819,11 @@ export async function handleCommands(params: {
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
const effectiveConfig = cfg ?? {};
const json = JSON.stringify(overrides, null, 2);
const effectiveJson = JSON.stringify(effectiveConfig, null, 2);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`\n⚙ Effective config (with overrides):\n\`\`\`json\n${effectiveJson}\n\`\`\``,
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
},
};
}
@@ -895,7 +880,7 @@ export async function handleCommands(params: {
if (allowTextCommands && stopRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /stop from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /stop from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -927,7 +912,7 @@ export async function handleCommands(params: {
if (compactRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /compact from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
`Ignoring /compact from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}

View File

@@ -198,7 +198,11 @@ export function createFollowupRunner(params: {
(queued.run.messageProvider?.toLowerCase() as
| OriginatingChannelType
| undefined);
const replyToMode = resolveReplyToMode(queued.run.config, replyToChannel);
const replyToMode = resolveReplyToMode(
queued.run.config,
replyToChannel,
queued.originatingAccountId,
);
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
payloads: sanitizedPayloads,

View File

@@ -1,181 +1,39 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderGroupRequireMention } from "../../config/group-policy.js";
import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { getProviderDock } from "../../providers/dock.js";
import {
getChatProviderMeta,
normalizeProviderId,
} from "../../providers/registry.js";
import { isInternalMessageProvider } from "../../utils/message-provider.js";
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
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,
) {
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 resolveGroupRequireMention(params: {
cfg: ClawdbotConfig;
ctx: TemplateContext;
groupResolution?: GroupKeyResolution;
}): boolean {
const { cfg, ctx, groupResolution } = params;
const provider =
groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase();
const rawProvider = groupResolution?.provider ?? ctx.Provider?.trim();
const provider = normalizeProviderId(rawProvider);
if (!provider) return true;
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (provider === "telegram") {
const { chatId, topicId } = parseTelegramGroupId(groupId);
const requireMention = resolveTelegramRequireMention({
cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveProviderGroupRequireMention({
cfg,
provider,
groupId: chatId ?? groupId,
});
}
if (provider === "whatsapp" || provider === "imessage") {
return resolveProviderGroupRequireMention({
cfg,
provider,
groupId,
});
}
if (provider === "discord") {
const guildEntry = resolveDiscordGuildEntry(
cfg.discord?.guilds,
groupSpace,
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const channelSlug = normalizeDiscordSlug(groupRoom);
const entry =
(groupId ? channelEntries[groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupRoom
? channelEntries[normalizeDiscordSlug(groupRoom)]
: undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof guildEntry?.requireMention === "boolean") {
return guildEntry.requireMention;
}
return true;
}
if (provider === "slack") {
const account = resolveSlackAccount({ cfg, accountId: ctx.AccountId });
const channels = account.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = groupId?.trim();
const channelName = 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;
}
const requireMention = getProviderDock(
provider,
)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupRoom,
groupSpace,
accountId: ctx.AccountId,
});
if (typeof requireMention === "boolean") return requireMention;
return true;
}
@@ -186,6 +44,7 @@ export function defaultGroupActivation(
}
export function buildGroupIntro(params: {
cfg: ClawdbotConfig;
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
defaultActivation: "always" | "mention";
@@ -196,14 +55,14 @@ export function buildGroupIntro(params: {
params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim();
const provider = params.sessionCtx.Provider?.trim().toLowerCase();
const rawProvider = params.sessionCtx.Provider?.trim();
const providerKey = rawProvider?.toLowerCase() ?? "";
const providerId = normalizeProviderId(rawProvider);
const providerLabel = (() => {
if (!provider) return "chat";
if (provider === "whatsapp") return "WhatsApp";
if (provider === "telegram") return "Telegram";
if (provider === "discord") return "Discord";
if (provider === "webchat") return "WebChat";
return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
if (!providerKey) return "chat";
if (isInternalMessageProvider(providerKey)) return "WebChat";
if (providerId) return getChatProviderMeta(providerId).label;
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
})();
const subjectLine = subject
? `You are replying inside the ${providerLabel} group "${subject}".`
@@ -213,10 +72,18 @@ export function buildGroupIntro(params: {
activation === "always"
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const whatsappIdsLine =
provider === "whatsapp"
? "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant)."
: undefined;
const groupId = params.sessionCtx.From?.replace(/^group:/, "");
const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject;
const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId
? getProviderDock(providerId)?.groups?.resolveGroupIntroHint?.({
cfg: params.cfg,
groupId,
groupRoom,
groupSpace,
accountId: params.sessionCtx.AccountId,
})
: undefined;
const silenceLine =
activation === "always"
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so Clawdbot stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
@@ -231,7 +98,7 @@ export function buildGroupIntro(params: {
subjectLine,
membersLine,
activationLine,
whatsappIdsLine,
providerIdsLine,
silenceLine,
cautionLine,
lurkLine,

View File

@@ -1,5 +1,7 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
@@ -112,9 +114,14 @@ export function stripMentions(
agentId?: string,
): string {
let result = text;
const rawPatterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(rawPatterns);
const providerId = ctx.Provider ? normalizeProviderId(ctx.Provider) : null;
const providerMentions = providerId
? getProviderDock(providerId)?.mentions
: undefined;
const patterns = normalizeMentionPatterns([
...resolveMentionPatterns(cfg, agentId),
...(providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? []),
]);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
@@ -123,16 +130,15 @@ export function stripMentions(
// ignore invalid regex
}
}
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
if (selfE164) {
const esc = selfE164.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result
.replace(new RegExp(esc, "gi"), " ")
.replace(new RegExp(`@${esc}`, "gi"), " ");
if (providerMentions?.stripMentions) {
result = providerMentions.stripMentions({
text: result,
ctx,
cfg,
agentId,
});
}
// Generic mention patterns like @123456789 or plain digits
result = result.replace(/@[0-9+]{5,}/g, " ");
// Discord-style mentions (<@123> or <@!123>)
result = result.replace(/<@!?\d+>/g, " ");
return result.replace(/\s+/g, " ").trim();
}

View File

@@ -568,14 +568,7 @@ export function scheduleFollowupDrain(
}
})();
}
function defaultQueueModeForProvider(provider?: string): QueueMode {
const normalized = provider?.trim().toLowerCase();
if (normalized === "discord") return "collect";
if (normalized === "webchat") return "collect";
if (normalized === "whatsapp") return "collect";
if (normalized === "telegram") return "collect";
if (normalized === "imessage") return "collect";
if (normalized === "signal") return "collect";
function defaultQueueModeForProvider(_provider?: string): QueueMode {
return "collect";
}
export function resolveQueueSettings(params: {

View File

@@ -1,22 +1,22 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
export function resolveReplyToMode(
cfg: ClawdbotConfig,
channel?: OriginatingChannelType,
accountId?: string | null,
): ReplyToMode {
switch (channel) {
case "telegram":
return cfg.telegram?.replyToMode ?? "first";
case "discord":
return cfg.discord?.replyToMode ?? "off";
case "slack":
return cfg.slack?.replyToMode ?? "off";
default:
return "all";
}
const provider = normalizeProviderId(channel);
if (!provider) return "all";
const resolved = getProviderDock(provider)?.threading?.resolveReplyToMode?.({
cfg,
accountId,
});
return resolved ?? "all";
}
export function createReplyToModeFilter(
@@ -43,7 +43,11 @@ export function createReplyToModeFilterForChannel(
mode: ReplyToMode,
channel?: OriginatingChannelType,
) {
const provider = normalizeProviderId(channel);
const allowTagsWhenOff = provider
? Boolean(getProviderDock(provider)?.threading?.allowTagsWhenOff)
: false;
return createReplyToModeFilter(mode, {
allowTagsWhenOff: channel === "slack",
allowTagsWhenOff,
});
}

View File

@@ -236,11 +236,12 @@ describe("routeReply", () => {
to: "conversation:19:abc@thread.tacv2",
cfg,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
mediaUrl: undefined,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
}),
);
});
});

View File

@@ -10,13 +10,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { sendMessageMSTeams } from "../../msteams/send.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { sendMessageWhatsApp } from "../../web/outbound.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
@@ -93,118 +88,39 @@ export async function routeReply(
return { ok: true };
}
const sendOne = async (params: {
text: string;
mediaUrl?: string;
}): Promise<RouteReplyResult> => {
if (abortSignal?.aborted) {
return { ok: false, error: "Reply routing aborted" };
}
const { text, mediaUrl } = params;
switch (channel) {
case "telegram": {
const replyToMessageId = replyToId
? Number.parseInt(replyToId, 10)
: undefined;
const resolvedReplyToMessageId = Number.isFinite(replyToMessageId)
? replyToMessageId
: undefined;
const result = await sendMessageTelegram(to, text, {
mediaUrl,
messageThreadId: threadId,
replyToMessageId: resolvedReplyToMessageId,
accountId,
});
return { ok: true, messageId: result.messageId };
}
if (channel === INTERNAL_MESSAGE_PROVIDER) {
return {
ok: false,
error: "Webchat routing not supported for queued replies",
};
}
case "slack": {
const result = await sendMessageSlack(to, text, {
mediaUrl,
threadTs: replyToId,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "discord": {
const result = await sendMessageDiscord(to, text, {
mediaUrl,
replyTo: replyToId,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "signal": {
const result = await sendMessageSignal(to, text, {
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "imessage": {
const result = await sendMessageIMessage(to, text, {
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "whatsapp": {
const result = await sendMessageWhatsApp(to, text, {
verbose: false,
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "webchat": {
return {
ok: false,
error: `Webchat routing not supported for queued replies`,
};
}
case "msteams": {
const result = await sendMessageMSTeams({
cfg,
to,
text,
mediaUrl,
});
return { ok: true, messageId: result.messageId };
}
default: {
const _exhaustive: never = channel;
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
}
}
};
const provider = normalizeProviderId(channel) ?? null;
if (!provider) {
return { ok: false, error: `Unknown channel: ${String(channel)}` };
}
if (abortSignal?.aborted) {
return { ok: false, error: "Reply routing aborted" };
}
try {
if (abortSignal?.aborted) {
return { ok: false, error: "Reply routing aborted" };
}
if (mediaUrls.length === 0) {
return await sendOne({ text });
}
let last: RouteReplyResult | undefined;
for (let i = 0; i < mediaUrls.length; i++) {
if (abortSignal?.aborted) {
return { ok: false, error: "Reply routing aborted" };
}
const mediaUrl = mediaUrls[i];
const caption = i === 0 ? text : "";
last = await sendOne({ text: caption, mediaUrl });
if (!last.ok) return last;
}
return last ?? { ok: true };
// Provider docking: this is an execution boundary (we're about to send).
// Keep the module cheap to import by loading outbound plumbing lazily.
const { deliverOutboundPayloads } = await import(
"../../infra/outbound/deliver.js"
);
const results = await deliverOutboundPayloads({
cfg,
provider,
to,
accountId: accountId ?? undefined,
payloads: [normalized],
replyToId: replyToId ?? null,
threadId: threadId ?? null,
abortSignal,
});
const last = results.at(-1);
return { ok: true, messageId: last?.messageId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
@@ -222,22 +138,10 @@ export async function routeReply(
*/
export function isRoutableChannel(
channel: OriginatingChannelType | undefined,
): channel is
| "telegram"
| "slack"
| "discord"
| "signal"
| "imessage"
| "whatsapp"
| "msteams" {
if (!channel) return false;
return [
"telegram",
"slack",
"discord",
"signal",
"imessage",
"whatsapp",
"msteams",
].includes(channel);
): channel is Exclude<
OriginatingChannelType,
typeof INTERNAL_MESSAGE_PROVIDER
> {
if (!channel || channel === INTERNAL_MESSAGE_PROVIDER) return false;
return normalizeProviderId(channel) !== null;
}

View File

@@ -23,6 +23,8 @@ import {
type SessionScope,
saveSessionStore,
} from "../../config/sessions.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
@@ -236,7 +238,13 @@ export async function initSessionState(params: {
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const isRoomProvider = provider === "discord" || provider === "slack";
const normalizedProvider = normalizeProviderId(provider);
const isRoomProvider = Boolean(
normalizedProvider &&
getProviderDock(normalizedProvider)?.capabilities.chatTypes.includes(
"channel",
),
);
const nextRoom =
explicitRoom ??
(isRoomProvider && subject && subject.startsWith("#")