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:
committed by
GitHub
parent
23eec7d841
commit
7acd26a2fc
@@ -117,7 +117,12 @@ describe("resolveTextChunkLimit", () => {
|
||||
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);
|
||||
expect(resolveTextChunkLimit(undefined, "signal")).toBe(4000);
|
||||
expect(resolveTextChunkLimit(undefined, "imessage")).toBe(4000);
|
||||
expect(resolveTextChunkLimit(undefined, "discord")).toBe(2000);
|
||||
expect(resolveTextChunkLimit(undefined, "discord")).toBe(4000);
|
||||
expect(
|
||||
resolveTextChunkLimit(undefined, "discord", undefined, {
|
||||
fallbackLimit: 2000,
|
||||
}),
|
||||
).toBe(2000);
|
||||
});
|
||||
|
||||
it("supports provider overrides", () => {
|
||||
|
||||
@@ -8,80 +8,63 @@ import {
|
||||
isSafeFenceBreak,
|
||||
parseFenceSpans,
|
||||
} from "../markdown/fences.js";
|
||||
import type { ProviderId } from "../providers/plugins/types.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
|
||||
|
||||
export type TextChunkProvider =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "webchat"
|
||||
| "msteams";
|
||||
export type TextChunkProvider = ProviderId | typeof INTERNAL_MESSAGE_PROVIDER;
|
||||
|
||||
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||
whatsapp: 4000,
|
||||
telegram: 4000,
|
||||
discord: 2000,
|
||||
slack: 4000,
|
||||
signal: 4000,
|
||||
imessage: 4000,
|
||||
webchat: 4000,
|
||||
msteams: 4000,
|
||||
const DEFAULT_CHUNK_LIMIT = 4000;
|
||||
|
||||
type ProviderChunkConfig = {
|
||||
textChunkLimit?: number;
|
||||
accounts?: Record<string, { textChunkLimit?: number }>;
|
||||
};
|
||||
|
||||
function resolveChunkLimitForProvider(
|
||||
cfgSection: ProviderChunkConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): number | undefined {
|
||||
if (!cfgSection) return undefined;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accounts = cfgSection.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
const direct = accounts[normalizedAccountId];
|
||||
if (typeof direct?.textChunkLimit === "number") {
|
||||
return direct.textChunkLimit;
|
||||
}
|
||||
const matchKey = Object.keys(accounts).find(
|
||||
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
|
||||
);
|
||||
const match = matchKey ? accounts[matchKey] : undefined;
|
||||
if (typeof match?.textChunkLimit === "number") {
|
||||
return match.textChunkLimit;
|
||||
}
|
||||
}
|
||||
return cfgSection.textChunkLimit;
|
||||
}
|
||||
|
||||
export function resolveTextChunkLimit(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
provider?: TextChunkProvider,
|
||||
accountId?: string | null,
|
||||
opts?: { fallbackLimit?: number },
|
||||
): number {
|
||||
const fallback =
|
||||
typeof opts?.fallbackLimit === "number" && opts.fallbackLimit > 0
|
||||
? opts.fallbackLimit
|
||||
: DEFAULT_CHUNK_LIMIT;
|
||||
const providerOverride = (() => {
|
||||
if (!provider) return undefined;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (provider === "whatsapp") {
|
||||
return cfg?.whatsapp?.textChunkLimit;
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return (
|
||||
cfg?.telegram?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.telegram?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "discord") {
|
||||
return (
|
||||
cfg?.discord?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.discord?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return (
|
||||
cfg?.slack?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.slack?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "signal") {
|
||||
return (
|
||||
cfg?.signal?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.signal?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "imessage") {
|
||||
return (
|
||||
cfg?.imessage?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.imessage?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "msteams") {
|
||||
return cfg?.msteams?.textChunkLimit;
|
||||
}
|
||||
return undefined;
|
||||
if (!provider || provider === INTERNAL_MESSAGE_PROVIDER) return undefined;
|
||||
const providerConfig = (cfg as Record<string, unknown> | undefined)?.[
|
||||
provider
|
||||
] as ProviderChunkConfig | undefined;
|
||||
return resolveChunkLimitForProvider(providerConfig, accountId);
|
||||
})();
|
||||
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||
return providerOverride;
|
||||
}
|
||||
if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider];
|
||||
return 4000;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
|
||||
@@ -1,72 +1,123 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { ProviderDock } from "../providers/dock.js";
|
||||
import { getProviderDock, listProviderDocks } from "../providers/dock.js";
|
||||
import type { ProviderId } from "../providers/plugins/types.js";
|
||||
import { normalizeProviderId } from "../providers/registry.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
export type CommandAuthorization = {
|
||||
isWhatsAppProvider: boolean;
|
||||
providerId?: ProviderId;
|
||||
ownerList: string[];
|
||||
senderE164?: string;
|
||||
senderId?: string;
|
||||
isAuthorizedSender: boolean;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
function resolveProviderFromContext(
|
||||
ctx: MsgContext,
|
||||
cfg: ClawdbotConfig,
|
||||
): ProviderId | undefined {
|
||||
const direct =
|
||||
normalizeProviderId(ctx.Provider) ??
|
||||
normalizeProviderId(ctx.Surface) ??
|
||||
normalizeProviderId(ctx.OriginatingChannel);
|
||||
if (direct) return direct;
|
||||
const candidates = [ctx.From, ctx.To]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.flatMap((value) => value.split(":").map((part) => part.trim()));
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeProviderId(candidate);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
const configured = listProviderDocks()
|
||||
.map((dock) => {
|
||||
if (!dock.config?.resolveAllowFrom) return null;
|
||||
const allowFrom = dock.config.resolveAllowFrom({
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
});
|
||||
if (!Array.isArray(allowFrom) || allowFrom.length === 0) return null;
|
||||
return dock.id;
|
||||
})
|
||||
.filter((value): value is ProviderId => Boolean(value));
|
||||
if (configured.length === 1) return configured[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatAllowFromList(params: {
|
||||
dock?: ProviderDock;
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
allowFrom: Array<string | number>;
|
||||
}): string[] {
|
||||
const { dock, cfg, accountId, allowFrom } = params;
|
||||
if (!allowFrom || allowFrom.length === 0) return [];
|
||||
if (dock?.config?.formatAllowFrom) {
|
||||
return dock.config.formatAllowFrom({ cfg, accountId, allowFrom });
|
||||
}
|
||||
return allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveCommandAuthorization(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandAuthorization {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const provider = (ctx.Provider ?? "").trim().toLowerCase();
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const hasWhatsappPrefix =
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
const looksLikeE164 = (value: string) =>
|
||||
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
|
||||
const inferWhatsApp =
|
||||
!provider &&
|
||||
Boolean(cfg.whatsapp?.allowFrom?.length) &&
|
||||
(looksLikeE164(from) || looksLikeE164(to));
|
||||
const isWhatsAppProvider =
|
||||
provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
||||
|
||||
const configuredAllowFrom = isWhatsAppProvider
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const providerId = resolveProviderFromContext(ctx, cfg);
|
||||
const dock = providerId ? getProviderDock(providerId) : undefined;
|
||||
const from = (ctx.From ?? "").trim();
|
||||
const to = (ctx.To ?? "").trim();
|
||||
const allowFromRaw = dock?.config?.resolveAllowFrom
|
||||
? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId })
|
||||
: [];
|
||||
const allowFromList = formatAllowFromList({
|
||||
dock,
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
|
||||
});
|
||||
const allowAll =
|
||||
!isWhatsAppProvider ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const senderE164 = normalizeE164(
|
||||
ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""),
|
||||
);
|
||||
const ownerCandidates =
|
||||
isWhatsAppProvider && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
const ownerCandidates = allowAll
|
||||
? []
|
||||
: allowFromList.filter((entry) => entry !== "*");
|
||||
if (!allowAll && ownerCandidates.length === 0 && to) {
|
||||
const normalizedTo = formatAllowFromList({
|
||||
dock,
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
allowFrom: [to],
|
||||
})[0];
|
||||
if (normalizedTo) ownerCandidates.push(normalizedTo);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const ownerList = ownerCandidates;
|
||||
|
||||
const senderRaw = ctx.SenderId ?? ctx.SenderE164 ?? from;
|
||||
const senderId = senderRaw
|
||||
? formatAllowFromList({
|
||||
dock,
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
allowFrom: [senderRaw],
|
||||
})[0]
|
||||
: undefined;
|
||||
|
||||
const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands);
|
||||
const isOwner =
|
||||
!isWhatsAppProvider ||
|
||||
!enforceOwner ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
(senderId ? ownerList.includes(senderId) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
isWhatsAppProvider,
|
||||
providerId,
|
||||
ownerList,
|
||||
senderE164: senderE164 || undefined,
|
||||
senderId: senderId || undefined,
|
||||
isAuthorizedSender,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
|
||||
@@ -43,9 +43,8 @@ describe("control command parsing", () => {
|
||||
expect(hasControlCommand("/commands")).toBe(true);
|
||||
expect(hasControlCommand("/commands:")).toBe(true);
|
||||
expect(hasControlCommand("commands")).toBe(false);
|
||||
expect(hasControlCommand("/compact")).toBe(true);
|
||||
expect(hasControlCommand("/compact:")).toBe(true);
|
||||
expect(hasControlCommand("compact")).toBe(false);
|
||||
expect(hasControlCommand("/status")).toBe(true);
|
||||
expect(hasControlCommand("/status:")).toBe(true);
|
||||
expect(hasControlCommand("status")).toBe(false);
|
||||
expect(hasControlCommand("usage")).toBe(false);
|
||||
|
||||
@@ -55,6 +54,9 @@ describe("control command parsing", () => {
|
||||
expect(hasControlCommand(`${alias}:`)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(hasControlCommand("/compact")).toBe(true);
|
||||
expect(hasControlCommand("/compact:")).toBe(true);
|
||||
expect(hasControlCommand("compact")).toBe(false);
|
||||
});
|
||||
|
||||
it("respects disabled config/debug commands", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import { listProviderDocks } from "../providers/dock.js";
|
||||
|
||||
export type CommandScope = "text" | "native" | "both";
|
||||
|
||||
@@ -263,8 +264,18 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
assertCommandRegistry(commands);
|
||||
return commands;
|
||||
})();
|
||||
let cachedNativeCommandSurfaces: Set<string> | null = null;
|
||||
|
||||
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||
const getNativeCommandSurfaces = (): Set<string> => {
|
||||
if (!cachedNativeCommandSurfaces) {
|
||||
cachedNativeCommandSurfaces = new Set(
|
||||
listProviderDocks()
|
||||
.filter((dock) => dock.capabilities.nativeCommands)
|
||||
.map((dock) => dock.id),
|
||||
);
|
||||
}
|
||||
return cachedNativeCommandSurfaces;
|
||||
};
|
||||
|
||||
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
||||
const map = new Map<string, TextAliasSpec>();
|
||||
@@ -354,14 +365,18 @@ export function normalizeCommandBody(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) return trimmed;
|
||||
|
||||
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
const newline = trimmed.indexOf("\n");
|
||||
const singleLine =
|
||||
newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
|
||||
|
||||
const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
const normalized = colonMatch
|
||||
? (() => {
|
||||
const [, command, rest] = colonMatch;
|
||||
const normalizedRest = rest.trimStart();
|
||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||
})()
|
||||
: trimmed;
|
||||
: singleLine;
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
const exact = TEXT_ALIAS_MAP.get(lowered);
|
||||
@@ -380,44 +395,86 @@ export function normalizeCommandBody(raw: string): string {
|
||||
: tokenSpec.canonical;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
export function isCommandMessage(raw: string): boolean {
|
||||
const trimmed = normalizeCommandBody(raw);
|
||||
return trimmed.startsWith("/");
|
||||
}
|
||||
|
||||
export function getCommandDetection(_cfg?: ClawdbotConfig): {
|
||||
exact: Set<string>;
|
||||
regex: RegExp;
|
||||
} {
|
||||
if (cachedDetection) return cachedDetection;
|
||||
const exact = new Set<string>();
|
||||
const patterns: string[] = [];
|
||||
for (const command of CHAT_COMMANDS) {
|
||||
for (const alias of command.textAliases) {
|
||||
for (const cmd of CHAT_COMMANDS) {
|
||||
for (const alias of cmd.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
exact.add(normalized);
|
||||
const escaped = escapeRegExp(normalized);
|
||||
if (!escaped) continue;
|
||||
if (command.acceptsArgs) {
|
||||
if (cmd.acceptsArgs) {
|
||||
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
|
||||
} else {
|
||||
patterns.push(`${escaped}(?:\\s*:\\s*)?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const regex = patterns.length
|
||||
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
|
||||
: /$^/;
|
||||
cachedDetection = { exact, regex };
|
||||
cachedDetection = {
|
||||
exact,
|
||||
regex: patterns.length
|
||||
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
|
||||
: /$^/,
|
||||
};
|
||||
return cachedDetection;
|
||||
}
|
||||
|
||||
export function supportsNativeCommands(surface?: string): boolean {
|
||||
export function maybeResolveTextAlias(raw: string, cfg?: ClawdbotConfig) {
|
||||
const trimmed = normalizeCommandBody(raw).trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
const detection = getCommandDetection(cfg);
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (detection.exact.has(normalized)) return normalized;
|
||||
if (!detection.regex.test(normalized)) return null;
|
||||
const tokenMatch = normalized.match(/^\/([^\s:]+)(?:\s|$)/);
|
||||
if (!tokenMatch) return null;
|
||||
const tokenKey = `/${tokenMatch[1]}`;
|
||||
return TEXT_ALIAS_MAP.has(tokenKey) ? tokenKey : null;
|
||||
}
|
||||
|
||||
export function resolveTextCommand(
|
||||
raw: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
): {
|
||||
command: ChatCommandDefinition;
|
||||
args?: string;
|
||||
} | null {
|
||||
const trimmed = normalizeCommandBody(raw).trim();
|
||||
const alias = maybeResolveTextAlias(trimmed, cfg);
|
||||
if (!alias) return null;
|
||||
const spec = TEXT_ALIAS_MAP.get(alias);
|
||||
if (!spec) return null;
|
||||
const command = CHAT_COMMANDS.find(
|
||||
(entry) => `/${entry.key}` === spec.canonical,
|
||||
);
|
||||
if (!command) return null;
|
||||
if (!spec.acceptsArgs) return { command };
|
||||
const args = trimmed.slice(alias.length).trim();
|
||||
return { command, args: args || undefined };
|
||||
}
|
||||
|
||||
export function isNativeCommandSurface(surface?: string): boolean {
|
||||
if (!surface) return false;
|
||||
return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase());
|
||||
return getNativeCommandSurfaces().has(surface.toLowerCase());
|
||||
}
|
||||
|
||||
export function shouldHandleTextCommands(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
surface?: string;
|
||||
surface: string;
|
||||
commandSource?: "text" | "native";
|
||||
}): boolean {
|
||||
const { cfg, surface, commandSource } = params;
|
||||
const textEnabled = cfg.commands?.text !== false;
|
||||
if (commandSource === "native") return true;
|
||||
if (textEnabled) return true;
|
||||
return !supportsNativeCommands(surface);
|
||||
if (params.commandSource === "native") return true;
|
||||
if (params.cfg.commands?.text !== false) return true;
|
||||
return !isNativeCommandSurface(params.surface);
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to discord dm allowFrom for elevated approval", async () => {
|
||||
it("uses tools.elevated.allowFrom.discord for elevated approval", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
@@ -809,11 +809,7 @@ describe("trigger handling", () => {
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
dm: {
|
||||
allowFrom: ["steipete"],
|
||||
},
|
||||
},
|
||||
tools: { elevated: { allowFrom: { discord: ["steipete"] } } },
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
@@ -856,11 +852,6 @@ describe("trigger handling", () => {
|
||||
allowFrom: { discord: [] },
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
dm: {
|
||||
allowFrom: ["steipete"],
|
||||
},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
|
||||
@@ -32,8 +32,14 @@ import {
|
||||
import { resolveSessionFilePath } from "../config/sessions.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { getProviderDock } from "../providers/dock.js";
|
||||
import {
|
||||
CHAT_PROVIDER_ORDER,
|
||||
normalizeProviderId,
|
||||
} from "../providers/registry.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import {
|
||||
@@ -127,53 +133,41 @@ function slugAllowToken(value?: string) {
|
||||
return text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const SENDER_PREFIXES = [
|
||||
...CHAT_PROVIDER_ORDER,
|
||||
INTERNAL_MESSAGE_PROVIDER,
|
||||
"user",
|
||||
"group",
|
||||
"channel",
|
||||
];
|
||||
const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i");
|
||||
|
||||
function stripSenderPrefix(value?: string) {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed.replace(
|
||||
/^(whatsapp|telegram|discord|signal|imessage|webchat|user|group|channel):/i,
|
||||
"",
|
||||
);
|
||||
return trimmed.replace(SENDER_PREFIX_RE, "");
|
||||
}
|
||||
|
||||
function resolveElevatedAllowList(
|
||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||
provider: string,
|
||||
discordFallback?: Array<string | number>,
|
||||
fallbackAllowFrom?: Array<string | number>,
|
||||
): Array<string | number> | undefined {
|
||||
switch (provider) {
|
||||
case "whatsapp":
|
||||
return allowFrom?.whatsapp;
|
||||
case "telegram":
|
||||
return allowFrom?.telegram;
|
||||
case "discord": {
|
||||
const hasExplicit = Boolean(
|
||||
allowFrom && Object.hasOwn(allowFrom, "discord"),
|
||||
);
|
||||
if (hasExplicit) return allowFrom?.discord;
|
||||
return discordFallback;
|
||||
}
|
||||
case "signal":
|
||||
return allowFrom?.signal;
|
||||
case "imessage":
|
||||
return allowFrom?.imessage;
|
||||
case "webchat":
|
||||
return allowFrom?.webchat;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
if (!allowFrom) return fallbackAllowFrom;
|
||||
const value = allowFrom[provider];
|
||||
return Array.isArray(value) ? value : fallbackAllowFrom;
|
||||
}
|
||||
|
||||
function isApprovedElevatedSender(params: {
|
||||
provider: string;
|
||||
ctx: MsgContext;
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
discordFallback?: Array<string | number>;
|
||||
fallbackAllowFrom?: Array<string | number>;
|
||||
}): boolean {
|
||||
const rawAllow = resolveElevatedAllowList(
|
||||
params.allowFrom,
|
||||
params.provider,
|
||||
params.discordFallback,
|
||||
params.fallbackAllowFrom,
|
||||
);
|
||||
if (!rawAllow || rawAllow.length === 0) return false;
|
||||
|
||||
@@ -248,23 +242,24 @@ function resolveElevatedPermissions(params: {
|
||||
return { enabled, allowed: false, failures };
|
||||
}
|
||||
|
||||
const discordFallback =
|
||||
params.provider === "discord"
|
||||
? params.cfg.discord?.dm?.allowFrom
|
||||
: undefined;
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
const dockFallbackAllowFrom = normalizedProvider
|
||||
? getProviderDock(normalizedProvider)?.elevated?.allowFromFallback?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
})
|
||||
: undefined;
|
||||
const fallbackAllowFrom = dockFallbackAllowFrom;
|
||||
const globalAllowed = isApprovedElevatedSender({
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: globalConfig?.allowFrom,
|
||||
discordFallback,
|
||||
fallbackAllowFrom,
|
||||
});
|
||||
if (!globalAllowed) {
|
||||
failures.push({
|
||||
gate: "allowFrom",
|
||||
key:
|
||||
params.provider === "discord" && discordFallback
|
||||
? "tools.elevated.allowFrom.discord (or discord.dm.allowFrom fallback)"
|
||||
: `tools.elevated.allowFrom.${params.provider}`,
|
||||
key: `tools.elevated.allowFrom.${params.provider}`,
|
||||
});
|
||||
return { enabled, allowed: false, failures };
|
||||
}
|
||||
@@ -274,6 +269,7 @@ function resolveElevatedPermissions(params: {
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: agentConfig.allowFrom,
|
||||
fallbackAllowFrom,
|
||||
})
|
||||
: true;
|
||||
if (!agentAllowed) {
|
||||
@@ -605,7 +601,8 @@ export async function getReplyFromConfig(
|
||||
agentCfg?.blockStreamingBreak === "message_end"
|
||||
? "message_end"
|
||||
: "text_end";
|
||||
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
||||
const blockStreamingEnabled =
|
||||
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
|
||||
const blockReplyChunking = blockStreamingEnabled
|
||||
? resolveBlockStreamingChunking(
|
||||
cfg,
|
||||
@@ -782,8 +779,13 @@ export async function getReplyFromConfig(
|
||||
: undefined;
|
||||
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
const skipWhenConfigEmpty = command.providerId
|
||||
? Boolean(
|
||||
getProviderDock(command.providerId)?.commands?.skipWhenConfigEmpty,
|
||||
)
|
||||
: false;
|
||||
if (
|
||||
command.isWhatsAppProvider &&
|
||||
skipWhenConfigEmpty &&
|
||||
isEmptyConfig &&
|
||||
command.from &&
|
||||
command.to &&
|
||||
@@ -854,6 +856,7 @@ export async function getReplyFromConfig(
|
||||
);
|
||||
const groupIntro = shouldInjectGroupIntro
|
||||
? buildGroupIntro({
|
||||
cfg,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
defaultActivation,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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("#")
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { ProviderId } from "../providers/plugins/types.js";
|
||||
import type { InternalMessageProvider } from "../utils/message-provider.js";
|
||||
|
||||
/** Valid provider channels for message routing. */
|
||||
export type OriginatingChannelType =
|
||||
| "telegram"
|
||||
| "slack"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "whatsapp"
|
||||
| "webchat"
|
||||
| "msteams";
|
||||
export type OriginatingChannelType = ProviderId | InternalMessageProvider;
|
||||
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
@@ -50,7 +45,7 @@ export type MsgContext = {
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
/** Provider label (whatsapp|telegram|discord|imessage|...). */
|
||||
/** Provider label (e.g. whatsapp, telegram). */
|
||||
Provider?: string;
|
||||
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
|
||||
Surface?: string;
|
||||
|
||||
Reference in New Issue
Block a user