diff --git a/CHANGELOG.md b/CHANGELOG.md index 1407de19d..68d1dfdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - macOS: stop syncing Peekaboo as a git submodule in postinstall. - Swabble: use the tagged Commander Swift package release. - CLI: add `clawdbot acp client` interactive ACP harness for debugging. +- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. - Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras. ### Fixes diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index afc6c591a..ccd2cf7a8 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { downloadBlueBubblesAttachment } from "./attachments.js"; @@ -836,7 +836,7 @@ async function processMessage( const fromLabel = message.isGroup ? `group:${peerId}` : message.senderName || `user:${message.senderId}`; - const body = formatAgentEnvelope({ + const body = core.channel.reply.formatAgentEnvelope({ channel: "BlueBubbles", from: fromLabel, timestamp: message.timestamp, @@ -1058,7 +1058,7 @@ async function processReaction( const senderLabel = reaction.senderName || reaction.senderId; const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`; - enqueueSystemEvent(text, { + core.system.enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, }); diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 055f868bb..e93ca873e 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { discordPlugin } from "./src/channel.js"; +import { setDiscordRuntime } from "./src/runtime.js"; const plugin = { id: "discord", name: "Discord", description: "Discord channel plugin", register(api: ClawdbotPluginApi) { + setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); }, }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7f75d63b7..5775cea61 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,42 +1,43 @@ import { applyAccountNameToChannelSection, - auditDiscordChannelPermissions, buildChannelConfigSchema, collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - discordMessageActions, discordOnboardingAdapter, DiscordConfigSchema, formatPairingApproveHint, getChatChannelMeta, listDiscordAccountIds, listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersFromConfig, - listDiscordDirectoryPeersLive, looksLikeDiscordTargetId, migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeDiscordMessagingTarget, PAIRING_APPROVED_MESSAGE, - probeDiscord, resolveDiscordAccount, resolveDefaultDiscordAccountId, - resolveDiscordChannelAllowlist, resolveDiscordGroupRequireMention, - resolveDiscordUserAllowlist, - sendMessageDiscord, - sendPollDiscord, setAccountEnabledInConfigSection, - shouldLogVerbose, + type ChannelMessageActionAdapter, type ChannelPlugin, type ResolvedDiscordAccount, } from "clawdbot/plugin-sdk"; +import { getDiscordRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("discord"); +const discordMessageActions: ChannelMessageActionAdapter = { + listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx), + extractToolSend: (ctx) => + getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx), + handleAction: async (ctx) => + await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx), +}; + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { @@ -47,7 +48,10 @@ export const discordPlugin: ChannelPlugin = { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), notifyApproval: async ({ id }) => { - await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE); + await getDiscordRuntime().channel.discord.sendMessageDiscord( + `user:${id}`, + PAIRING_APPROVED_MESSAGE, + ); }, }, capabilities: { @@ -158,8 +162,10 @@ export const discordPlugin: ChannelPlugin = { self: async () => null, listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => listDiscordDirectoryPeersLive(params), - listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params), + listPeersLive: async (params) => + getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), + listGroupsLive: async (params) => + getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), }, resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { @@ -173,7 +179,10 @@ export const discordPlugin: ChannelPlugin = { })); } if (kind === "group") { - const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs }); + const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ + token, + entries: inputs, + }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, @@ -185,7 +194,10 @@ export const discordPlugin: ChannelPlugin = { note: entry.note, })); } - const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs }); + const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ + token, + entries: inputs, + }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, @@ -267,7 +279,8 @@ export const discordPlugin: ChannelPlugin = { textChunkLimit: 2000, pollMaxOptions: 10, sendText: async ({ to, text, accountId, deps, replyToId }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; + const send = + deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, replyTo: replyToId ?? undefined, @@ -276,7 +289,8 @@ export const discordPlugin: ChannelPlugin = { return { channel: "discord", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; + const send = + deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, mediaUrl, @@ -286,7 +300,7 @@ export const discordPlugin: ChannelPlugin = { return { channel: "discord", ...result }; }, sendPoll: async ({ to, poll, accountId }) => - await sendPollDiscord(to, poll, { + await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { accountId: accountId ?? undefined, }), }, @@ -310,7 +324,9 @@ export const discordPlugin: ChannelPlugin = { lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ account, timeoutMs }) => - probeDiscord(account.token, timeoutMs, { includeApplication: true }), + getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { + includeApplication: true, + }), auditAccount: async ({ account, timeoutMs, cfg }) => { const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ cfg, @@ -327,7 +343,7 @@ export const discordPlugin: ChannelPlugin = { elapsedMs: 0, }; } - const audit = await auditDiscordChannelPermissions({ + const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({ token: botToken, accountId: account.accountId, channelIds, @@ -364,7 +380,7 @@ export const discordPlugin: ChannelPlugin = { const token = account.token.trim(); let discordBotLabel = ""; try { - const probe = await probeDiscord(token, 2500, { + const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, { includeApplication: true, }); const username = probe.ok ? probe.bot?.username?.trim() : null; @@ -385,14 +401,12 @@ export const discordPlugin: ChannelPlugin = { ); } } catch (err) { - if (shouldLogVerbose()) { + if (getDiscordRuntime().logging.shouldLogVerbose()) { ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); } } ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorDiscordProvider } = await import("clawdbot/plugin-sdk"); - return monitorDiscordProvider({ + return getDiscordRuntime().channel.discord.monitorDiscordProvider({ token, accountId: account.accountId, config: ctx.cfg, diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts new file mode 100644 index 000000000..3e963091c --- /dev/null +++ b/extensions/discord/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setDiscordRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getDiscordRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Discord runtime not initialized"); + } + return runtime; +} diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index bc15c102f..2bc53759d 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { imessagePlugin } from "./src/channel.js"; +import { setIMessageRuntime } from "./src/runtime.js"; const plugin = { id: "imessage", name: "iMessage", description: "iMessage channel plugin", register(api: ClawdbotPluginApi) { + setIMessageRuntime(api.runtime); api.registerChannel({ plugin: imessagePlugin }); }, }; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index e2901ba1f..30a82d612 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, - chunkText, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -10,33 +9,36 @@ import { IMessageConfigSchema, listIMessageAccountIds, migrateBaseNameToDefaultAccount, - monitorIMessageProvider, normalizeAccountId, PAIRING_APPROVED_MESSAGE, - probeIMessage, resolveChannelMediaMaxBytes, resolveDefaultIMessageAccountId, resolveIMessageAccount, resolveIMessageGroupRequireMention, setAccountEnabledInConfigSection, - sendMessageIMessage, type ChannelPlugin, type ResolvedIMessageAccount, } from "clawdbot/plugin-sdk"; +import { getIMessageRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("imessage"); export const imessagePlugin: ChannelPlugin = { id: "imessage", meta: { ...meta, + aliases: ["imsg"], showConfigured: false, }, onboarding: imessageOnboardingAdapter, pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { - await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); + await getIMessageRuntime().channel.imessage.sendMessageIMessage( + id, + PAIRING_APPROVED_MESSAGE, + ); }, }, capabilities: { @@ -181,10 +183,10 @@ export const imessagePlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: chunkText, + chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = deps?.sendIMessage ?? sendMessageIMessage; + const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ cfg, resolveChannelLimitMb: ({ cfg, accountId }) => @@ -199,7 +201,7 @@ export const imessagePlugin: ChannelPlugin = { return { channel: "imessage", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { - const send = deps?.sendIMessage ?? sendMessageIMessage; + const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ cfg, resolveChannelLimitMb: ({ cfg, accountId }) => @@ -249,7 +251,8 @@ export const imessagePlugin: ChannelPlugin = { probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs), + probeAccount: async ({ timeoutMs }) => + getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, name: account.name, @@ -280,7 +283,7 @@ export const imessagePlugin: ChannelPlugin = { ctx.log?.info( `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, ); - return monitorIMessageProvider({ + return getIMessageRuntime().channel.imessage.monitorIMessageProvider({ accountId: account.accountId, config: ctx.cfg, runtime: ctx.runtime, diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts new file mode 100644 index 000000000..adb67b012 --- /dev/null +++ b/extensions/imessage/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setIMessageRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getIMessageRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("iMessage runtime not initialized"); + } + return runtime; +} diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 7b45e408f..ee41e0120 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { CoreConfig } from "./types.js"; import { matrixPlugin } from "./channel.js"; +import { setMatrixRuntime } from "./runtime.js"; +import { createPluginRuntime } from "../../../src/plugins/runtime/index.js"; describe("matrix directory", () => { + beforeEach(() => { + setMatrixRuntime(createPluginRuntime()); + }); + it("lists peers and groups from config", async () => { const cfg = { channels: { diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index a9921e597..4c95b936e 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -15,7 +15,7 @@ import type { RoomTopicEventContent, } from "matrix-js-sdk/lib/@types/state_events.js"; -import { loadConfig } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; import type { CoreConfig } from "../types.js"; import { getActiveMatrixClient } from "./active-client.js"; import { @@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise { - const cfg = params?.cfg ?? (loadConfig() as CoreConfig); + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; const resolved = resolveMatrixConfig(cfg, env); if (!resolved.homeserver) { diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index edf0d5657..4784a6f9f 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveStateDir } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; export type MatrixStoredCredentials = { homeserver: string; @@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json"; export function resolveMatrixCredentialsDir( env: NodeJS.ProcessEnv = process.env, - stateDir: string = resolveStateDir(env, os.homedir), + stateDir?: string, ): string { - return path.join(stateDir, "credentials", "matrix"); + const resolvedStateDir = + stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return path.join(resolvedStateDir, "credentials", "matrix"); } export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string { diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 7b71123d0..fdcf66fe4 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -3,7 +3,8 @@ import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; const MATRIX_SDK_PACKAGE = "matrix-js-sdk"; @@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runCommandWithTimeout(command, { + const result = await getMatrixRuntime().system.runCommandWithTimeout(command, { cwd: root, timeoutMs: 300_000, env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index ead678948..da3d43ff8 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,8 +1,9 @@ import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk"; import { RoomMemberEvent } from "matrix-js-sdk"; -import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import type { CoreConfig } from "../../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; export function registerMatrixAutoJoin(params: { client: MatrixClient; @@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: { runtime: RuntimeEnv; }) { const { client, cfg, runtime } = params; + const core = getMatrixRuntime(); + const logVerbose = (message: string) => { + if (!core.logging.shouldLogVerbose()) return; + runtime.log?.(message); + }; const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; @@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: { await client.joinRoom(roomId); logVerbose(`matrix: joined room ${roomId}`); } catch (err) { - runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`)); + runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`); } }); } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 82cb9f591..8fb6ea844 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk"; import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; import { - buildMentionRegexes, - chunkMarkdownText, - createReplyDispatcherWithTyping, - danger, - dispatchReplyFromConfig, - enqueueSystemEvent, - finalizeInboundContext, - formatAgentEnvelope, formatAllowlistMatchMeta, - getChildLogger, - hasControlCommand, - loadConfig, - logVerbose, mergeAllowlist, - matchesMentionPatterns, - readChannelAllowFromStore, - recordSessionMetaFromInbound, - resolveAgentRoute, - resolveCommandAuthorizedFromAuthorizers, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveStorePath, - resolveTextChunkLimit, - shouldHandleTextCommands, - shouldLogVerbose, summarizeMapping, - updateLastRoute, - upsertChannelPairingRequest, type ReplyPayload, type RuntimeEnv, } from "clawdbot/plugin-sdk"; @@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; import { resolveMatrixTargets } from "../../resolve-targets.js"; +import { getMatrixRuntime } from "../../runtime.js"; export type MonitorMatrixOpts = { runtime?: RuntimeEnv; @@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi if (isBunRuntime()) { throw new Error("Matrix provider requires Node (bun runtime not supported)"); } - let cfg = loadConfig() as CoreConfig; + const core = getMatrixRuntime(); + let cfg = core.config.loadConfig() as CoreConfig; if (cfg.channels?.matrix?.enabled === false) return; const runtime: RuntimeEnv = opts.runtime ?? { @@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); setActiveMatrixClient(client); - const mentionRegexes = buildMentionRegexes(cfg); - const logger = getChildLogger({ module: "matrix-auto-reply" }); + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); + const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); + const logVerboseMessage = (message: string) => { + if (core.logging.shouldLogVerbose()) { + logger.debug(message); + } + }; const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; @@ -220,7 +202,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const dmPolicyRaw = dmConfig?.policy ?? "pairing"; const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; const allowFrom = dmConfig?.allowFrom ?? []; - const textLimit = resolveTextChunkLimit(cfg, "matrix"); + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const startupMs = Date.now(); @@ -306,22 +288,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }`; if (roomConfigInfo.config && !roomConfigInfo.allowed) { - logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); return; } if (groupPolicy === "allowlist") { if (!roomConfigInfo.allowlistConfigured) { - logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); return; } if (!roomConfigInfo.config) { - logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); return; } } const senderName = room.getMember(senderId)?.name ?? senderId; - const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []); + const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); if (isDirectMessage) { @@ -335,13 +317,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); if (!allowMatch.allowed) { if (dmPolicy === "pairing") { - const { code, created } = await upsertChannelPairingRequest({ + const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "matrix", id: senderId, meta: { name: senderName }, }); if (created) { - logVerbose( + logVerboseMessage( `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, ); try { @@ -358,12 +340,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi { client }, ); } catch (err) { - logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); } } } if (dmPolicy !== "pairing") { - logVerbose( + logVerboseMessage( `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, ); } @@ -379,7 +361,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi userName: senderName, }); if (!userMatch.allowed) { - logVerbose( + logVerboseMessage( `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( userMatch, )})`, @@ -388,7 +370,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } } if (isRoom) { - logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`); + logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); } const rawBody = content.body.trim(); @@ -416,7 +398,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi maxBytes: mediaMaxBytes, }); } catch (err) { - logVerbose(`matrix: media download failed: ${String(err)}`); + logVerboseMessage(`matrix: media download failed: ${String(err)}`); } } @@ -429,7 +411,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi text: bodyText, mentionRegexes, }); - const allowTextCommands = shouldHandleTextCommands({ + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "matrix", }); @@ -439,14 +421,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi userId: senderId, userName: senderName, }); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, ], }); - if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) { - logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`); + if ( + isRoom && + allowTextCommands && + core.channel.text.hasControlCommand(bodyText, cfg) && + !commandAuthorized + ) { + logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); return; } const shouldRequireMention = isRoom @@ -465,7 +452,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi !wasMentioned && !hasExplicitMention && commandAuthorized && - hasControlCommand(bodyText); + core.channel.text.hasControlCommand(bodyText); if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info({ roomId, reason: "no-mention" }, "skipping room message"); return; @@ -482,14 +469,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const body = formatAgentEnvelope({ + const body = core.channel.reply.formatAgentEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: event.getTs() ?? undefined, body: textWithId, }); - const route = resolveAgentRoute({ + const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "matrix", peer: { @@ -499,7 +486,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; - const ctxPayload = finalizeInboundContext({ + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: bodyText, CommandBody: bodyText, @@ -531,10 +518,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi OriginatingTo: `room:${roomId}`, }); - const storePath = resolveStorePath(cfg.session?.store, { + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ + void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, @@ -546,7 +533,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); if (isDirectMessage) { - await updateLastRoute({ + await core.channel.session.updateLastRoute({ storePath, sessionKey: route.mainSessionKey, channel: "matrix", @@ -556,10 +543,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); } - if (shouldLogVerbose()) { - const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); - logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); - } + const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; @@ -577,20 +562,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }; if (shouldAckReaction() && messageId) { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { - logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`); + logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); }); } const replyTarget = ctxPayload.To; if (!replyTarget) { - runtime.error?.(danger("matrix: missing reply target")); + runtime.error?.("matrix: missing reply target"); return; } let didSendReply = false; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverMatrixReplies({ replies: [payload], @@ -604,13 +589,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi didSendReply = true; }, onError: (err, info) => { - runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`)); + runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), }); - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, @@ -622,19 +607,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi markDispatchIdle(); if (!queuedFinal) return; didSendReply = true; - if (shouldLogVerbose()) { - const finalCount = counts.final; - logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`); - } + const finalCount = counts.final; + logVerboseMessage( + `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, + ); if (didSendReply) { const preview = bodyText.replace(/\s+/g, " ").slice(0, 160); - enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, { + core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, { sessionKey: route.sessionKey, contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, }); } } catch (err) { - runtime.error?.(danger(`matrix handler failed: ${String(err)}`)); + runtime.error?.(`matrix handler failed: ${String(err)}`); } }; diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index cbb59f298..4a2405937 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -1,6 +1,6 @@ import type { MatrixClient } from "matrix-js-sdk"; -import { saveMediaBuffer } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../../runtime.js"; async function fetchMatrixMediaBuffer(params: { client: MatrixClient; @@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: { }); if (!fetched) return null; const headerType = fetched.headerType ?? params.contentType ?? undefined; - const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes); + const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( + fetched.buffer, + headerType, + "inbound", + params.maxBytes, + ); return { path: saved.path, contentType: saved.contentType, diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 6842c269d..3a10fdda1 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,6 +1,6 @@ import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; -import { matchesMentionPatterns } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../../runtime.js"; export function resolveMentions(params: { content: RoomMessageEventContent; @@ -17,6 +17,9 @@ export function resolveMentions(params: { const wasMentioned = Boolean(mentions?.room) || (params.userId ? mentionedUsers.has(params.userId) : false) || - matchesMentionPatterns(params.text ?? "", params.mentionRegexes); + getMatrixRuntime().channel.mentions.matchesMentionPatterns( + params.text ?? "", + params.mentionRegexes, + ); return { wasMentioned, hasExplicitMention: Boolean(mentions) }; } diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index cc558b062..26233de08 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,13 +1,8 @@ import type { MatrixClient } from "matrix-js-sdk"; -import { - chunkMarkdownText, - danger, - logVerbose, - type ReplyPayload, - type RuntimeEnv, -} from "clawdbot/plugin-sdk"; +import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; +import { getMatrixRuntime } from "../../runtime.js"; export async function deliverMatrixReplies(params: { replies: ReplyPayload[]; @@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: { replyToMode: "off" | "first" | "all"; threadId?: string; }): Promise { + const core = getMatrixRuntime(); + const logVerbose = (message: string) => { + if (core.logging.shouldLogVerbose()) { + params.runtime.log?.(message); + } + }; const chunkLimit = Math.min(params.textLimit, 4000); let hasReplied = false; for (const reply of params.replies) { @@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: { logVerbose("matrix reply has audioAsVoice without media/text; skipping"); continue; } - params.runtime.error?.(danger("matrix reply missing text/media")); + params.runtime.error?.("matrix reply missing text/media"); continue; } const replyToIdRaw = reply.replyToId?.trim(); @@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); if (mediaList.length === 0) { - for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) { + for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed) continue; await sendMessageMatrix(params.roomId, trimmed, { diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index a0bdd159e..1cae12f32 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,5 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; +import { setMatrixRuntime } from "../runtime.js"; + vi.mock("matrix-js-sdk", () => ({ EventType: { Direct: "m.direct", @@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({ }, })); -vi.mock("clawdbot/plugin-sdk", () => ({ - loadConfig: () => ({}), - resolveTextChunkLimit: () => 4000, - chunkMarkdownText: (text: string) => (text ? [text] : []), - loadWebMedia: vi.fn().mockResolvedValue({ - buffer: Buffer.from("media"), - fileName: "photo.png", - contentType: "image/png", - kind: "image", - }), - mediaKindFromMime: () => "image", - isVoiceCompatibleAudio: () => false, - getImageMetadata: vi.fn().mockResolvedValue(null), - resizeToJpeg: vi.fn(), -})); +const loadWebMediaMock = vi.fn().mockResolvedValue({ + buffer: Buffer.from("media"), + fileName: "photo.png", + contentType: "image/png", + kind: "image", +}); +const getImageMetadataMock = vi.fn().mockResolvedValue(null); +const resizeToJpegMock = vi.fn(); + +const runtimeStub = { + config: { + loadConfig: () => ({}), + }, + media: { + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), + mediaKindFromMime: () => "image", + isVoiceCompatibleAudio: () => false, + getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), + resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), + }, + channel: { + text: { + resolveTextChunkLimit: () => 4000, + chunkMarkdownText: (text: string) => (text ? [text] : []), + }, + }, +} as unknown as PluginRuntime; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; @@ -50,11 +65,13 @@ const makeClient = () => { describe("sendMessageMatrix media", () => { beforeAll(async () => { + setMatrixRuntime(runtimeStub); ({ sendMessageMatrix } = await import("./send.js")); }); beforeEach(() => { vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); }); it("uploads media with url payloads", async () => { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 972b78f50..9e4499594 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -5,17 +5,8 @@ import type { ReactionEventContent, } from "matrix-js-sdk/lib/@types/events.js"; -import { - chunkMarkdownText, - getImageMetadata, - isVoiceCompatibleAudio, - loadConfig, - loadWebMedia, - mediaKindFromMime, - type PollInput, - resolveTextChunkLimit, - resizeToJpeg, -} from "clawdbot/plugin-sdk"; +import type { PollInput } from "clawdbot/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; import { getActiveMatrixClient } from "./active-client.js"; import { createMatrixClient, @@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; import type { CoreConfig } from "../types.js"; const MATRIX_TEXT_LIMIT = 4000; +const getCore = () => getMatrixRuntime(); type MatrixDirectAccountData = AccountDataEvents[EventType.Direct]; @@ -65,7 +57,7 @@ function ensureNodeRuntime() { } function resolveMediaMaxBytes(): number | undefined { - const cfg = loadConfig() as CoreConfig; + const cfg = getCore().config.loadConfig() as CoreConfig; if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; } @@ -224,7 +216,7 @@ function resolveMatrixMsgType( contentType?: string, fileName?: string, ): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File { - const kind = mediaKindFromMime(contentType ?? ""); + const kind = getCore().media.mediaKindFromMime(contentType ?? ""); switch (kind) { case "image": return MsgType.Image; @@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: { fileName?: string; }): { useVoice: boolean } { if (!opts.wantsVoice) return { useVoice: false }; - if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) { + if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) { return { useVoice: true }; } return { useVoice: false }; @@ -256,19 +248,19 @@ async function prepareImageInfo(params: { buffer: Buffer; client: MatrixClient; }): Promise { - const meta = await getImageMetadata(params.buffer).catch(() => null); + const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null); if (!meta) return undefined; const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height }; const maxDim = Math.max(meta.width, meta.height); if (maxDim > THUMBNAIL_MAX_SIDE) { try { - const thumbBuffer = await resizeToJpeg({ + const thumbBuffer = await getCore().media.resizeToJpeg({ buffer: params.buffer, maxSide: THUMBNAIL_MAX_SIDE, quality: THUMBNAIL_QUALITY, withoutEnlargement: true, }); - const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null); + const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null); const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, { type: "image/jpeg", name: "thumbnail.jpg", @@ -352,10 +344,10 @@ export async function sendMessageMatrix( }); try { const roomId = await resolveMatrixRoomId(client, to); - const cfg = loadConfig(); - const textLimit = resolveTextChunkLimit(cfg, "matrix"); + const cfg = getCore().config.loadConfig(); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); + const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? undefined : buildReplyRelation(opts.replyToId); const sendContent = (content: RoomMessageEventContent) => @@ -364,7 +356,7 @@ export async function sendMessageMatrix( let lastMessageId = ""; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(); - const media = await loadWebMedia(opts.mediaUrl, maxBytes); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); const contentUri = await uploadFile(client, media.buffer, { contentType: media.contentType, filename: media.fileName, diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 242dd0c9c..04e738c0d 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,11 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; -import { - createMemoryGetTool, - createMemorySearchTool, - registerMemoryCli, -} from "clawdbot/plugin-sdk"; - const memoryCorePlugin = { id: "memory-core", name: "Memory (Core)", @@ -14,11 +8,11 @@ const memoryCorePlugin = { register(api: ClawdbotPluginApi) { api.registerTool( (ctx) => { - const memorySearchTool = createMemorySearchTool({ + const memorySearchTool = api.runtime.tools.createMemorySearchTool({ config: ctx.config, agentSessionKey: ctx.sessionKey, }); - const memoryGetTool = createMemoryGetTool({ + const memoryGetTool = api.runtime.tools.createMemoryGetTool({ config: ctx.config, agentSessionKey: ctx.sessionKey, }); @@ -30,7 +24,7 @@ const memoryCorePlugin = { api.registerCli( ({ program }) => { - registerMemoryCli(program); + api.runtime.tools.registerMemoryCli(program); }, { commands: ["memory"] }, ); diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts index 1aab1fb78..fbf6bb3a5 100644 --- a/extensions/msteams/index.ts +++ b/extensions/msteams/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { msteamsPlugin } from "./src/channel.js"; +import { setMSTeamsRuntime } from "./src/runtime.js"; const plugin = { id: "msteams", name: "Microsoft Teams", description: "Microsoft Teams channel plugin (Bot Framework)", register(api: ClawdbotPluginApi) { + setMSTeamsRuntime(api.runtime); api.registerChannel({ plugin: msteamsPlugin }); }, }; diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index b99e66851..f12787720 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,15 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; +import { setMSTeamsRuntime } from "./runtime.js"; + const detectMimeMock = vi.fn(async () => "image/png"); const saveMediaBufferMock = vi.fn(async () => ({ path: "/tmp/saved.png", contentType: "image/png", })); -vi.mock("clawdbot/plugin-sdk", () => ({ - detectMime: (...args: unknown[]) => detectMimeMock(...args), - saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args), -})); +const runtimeStub = { + media: { + detectMime: (...args: unknown[]) => detectMimeMock(...args), + }, + channel: { + media: { + saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args), + }, + }, +} as unknown as PluginRuntime; describe("msteams attachments", () => { const load = async () => { @@ -19,6 +28,7 @@ describe("msteams attachments", () => { beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); + setMSTeamsRuntime(runtimeStub); }); describe("buildMSTeamsAttachmentPlaceholder", () => { diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 6ba0524a6..7b2c6bf9d 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -1,4 +1,4 @@ -import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk"; +import { getMSTeamsRuntime } from "../runtime.js"; import { extractInlineImageCandidates, inferPlaceholder, @@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: { if (inline.kind !== "data") continue; if (inline.data.byteLength > params.maxBytes) continue; try { - const saved = await saveMediaBuffer( + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( inline.data, inline.contentType, "inbound", @@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: { if (!res.ok) continue; const buffer = Buffer.from(await res.arrayBuffer()); if (buffer.byteLength > params.maxBytes) continue; - const mime = await detectMime({ + const mime = await getMSTeamsRuntime().media.detectMime({ buffer, headerMime: res.headers.get("content-type"), filePath: candidate.fileHint ?? candidate.url, }); - const saved = await saveMediaBuffer( + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( buffer, mime ?? candidate.contentTypeHint, "inbound", diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 4b270d362..6050eeebb 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -1,4 +1,4 @@ -import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk"; +import { getMSTeamsRuntime } from "../runtime.js"; import { downloadMSTeamsImageAttachments } from "./download.js"; import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js"; import type { @@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: { continue; } if (buffer.byteLength > params.maxBytes) continue; - const mime = await detectMime({ + const mime = await getMSTeamsRuntime().media.detectMime({ buffer, headerMime: item.contentType ?? undefined, }); if (mime && !mime.startsWith("image/")) continue; try { - const saved = await saveMediaBuffer( + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( buffer, mime ?? item.contentType ?? undefined, "inbound", diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts index f5673e78e..09d58733e 100644 --- a/extensions/msteams/src/conversation-store-fs.test.ts +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -2,12 +2,29 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { StoredConversationReference } from "./conversation-store.js"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const runtimeStub = { + state: { + resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { + const override = env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return override; + const resolvedHome = homedir ? homedir() : os.homedir(); + return path.join(resolvedHome, ".clawdbot"); + }, + }, +} as unknown as PluginRuntime; describe("msteams conversation store (fs)", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + it("filters and prunes expired entries (but keeps legacy ones)", async () => { const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-")); diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 143706085..5be0fbf2b 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -1,14 +1,35 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; -import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk"; +import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk"; import type { StoredConversationReference } from "./conversation-store.js"; import { type MSTeamsAdapter, renderReplyPayloadsToMessages, sendMSTeamsMessages, } from "./messenger.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const runtimeStub = { + channel: { + text: { + chunkMarkdownText: (text: string, limit: number) => { + if (!text) return []; + if (limit <= 0 || text.length <= limit) return [text]; + const chunks: string[] = []; + for (let index = 0; index < text.length; index += limit) { + chunks.push(text.slice(index, index + limit)); + } + return chunks; + }, + }, + }, +} as unknown as PluginRuntime; describe("msteams messenger", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + describe("renderReplyPayloadsToMessages", () => { it("filters silent replies", () => { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index a2bc6f4cc..573bb33bc 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -1,5 +1,4 @@ import { - chunkMarkdownText, isSilentReplyText, type MSTeamsReplyStyle, type ReplyPayload, @@ -7,6 +6,7 @@ import { } from "clawdbot/plugin-sdk"; import type { StoredConversationReference } from "./conversation-store.js"; import { classifyMSTeamsSendError } from "./errors.js"; +import { getMSTeamsRuntime } from "./runtime.js"; type SendContext = { sendActivity: (textOrActivity: string | object) => Promise; @@ -108,7 +108,7 @@ function pushTextMessages( ) { if (!text) return; if (opts.chunkText) { - for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) { + for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; out.push(trimmed); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index 37e8bf40c..a862729f0 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -1,5 +1,4 @@ import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; -import { danger } from "clawdbot/plugin-sdk"; import type { MSTeamsConversationStore } from "./conversation-store.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; @@ -42,7 +41,7 @@ export function registerMSTeamsHandlers( try { await handleTeamsMessage(context as MSTeamsTurnContext); } catch (err) { - deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`)); + deps.runtime.error?.(`msteams handler failed: ${String(err)}`); } await next(); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 1fcc52075..6addc74fe 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -1,25 +1,10 @@ import { buildPendingHistoryContextFromMap, clearHistoryEntries, - createInboundDebouncer, - danger, DEFAULT_GROUP_HISTORY_LIMIT, - readChannelAllowFromStore, - recordSessionMetaFromInbound, recordPendingHistoryEntry, - resolveAgentRoute, - resolveCommandAuthorizedFromAuthorizers, - resolveInboundDebounceMs, resolveMentionGating, - resolveStorePath, - dispatchReplyFromConfig, - finalizeInboundContext, - formatAgentEnvelope, formatAllowlistMatchMeta, - hasControlCommand, - logVerbose, - shouldLogVerbose, - upsertChannelPairingRequest, type HistoryEntry, } from "clawdbot/plugin-sdk"; @@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; +import { getMSTeamsRuntime } from "../runtime.js"; export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const { @@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { pollStore, log, } = deps; + const core = getMSTeamsRuntime(); + const logVerboseMessage = (message: string) => { + if (core.logging.shouldLogVerbose()) { + log.debug(message); + } + }; const msteamsCfg = cfg.channels?.msteams; const historyLimit = Math.max( 0, @@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { DEFAULT_GROUP_HISTORY_LIMIT, ); const conversationHistories = new Map(); - const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" }); + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "msteams", + }); type MSTeamsDebounceEntry = { context: MSTeamsTurnContext; @@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const senderName = from.name ?? from.id; const senderId = from.aadObjectId ?? from.id; - const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []); + const storedAllowFrom = await core.channel.pairing + .readAllowFromStore("msteams") + .catch(() => []); const useAccessGroups = cfg.commands?.useAccessGroups !== false; // Check DM policy for direct messages. @@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { if (!allowMatch.allowed) { if (dmPolicy === "pairing") { - const request = await upsertChannelPairingRequest({ + const request = await core.channel.pairing.upsertPairingRequest({ channel: "msteams", id: senderId, meta: { name: senderName }, @@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { senderId, senderName, }); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], }); - if (hasControlCommand(text, cfg) && !commandAuthorized) { - logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`); + if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) { + logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); return; } @@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { : `msteams:group:${conversationId}`; const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`; - const route = resolveAgentRoute({ + const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "msteams", peer: { @@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ? `Teams DM from ${senderName}` : `Teams message in ${conversationType} from ${senderName}`; - enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { sessionKey: route.sessionKey, contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`, }); @@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const mediaPayload = buildMSTeamsMediaPayload(mediaList); const envelopeFrom = isDirectMessage ? senderName : conversationType; - const body = formatAgentEnvelope({ + const body = core.channel.reply.formatAgentEnvelope({ channel: "Teams", from: envelopeFrom, timestamp, @@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatAgentEnvelope({ + core.channel.reply.formatAgentEnvelope({ channel: "Teams", from: conversationType, timestamp: entry.timestamp, @@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); } - const ctxPayload = finalizeInboundContext({ + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, RawBody: rawBody, CommandBody: rawBody, @@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - const storePath = resolveStorePath(cfg.session?.store, { + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ + void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, }).catch((err) => { - logVerbose(`msteams: failed updating session meta: ${String(err)}`); + logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); }); - if (shouldLogVerbose()) { - logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); - } + logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ cfg, @@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { log.info("dispatching to agent", { sessionKey: route.sessionKey }); try { - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, @@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } return; } - if (shouldLogVerbose()) { - const finalCount = counts.final; - logVerbose( - `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, - ); - } + const finalCount = counts.final; + logVerboseMessage( + `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, + ); if (isRoomish && historyKey && historyLimit > 0) { clearHistoryEntries({ historyMap: conversationHistories, historyKey }); } } catch (err) { log.error("dispatch failed", { error: String(err) }); - runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`)); + runtime.error?.(`msteams dispatch failed: ${String(err)}`); try { await context.sendActivity( `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`, @@ -535,7 +528,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } }; - const inboundDebouncer = createInboundDebouncer({ + const inboundDebouncer = core.channel.debounce.createInboundDebouncer({ debounceMs: inboundDebounceMs, buildKey: (entry) => { const conversationId = normalizeMSTeamsConversationId( @@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { shouldDebounce: (entry) => { if (!entry.text.trim()) return false; if (entry.attachments.length > 0) return false; - return !hasControlCommand(entry.text, cfg); + return !core.channel.text.hasControlCommand(entry.text, cfg); }, onFlush: async (entries) => { const last = entries.at(-1); @@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); }, onError: (err) => { - runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`)); + runtime.error?.(`msteams debounce flush failed: ${String(err)}`); }, }); diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index 4902ed01c..e1c08a505 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -1,8 +1,6 @@ import type { Request, Response } from "express"; import { - getChildLogger, mergeAllowlist, - resolveTextChunkLimit, summarizeMapping, type ClawdbotConfig, type RuntimeEnv, @@ -19,8 +17,7 @@ import { } from "./resolve-allowlist.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { resolveMSTeamsCredentials } from "./token.js"; - -const log = getChildLogger({ name: "msteams" }); +import { getMSTeamsRuntime } from "./runtime.js"; export type MonitorMSTeamsOpts = { cfg: ClawdbotConfig; @@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = { export async function monitorMSTeamsProvider( opts: MonitorMSTeamsOpts, ): Promise { + const core = getMSTeamsRuntime(); + const log = core.logging.getChildLogger({ name: "msteams" }); let cfg = opts.cfg; let msteamsCfg = cfg.channels?.msteams; if (!msteamsCfg?.enabled) { @@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider( }; const port = msteamsCfg.webhook?.port ?? 3978; - const textLimit = resolveTextChunkLimit(cfg, "msteams"); + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams"); const MB = 1024 * 1024; const agentDefaults = cfg.agents?.defaults; const mediaMaxBytes = diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 77704b8b5..16fdd5c91 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,11 +1,12 @@ -import { chunkMarkdownText, type ChannelOutboundAdapter } from "clawdbot/plugin-sdk"; +import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk"; import { createMSTeamsPollStoreFs } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; export const msteamsOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", - chunker: chunkMarkdownText, + chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 4000, pollMaxOptions: 12, sendText: async ({ cfg, to, text, deps }) => { diff --git a/extensions/msteams/src/polls.test.ts b/extensions/msteams/src/polls.test.ts index 3d4f7c2de..afb1cdbca 100644 --- a/extensions/msteams/src/polls.test.ts +++ b/extensions/msteams/src/polls.test.ts @@ -2,11 +2,28 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const runtimeStub = { + state: { + resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { + const override = env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return override; + const resolvedHome = homedir ? homedir() : os.homedir(); + return path.join(resolvedHome, ".clawdbot"); + }, + }, +} as unknown as PluginRuntime; describe("msteams polls", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + it("builds poll cards with fallback text", () => { const card = buildMSTeamsPollCard({ question: "Lunch?", diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 004909416..38827b340 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,11 +1,7 @@ -import { - createReplyDispatcherWithTyping, - danger, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - type ClawdbotConfig, - type MSTeamsReplyStyle, - type RuntimeEnv, +import type { + ClawdbotConfig, + MSTeamsReplyStyle, + RuntimeEnv, } from "clawdbot/plugin-sdk"; import type { StoredConversationReference } from "./conversation-store.js"; import { @@ -20,6 +16,7 @@ import { } from "./messenger.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; +import { getMSTeamsRuntime } from "./runtime.js"; export function createMSTeamsReplyDispatcher(params: { cfg: ClawdbotConfig; @@ -34,6 +31,7 @@ export function createMSTeamsReplyDispatcher(params: { textLimit: number; onSentMessageIds?: (ids: string[]) => void; }) { + const core = getMSTeamsRuntime(); const sendTypingIndicator = async () => { try { await params.context.sendActivities([{ type: "typing" }]); @@ -42,9 +40,12 @@ export function createMSTeamsReplyDispatcher(params: { } }; - return createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix, - humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), + return core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig( + params.cfg, + params.agentId, + ).responsePrefix, + humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), deliver: async (payload) => { const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, @@ -74,7 +75,7 @@ export function createMSTeamsReplyDispatcher(params: { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); params.runtime.error?.( - danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`), + `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, ); params.log.error("reply failed", { kind: info.kind, diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts new file mode 100644 index 000000000..b6f00f3af --- /dev/null +++ b/extensions/msteams/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setMSTeamsRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMSTeamsRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("MSTeams runtime not initialized"); + } + return runtime; +} diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index f246a4bf6..685d356fc 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -1,5 +1,4 @@ -import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import type { MSTeamsConversationStore, StoredConversationReference, @@ -9,8 +8,10 @@ import type { MSTeamsAdapter } from "./messenger.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { resolveMSTeamsCredentials } from "./token.js"; -let _log: ReturnType | undefined; -const getLog = async (): Promise> => { +type GetChildLogger = PluginRuntime["logging"]["getChildLogger"]; + +let _log: ReturnType | undefined; +const getLog = async (): Promise> => { if (_log) return _log; const { getChildLogger } = await import("../logging.js"); _log = getChildLogger({ name: "msteams:send" }); diff --git a/extensions/msteams/src/storage.ts b/extensions/msteams/src/storage.ts index 2e0bf42e2..09fdcf121 100644 --- a/extensions/msteams/src/storage.ts +++ b/extensions/msteams/src/storage.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { resolveStateDir } from "clawdbot/plugin-sdk"; +import { getMSTeamsRuntime } from "./runtime.js"; export type MSTeamsStorePathOptions = { env?: NodeJS.ProcessEnv; @@ -15,6 +15,8 @@ export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string if (params.stateDir) return path.join(params.stateDir, params.filename); const env = params.env ?? process.env; - const stateDir = params.homedir ? resolveStateDir(env, params.homedir) : resolveStateDir(env); + const stateDir = params.homedir + ? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir) + : getMSTeamsRuntime().state.resolveStateDir(env); return path.join(stateDir, params.filename); } diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index 765610389..39f24840d 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { signalPlugin } from "./src/channel.js"; +import { setSignalRuntime } from "./src/runtime.js"; const plugin = { id: "signal", name: "Signal", description: "Signal channel plugin", register(api: ClawdbotPluginApi) { + setSignalRuntime(api.runtime); api.registerChannel({ plugin: signalPlugin }); }, }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b6020be33..97c6f0695 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, - chunkText, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -13,11 +12,9 @@ import { normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, - probeSignal, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, resolveSignalAccount, - sendMessageSignal, setAccountEnabledInConfigSection, signalOnboardingAdapter, SignalConfigSchema, @@ -25,6 +22,8 @@ import { type ResolvedSignalAccount, } from "clawdbot/plugin-sdk"; +import { getSignalRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("signal"); export const signalPlugin: ChannelPlugin = { @@ -37,7 +36,7 @@ export const signalPlugin: ChannelPlugin = { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), notifyApproval: async ({ id }) => { - await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); + await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); }, }, capabilities: { @@ -197,10 +196,10 @@ export const signalPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: chunkText, + chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = deps?.sendSignal ?? sendMessageSignal; + const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ cfg, resolveChannelLimitMb: ({ cfg, accountId }) => @@ -215,7 +214,7 @@ export const signalPlugin: ChannelPlugin = { return { channel: "signal", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { - const send = deps?.sendSignal ?? sendMessageSignal; + const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ cfg, resolveChannelLimitMb: ({ cfg, accountId }) => @@ -264,7 +263,7 @@ export const signalPlugin: ChannelPlugin = { }), probeAccount: async ({ account, timeoutMs }) => { const baseUrl = account.baseUrl; - return await probeSignal(baseUrl, timeoutMs); + return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, @@ -290,8 +289,7 @@ export const signalPlugin: ChannelPlugin = { }); ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`); // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorSignalProvider } = await import("clawdbot/plugin-sdk"); - return monitorSignalProvider({ + return getSignalRuntime().channel.signal.monitorSignalProvider({ accountId: account.accountId, config: ctx.cfg, runtime: ctx.runtime, diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts new file mode 100644 index 000000000..2af8bd1dc --- /dev/null +++ b/extensions/signal/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setSignalRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getSignalRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Signal runtime not initialized"); + } + return runtime; +} diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 7fe95cafe..9fc8984be 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { slackPlugin } from "./src/channel.js"; +import { setSlackRuntime } from "./src/runtime.js"; const plugin = { id: "slack", name: "Slack", description: "Slack channel plugin", register(api: ClawdbotPluginApi) { + setSlackRuntime(api.runtime); api.registerChannel({ plugin: slackPlugin }); }, }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 36f573ca4..7aec49daf 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -6,28 +6,20 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, - handleSlackAction, - loadConfig, listEnabledSlackAccounts, listSlackAccountIds, listSlackDirectoryGroupsFromConfig, - listSlackDirectoryGroupsLive, listSlackDirectoryPeersFromConfig, - listSlackDirectoryPeersLive, looksLikeSlackTargetId, migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, - probeSlack, readNumberParam, readStringParam, resolveDefaultSlackAccountId, resolveSlackAccount, - resolveSlackChannelAllowlist, resolveSlackGroupRequireMention, - resolveSlackUserAllowlist, - sendMessageSlack, setAccountEnabledInConfigSection, slackOnboardingAdapter, SlackConfigSchema, @@ -36,6 +28,8 @@ import { type ResolvedSlackAccount, } from "clawdbot/plugin-sdk"; +import { getSlackRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("slack"); // Select the appropriate Slack token for read/write operations. @@ -61,7 +55,7 @@ export const slackPlugin: ChannelPlugin = { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), notifyApproval: async ({ id }) => { - const cfg = loadConfig(); + const cfg = getSlackRuntime().config.loadConfig(); const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID, @@ -70,11 +64,11 @@ export const slackPlugin: ChannelPlugin = { const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; if (tokenOverride) { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { token: tokenOverride, }); } else { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); } }, }, @@ -194,8 +188,9 @@ export const slackPlugin: ChannelPlugin = { self: async () => null, listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => listSlackDirectoryPeersLive(params), - listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params), + listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params), + listGroupsLive: async (params) => + getSlackRuntime().channel.slack.listDirectoryGroupsLive(params), }, resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { @@ -209,7 +204,10 @@ export const slackPlugin: ChannelPlugin = { })); } if (kind === "group") { - const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs }); + const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({ + token, + entries: inputs, + }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, @@ -218,7 +216,10 @@ export const slackPlugin: ChannelPlugin = { note: entry.archived ? "archived" : undefined, })); } - const resolved = await resolveSlackUserAllowlist({ token, entries: inputs }); + const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({ + token, + entries: inputs, + }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, @@ -284,7 +285,7 @@ export const slackPlugin: ChannelPlugin = { const mediaUrl = readStringParam(params, "media", { trim: false }); const threadId = readStringParam(params, "threadId"); const replyTo = readStringParam(params, "replyTo"); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "sendMessage", to, @@ -304,7 +305,7 @@ export const slackPlugin: ChannelPlugin = { }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "react", channelId: resolveChannelId(), @@ -322,7 +323,7 @@ export const slackPlugin: ChannelPlugin = { required: true, }); const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "reactions", channelId: resolveChannelId(), @@ -336,7 +337,7 @@ export const slackPlugin: ChannelPlugin = { if (action === "read") { const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "readMessages", channelId: resolveChannelId(), @@ -354,7 +355,7 @@ export const slackPlugin: ChannelPlugin = { required: true, }); const content = readStringParam(params, "message", { required: true }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "editMessage", channelId: resolveChannelId(), @@ -370,7 +371,7 @@ export const slackPlugin: ChannelPlugin = { const messageId = readStringParam(params, "messageId", { required: true, }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "deleteMessage", channelId: resolveChannelId(), @@ -386,7 +387,7 @@ export const slackPlugin: ChannelPlugin = { action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", @@ -400,14 +401,14 @@ export const slackPlugin: ChannelPlugin = { if (action === "member-info") { const userId = readStringParam(params, "userId", { required: true }); - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "memberInfo", userId, accountId: accountId ?? undefined }, cfg, ); } if (action === "emoji-list") { - return await handleSlackAction( + return await getSlackRuntime().channel.slack.handleSlackAction( { action: "emojiList", accountId: accountId ?? undefined }, cfg, ); @@ -492,7 +493,7 @@ export const slackPlugin: ChannelPlugin = { chunker: null, textChunkLimit: 4000, sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { - const send = deps?.sendSlack ?? sendMessageSlack; + const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); @@ -505,7 +506,7 @@ export const slackPlugin: ChannelPlugin = { return { channel: "slack", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { - const send = deps?.sendSlack ?? sendMessageSlack; + const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); @@ -541,7 +542,7 @@ export const slackPlugin: ChannelPlugin = { probeAccount: async ({ account, timeoutMs }) => { const token = account.botToken?.trim(); if (!token) return { ok: false, error: "missing token" }; - return await probeSlack(token, timeoutMs); + return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => { const configured = Boolean(account.botToken && account.appToken); @@ -568,9 +569,7 @@ export const slackPlugin: ChannelPlugin = { const botToken = account.botToken?.trim(); const appToken = account.appToken?.trim(); ctx.log?.info(`[${account.accountId}] starting provider`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorSlackProvider } = await import("clawdbot/plugin-sdk"); - return monitorSlackProvider({ + return getSlackRuntime().channel.slack.monitorSlackProvider({ botToken: botToken ?? "", appToken: appToken ?? "", accountId: account.accountId, diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts new file mode 100644 index 000000000..39a4189cb --- /dev/null +++ b/extensions/slack/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setSlackRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getSlackRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Slack runtime not initialized"); + } + return runtime; +} diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index 333c11fe2..95062b622 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { telegramPlugin } from "./src/channel.js"; +import { setTelegramRuntime } from "./src/runtime.js"; const plugin = { id: "telegram", name: "Telegram", description: "Telegram channel plugin", register(api: ClawdbotPluginApi) { + setTelegramRuntime(api.runtime); api.registerChannel({ plugin: telegramPlugin }); }, }; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index bf5f608c4..ac2958850 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,10 +1,7 @@ import { applyAccountNameToChannelSection, - auditTelegramGroupMembership, buildChannelConfigSchema, - chunkMarkdownText, collectTelegramStatusIssues, - collectTelegramUnmentionedGroupIds, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -17,25 +14,30 @@ import { normalizeAccountId, normalizeTelegramMessagingTarget, PAIRING_APPROVED_MESSAGE, - probeTelegram, resolveDefaultTelegramAccountId, resolveTelegramAccount, resolveTelegramGroupRequireMention, - resolveTelegramToken, - sendMessageTelegram, setAccountEnabledInConfigSection, - shouldLogVerbose, - telegramMessageActions, telegramOnboardingAdapter, TelegramConfigSchema, + type ChannelMessageActionAdapter, type ChannelPlugin, type ClawdbotConfig, type ResolvedTelegramAccount, - writeConfigFile, } from "clawdbot/plugin-sdk"; +import { getTelegramRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("telegram"); +const telegramMessageActions: ChannelMessageActionAdapter = { + listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions.listActions(ctx), + extractToolSend: (ctx) => + getTelegramRuntime().channel.telegram.messageActions.extractToolSend(ctx), + handleAction: async (ctx) => + await getTelegramRuntime().channel.telegram.messageActions.handleAction(ctx), +}; + function parseReplyToMessageId(replyToId?: string | null) { if (!replyToId) return undefined; const parsed = Number.parseInt(replyToId, 10); @@ -63,9 +65,11 @@ export const telegramPlugin: ChannelPlugin = { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), notifyApproval: async ({ cfg, id }) => { - const { token } = resolveTelegramToken(cfg); + const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); if (!token) throw new Error("telegram token not configured"); - await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token }); + await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { + token, + }); }, }, capabilities: { @@ -244,10 +248,11 @@ export const telegramPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: chunkMarkdownText, + chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 4000, sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { - const send = deps?.sendTelegram ?? sendMessageTelegram; + const send = + deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const result = await send(to, text, { @@ -259,7 +264,8 @@ export const telegramPlugin: ChannelPlugin = { return { channel: "telegram", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => { - const send = deps?.sendTelegram ?? sendMessageTelegram; + const send = + deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const result = await send(to, text, { @@ -293,13 +299,17 @@ export const telegramPlugin: ChannelPlugin = { lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ account, timeoutMs }) => - probeTelegram(account.token, timeoutMs, account.config.proxy), + getTelegramRuntime().channel.telegram.probeTelegram( + account.token, + timeoutMs, + account.config.proxy, + ), auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.groups; const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = - collectTelegramUnmentionedGroupIds(groups); + getTelegramRuntime().channel.telegram.collectUnmentionedGroupIds(groups); if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { return undefined; } @@ -318,7 +328,7 @@ export const telegramPlugin: ChannelPlugin = { elapsedMs: 0, }; } - const audit = await auditTelegramGroupMembership({ + const audit = await getTelegramRuntime().channel.telegram.auditGroupMembership({ token: account.token, botId, groupIds, @@ -368,18 +378,20 @@ export const telegramPlugin: ChannelPlugin = { const token = account.token.trim(); let telegramBotLabel = ""; try { - const probe = await probeTelegram(token, 2500, account.config.proxy); + const probe = await getTelegramRuntime().channel.telegram.probeTelegram( + token, + 2500, + account.config.proxy, + ); const username = probe.ok ? probe.bot?.username?.trim() : null; if (username) telegramBotLabel = ` (@${username})`; } catch (err) { - if (shouldLogVerbose()) { + if (getTelegramRuntime().logging.shouldLogVerbose()) { ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); } } ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorTelegramProvider } = await import("clawdbot/plugin-sdk"); - return monitorTelegramProvider({ + return getTelegramRuntime().channel.telegram.monitorTelegramProvider({ token, accountId: account.accountId, config: ctx.cfg, @@ -455,7 +467,7 @@ export const telegramPlugin: ChannelPlugin = { }); const loggedOut = resolved.tokenSource === "none"; if (changed) { - await writeConfigFile(nextCfg); + await getTelegramRuntime().config.writeConfigFile(nextCfg); } return { cleared, envToken: Boolean(envToken), loggedOut }; }, diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts new file mode 100644 index 000000000..df96e1447 --- /dev/null +++ b/extensions/telegram/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setTelegramRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getTelegramRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Telegram runtime not initialized"); + } + return runtime; +} diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index a1b986245..451d9bb4b 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -1,12 +1,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { whatsappPlugin } from "./src/channel.js"; +import { setWhatsAppRuntime } from "./src/runtime.js"; const plugin = { id: "whatsapp", name: "WhatsApp", description: "WhatsApp channel plugin", register(api: ClawdbotPluginApi) { + setWhatsAppRuntime(api.runtime); api.registerChannel({ plugin: whatsappPlugin }); }, }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index fcb6fe6ee..5e6ff23b2 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,23 +1,16 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, - chunkText, collectWhatsAppStatusIssues, createActionGate, - createWhatsAppLoginTool, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, - getActiveWebListener, getChatChannelMeta, - getWebAuthAgeMs, - handleWhatsAppAction, isWhatsAppGroupJid, listWhatsAppAccountIds, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - logWebSelfId, looksLikeWhatsAppTargetId, - logoutWeb, migrateBaseNameToDefaultAccount, missingTargetError, normalizeAccountId, @@ -25,22 +18,19 @@ import { normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, readStringParam, - readWebSelfId, resolveDefaultWhatsAppAccountId, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, resolveWhatsAppHeartbeatRecipients, - sendMessageWhatsApp, - sendPollWhatsApp, - shouldLogVerbose, whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, type ResolvedWhatsAppAccount, - webAuthExists, } from "clawdbot/plugin-sdk"; +import { getWhatsAppRuntime } from "./runtime.js"; + const meta = getChatChannelMeta("whatsapp"); const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -55,7 +45,7 @@ export const whatsappPlugin: ChannelPlugin = { preferSessionLookupForAnnounceTarget: true, }, onboarding: whatsappOnboardingAdapter, - agentTools: () => [createWhatsAppLoginTool()], + agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", }, @@ -110,7 +100,8 @@ export const whatsappPlugin: ChannelPlugin = { }, isEnabled: (account, cfg) => account.enabled !== false && cfg.web?.enabled !== false, disabledReason: () => "disabled", - isConfigured: async (account) => await webAuthExists(account.authDir), + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), unconfiguredReason: () => "not linked", describeAccount: (account) => ({ accountId: account.accountId, @@ -232,7 +223,7 @@ export const whatsappPlugin: ChannelPlugin = { directory: { self: async ({ cfg, accountId }) => { const account = resolveWhatsAppAccount({ cfg, accountId }); - const { e164, jid } = readWebSelfId(account.authDir); + const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir); const id = e164 ?? jid; if (!id) return null; return { @@ -264,7 +255,7 @@ export const whatsappPlugin: ChannelPlugin = { }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await handleWhatsAppAction( + return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction( { action: "react", chatJid: @@ -282,7 +273,7 @@ export const whatsappPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "gateway", - chunker: chunkText, + chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), textChunkLimit: 4000, pollMaxOptions: 12, resolveTarget: ({ to, allowFrom, mode }) => { @@ -335,7 +326,8 @@ export const whatsappPlugin: ChannelPlugin = { }; }, sendText: async ({ to, text, accountId, deps, gifPlayback }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp; const result = await send(to, text, { verbose: false, accountId: accountId ?? undefined, @@ -344,7 +336,8 @@ export const whatsappPlugin: ChannelPlugin = { return { channel: "whatsapp", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp; const result = await send(to, text, { verbose: false, mediaUrl, @@ -354,16 +347,20 @@ export const whatsappPlugin: ChannelPlugin = { return { channel: "whatsapp", ...result }; }, sendPoll: async ({ to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), + await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, { + verbose: getWhatsAppRuntime().logging.shouldLogVerbose(), accountId: accountId ?? undefined, }), }, auth: { login: async ({ cfg, accountId, runtime, verbose }) => { const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); - const { loginWeb } = await import("clawdbot/plugin-sdk"); - await loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); + await getWhatsAppRuntime().channel.whatsapp.loginWeb( + Boolean(verbose), + undefined, + runtime, + resolvedAccountId, + ); }, }, heartbeat: { @@ -372,13 +369,14 @@ export const whatsappPlugin: ChannelPlugin = { return { ok: false, reason: "whatsapp-disabled" }; } const account = resolveWhatsAppAccount({ cfg, accountId }); - const authExists = await (deps?.webAuthExists ?? webAuthExists)(account.authDir); + const authExists = await (deps?.webAuthExists ?? + getWhatsAppRuntime().channel.whatsapp.webAuthExists)(account.authDir); if (!authExists) { return { ok: false, reason: "whatsapp-not-linked" }; } const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() - : Boolean(getActiveWebListener()); + : Boolean(getWhatsAppRuntime().channel.whatsapp.getActiveWebListener()); if (!listenerActive) { return { ok: false, reason: "whatsapp-not-running" }; } @@ -405,10 +403,16 @@ export const whatsappPlugin: ChannelPlugin = { typeof snapshot.linked === "boolean" ? snapshot.linked : authDir - ? await webAuthExists(authDir) + ? await getWhatsAppRuntime().channel.whatsapp.webAuthExists(authDir) : false; - const authAgeMs = linked && authDir ? getWebAuthAgeMs(authDir) : null; - const self = linked && authDir ? readWebSelfId(authDir) : { e164: null, jid: null }; + const authAgeMs = + linked && authDir + ? getWhatsAppRuntime().channel.whatsapp.getWebAuthAgeMs(authDir) + : null; + const self = + linked && authDir + ? getWhatsAppRuntime().channel.whatsapp.readWebSelfId(authDir) + : { e164: null, jid: null }; return { configured: linked, linked, @@ -425,7 +429,7 @@ export const whatsappPlugin: ChannelPlugin = { }; }, buildAccountSnapshot: async ({ account, runtime }) => { - const linked = await webAuthExists(account.authDir); + const linked = await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir); return { accountId: account.accountId, name: account.name, @@ -446,19 +450,21 @@ export const whatsappPlugin: ChannelPlugin = { }, resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), logSelfId: ({ account, runtime, includeChannelPrefix }) => { - logWebSelfId(account.authDir, runtime, includeChannelPrefix); + getWhatsAppRuntime().channel.whatsapp.logWebSelfId( + account.authDir, + runtime, + includeChannelPrefix, + ); }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; - const { e164, jid } = readWebSelfId(account.authDir); + const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir); const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorWebChannel } = await import("clawdbot/plugin-sdk"); - return monitorWebChannel( - shouldLogVerbose(), + return getWhatsAppRuntime().channel.whatsapp.monitorWebChannel( + getWhatsAppRuntime().logging.shouldLogVerbose(), undefined, true, undefined, @@ -471,22 +477,16 @@ export const whatsappPlugin: ChannelPlugin = { ); }, loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => - await (async () => { - const { startWebLoginWithQr } = await import("clawdbot/plugin-sdk"); - return await startWebLoginWithQr({ - accountId, - force, - timeoutMs, - verbose, - }); - })(), + await getWhatsAppRuntime().channel.whatsapp.startWebLoginWithQr({ + accountId, + force, + timeoutMs, + verbose, + }), loginWithQrWait: async ({ accountId, timeoutMs }) => - await (async () => { - const { waitForWebLogin } = await import("clawdbot/plugin-sdk"); - return await waitForWebLogin({ accountId, timeoutMs }); - })(), + await getWhatsAppRuntime().channel.whatsapp.waitForWebLogin({ accountId, timeoutMs }), logoutAccount: async ({ account, runtime }) => { - const cleared = await logoutWeb({ + const cleared = await getWhatsAppRuntime().channel.whatsapp.logoutWeb({ authDir: account.authDir, isLegacyAuthDir: account.isLegacyAuthDir, runtime, diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts new file mode 100644 index 000000000..59ce96388 --- /dev/null +++ b/extensions/whatsapp/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setWhatsAppRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getWhatsAppRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("WhatsApp runtime not initialized"); + } + return runtime; +} diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index cad7d0694..f460d23a0 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,15 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { - finalizeInboundContext, - formatAgentEnvelope, - isControlCommandMessage, - recordSessionMetaFromInbound, - resolveCommandAuthorizedFromAuthorizers, - resolveStorePath, - shouldComputeCommandAuthorized, - type ClawdbotConfig, -} from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -448,7 +439,10 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const rawBody = text?.trim() || (mediaPath ? "" : ""); - const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized( + rawBody, + config, + ); const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeAuth) ? await core.channel.pairing.readAllowFromStore("zalo").catch(() => []) @@ -457,7 +451,7 @@ async function processMessageWithPipeline(params: { const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom); const commandAuthorized = shouldComputeAuth - ? resolveCommandAuthorizedFromAuthorizers({ + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }], }) @@ -526,20 +520,24 @@ async function processMessageWithPipeline(params: { }, }); - if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) { + if ( + isGroup && + core.channel.commands.isControlCommandMessage(rawBody, config) && + commandAuthorized !== true + ) { logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`); return; } const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const body = formatAgentEnvelope({ + const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo", from: fromLabel, timestamp: date ? date * 1000 : undefined, body: rawBody, }); - const ctxPayload = finalizeInboundContext({ + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: rawBody, CommandBody: rawBody, @@ -562,10 +560,10 @@ async function processMessageWithPipeline(params: { OriginatingTo: `zalo:${chatId}`, }); - const storePath = resolveStorePath(config.session?.store, { + const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ + void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 9f028722c..ec1cb5a60 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,17 +1,7 @@ import type { ChildProcess } from "node:child_process"; import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; -import { - finalizeInboundContext, - formatAgentEnvelope, - isControlCommandMessage, - mergeAllowlist, - recordSessionMetaFromInbound, - resolveCommandAuthorizedFromAuthorizers, - resolveStorePath, - shouldComputeCommandAuthorized, - summarizeMapping, -} from "clawdbot/plugin-sdk"; +import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk"; import { sendMessageZalouser } from "./send.js"; import type { ResolvedZalouserAccount, @@ -193,7 +183,10 @@ async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const rawBody = content.trim(); - const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized( + rawBody, + config, + ); const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeAuth) ? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => []) @@ -202,7 +195,7 @@ async function processMessage( const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom); const commandAuthorized = shouldComputeAuth - ? resolveCommandAuthorizedFromAuthorizers({ + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }], }) @@ -258,7 +251,11 @@ async function processMessage( } } - if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) { + if ( + isGroup && + core.channel.commands.isControlCommandMessage(rawBody, config) && + commandAuthorized !== true + ) { logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`); return; } @@ -277,14 +274,14 @@ async function processMessage( }); const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const body = formatAgentEnvelope({ + const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: timestamp ? timestamp * 1000 : undefined, body: rawBody, }); - const ctxPayload = finalizeInboundContext({ + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: rawBody, CommandBody: rawBody, @@ -304,10 +301,10 @@ async function processMessage( OriginatingTo: `zalouser:${chatId}`, }); - const storePath = resolveStorePath(config.session?.store, { + const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ + void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, diff --git a/src/agents/tools/sessions-announce-target.test.ts b/src/agents/tools/sessions-announce-target.test.ts index 17f7eb2e7..80ba138dd 100644 --- a/src/agents/tools/sessions-announce-target.test.ts +++ b/src/agents/tools/sessions-announce-target.test.ts @@ -13,6 +13,25 @@ const installRegistry = async () => { const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); setActivePluginRegistry( createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, { pluginId: "whatsapp", source: "test", diff --git a/src/agents/tools/whatsapp-actions.test.ts b/src/agents/tools/whatsapp-actions.test.ts index 63b4a1adc..a05993c27 100644 --- a/src/agents/tools/whatsapp-actions.test.ts +++ b/src/agents/tools/whatsapp-actions.test.ts @@ -4,9 +4,11 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { handleWhatsAppAction } from "./whatsapp-actions.js"; const sendReactionWhatsApp = vi.fn(async () => undefined); +const sendPollWhatsApp = vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })); vi.mock("../../web/outbound.js", () => ({ sendReactionWhatsApp: (...args: unknown[]) => sendReactionWhatsApp(...args), + sendPollWhatsApp: (...args: unknown[]) => sendPollWhatsApp(...args), })); const enabledConfig = { diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index fafa82432..66f9d15c7 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -1,8 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js"; import { listChatCommands } from "./commands-registry.js"; import { parseActivationCommand } from "./group-activation.js"; import { parseSendPolicyCommand } from "./send-policy.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; + +beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); + +afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); describe("control command parsing", () => { it("requires slash for send policy", () => { diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index e7b9a62c4..6f7c5a2f6 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -1,4 +1,5 @@ import { listChannelDocks } from "../channels/dock.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; import { listThinkingLevels } from "./thinking.js"; import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js"; @@ -111,7 +112,12 @@ function assertCommandRegistry(commands: ChatCommandDefinition[]): void { } } -export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { +let cachedCommands: ChatCommandDefinition[] | null = null; +let cachedRegistry: ReturnType | null = null; +let cachedNativeCommandSurfaces: Set | null = null; +let cachedNativeRegistry: ReturnType | null = null; + +function buildChatCommands(): ChatCommandDefinition[] { const commands: ChatCommandDefinition[] = [ defineChatCommand({ key: "help", @@ -454,17 +460,28 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { assertCommandRegistry(commands); return commands; -})(); +} -let cachedNativeCommandSurfaces: Set | null = null; +export function getChatCommands(): ChatCommandDefinition[] { + const registry = getActivePluginRegistry(); + if (cachedCommands && registry === cachedRegistry) return cachedCommands; + const commands = buildChatCommands(); + cachedCommands = commands; + cachedRegistry = registry; + cachedNativeCommandSurfaces = null; + return commands; +} -export const getNativeCommandSurfaces = (): Set => { - if (!cachedNativeCommandSurfaces) { - cachedNativeCommandSurfaces = new Set( - listChannelDocks() - .filter((dock) => dock.capabilities.nativeCommands) - .map((dock) => dock.id), - ); +export function getNativeCommandSurfaces(): Set { + const registry = getActivePluginRegistry(); + if (cachedNativeCommandSurfaces && registry === cachedNativeRegistry) { + return cachedNativeCommandSurfaces; } + cachedNativeCommandSurfaces = new Set( + listChannelDocks() + .filter((dock) => dock.capabilities.nativeCommands) + .map((dock) => dock.id), + ); + cachedNativeRegistry = registry; return cachedNativeCommandSurfaces; -}; +} diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index f1484bad7..8863e6ff5 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildCommandText, @@ -10,6 +10,16 @@ import { normalizeCommandBody, shouldHandleTextCommands, } from "./commands-registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; + +beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); + +afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); describe("commands registry", () => { it("builds command text with args", () => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 0ce3be6b3..983f2ea9c 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig } from "../config/types.js"; import type { SkillCommandSpec } from "../agents/skills.js"; -import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.js"; +import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { @@ -16,7 +16,6 @@ import type { ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; -export { CHAT_COMMANDS } from "./commands-registry.data.js"; export type { ChatCommandDefinition, CommandArgChoiceContext, @@ -37,9 +36,16 @@ type TextAliasSpec = { acceptsArgs: boolean; }; -const TEXT_ALIAS_MAP: Map = (() => { +let cachedTextAliasMap: Map | null = null; +let cachedTextAliasCommands: ChatCommandDefinition[] | null = null; +let cachedDetection: CommandDetection | undefined; +let cachedDetectionCommands: ChatCommandDefinition[] | null = null; + +function getTextAliasMap(): Map { + const commands = getChatCommands(); + if (cachedTextAliasMap && cachedTextAliasCommands === commands) return cachedTextAliasMap; const map = new Map(); - for (const command of CHAT_COMMANDS) { + for (const command of commands) { // Canonicalize to the *primary* text alias, not `/${key}`. Some command keys are // internal identifiers (e.g. `dock:telegram`) while the public text command is // the alias (e.g. `/dock-telegram`). @@ -53,10 +59,10 @@ const TEXT_ALIAS_MAP: Map = (() => { } } } + cachedTextAliasMap = map; + cachedTextAliasCommands = commands; return map; -})(); - -let cachedDetection: CommandDetection | undefined; +} function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -78,8 +84,9 @@ function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatC export function listChatCommands(params?: { skillCommands?: SkillCommandSpec[]; }): ChatCommandDefinition[] { - if (!params?.skillCommands?.length) return [...CHAT_COMMANDS]; - return [...CHAT_COMMANDS, ...buildSkillCommandDefinitions(params.skillCommands)]; + const commands = getChatCommands(); + if (!params?.skillCommands?.length) return [...commands]; + return [...commands, ...buildSkillCommandDefinitions(params.skillCommands)]; } export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boolean { @@ -93,7 +100,7 @@ export function listChatCommandsForConfig( cfg: ClawdbotConfig, params?: { skillCommands?: SkillCommandSpec[] }, ): ChatCommandDefinition[] { - const base = CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key)); + const base = getChatCommands().filter((command) => isCommandEnabled(cfg, command.key)); if (!params?.skillCommands?.length) return base; return [...base, ...buildSkillCommandDefinitions(params.skillCommands)]; } @@ -127,7 +134,7 @@ export function listNativeCommandSpecsForConfig( export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined { const normalized = name.trim().toLowerCase(); - return CHAT_COMMANDS.find( + return getChatCommands().find( (command) => command.scope !== "text" && command.nativeName?.toLowerCase() === normalized, ); } @@ -299,14 +306,15 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti : normalized; const lowered = commandBody.toLowerCase(); - const exact = TEXT_ALIAS_MAP.get(lowered); + const textAliasMap = getTextAliasMap(); + const exact = textAliasMap.get(lowered); if (exact) return exact.canonical; const tokenMatch = commandBody.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); if (!tokenMatch) return commandBody; const [, token, rest] = tokenMatch; const tokenKey = `/${token.toLowerCase()}`; - const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey); + const tokenSpec = textAliasMap.get(tokenKey); if (!tokenSpec) return commandBody; if (rest && !tokenSpec.acceptsArgs) return commandBody; const normalizedRest = rest?.trimStart(); @@ -319,10 +327,11 @@ export function isCommandMessage(raw: string): boolean { } export function getCommandDetection(_cfg?: ClawdbotConfig): CommandDetection { - if (cachedDetection) return cachedDetection; + const commands = getChatCommands(); + if (cachedDetection && cachedDetectionCommands === commands) return cachedDetection; const exact = new Set(); const patterns: string[] = []; - for (const cmd of CHAT_COMMANDS) { + for (const cmd of commands) { for (const alias of cmd.textAliases) { const normalized = alias.trim().toLowerCase(); if (!normalized) continue; @@ -340,6 +349,7 @@ export function getCommandDetection(_cfg?: ClawdbotConfig): CommandDetection { exact, regex: patterns.length ? new RegExp(`^(?:${patterns.join("|")})$`, "i") : /$^/, }; + cachedDetectionCommands = commands; return cachedDetection; } @@ -353,7 +363,7 @@ export function maybeResolveTextAlias(raw: string, cfg?: ClawdbotConfig) { const tokenMatch = normalized.match(/^\/([^\s:]+)(?:\s|$)/); if (!tokenMatch) return null; const tokenKey = `/${tokenMatch[1]}`; - return TEXT_ALIAS_MAP.has(tokenKey) ? tokenKey : null; + return getTextAliasMap().has(tokenKey) ? tokenKey : null; } export function resolveTextCommand( @@ -366,9 +376,9 @@ export function resolveTextCommand( const trimmed = normalizeCommandBody(raw).trim(); const alias = maybeResolveTextAlias(trimmed, cfg); if (!alias) return null; - const spec = TEXT_ALIAS_MAP.get(alias); + const spec = getTextAliasMap().get(alias); if (!spec) return null; - const command = CHAT_COMMANDS.find((entry) => entry.key === spec.key); + const command = getChatCommands().find((entry) => entry.key === spec.key); if (!command) return null; if (!spec.acceptsArgs) return { command }; const args = trimmed.slice(alias.length).trim(); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ac5b7a623..a1b1a4f5b 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -48,6 +48,7 @@ vi.mock("../../telegram/send.js", () => ({ })); vi.mock("../../web/outbound.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, + sendPollWhatsApp: mocks.sendMessageWhatsApp, })); vi.mock("../../infra/outbound/deliver.js", async () => { const actual = await vi.importActual( diff --git a/src/channels/dock.ts b/src/channels/dock.ts index b206751d8..92199a0f2 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -7,7 +7,7 @@ import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, @@ -320,8 +320,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { } function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { - const registry = getActivePluginRegistry(); - if (!registry) return []; + const registry = requireActivePluginRegistry(); const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = []; const seen = new Set(); for (const entry of registry.channels) { @@ -358,8 +357,8 @@ export function listChannelDocks(): ChannelDock[] { export function getChannelDock(id: ChannelId): ChannelDock | undefined { const core = DOCKS[id as ChatChannelId]; if (core) return core; - const registry = getActivePluginRegistry(); - const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); + const registry = requireActivePluginRegistry(); + const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id); if (!pluginEntry) return undefined; return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin); } diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts deleted file mode 100644 index 8a69ecf2a..000000000 --- a/src/channels/plugins/discord.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { - listDiscordAccountIds, - type ResolvedDiscordAccount, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../../discord/accounts.js"; -import { - auditDiscordChannelPermissions, - collectDiscordAuditChannelIds, -} from "../../discord/audit.js"; -import { probeDiscord } from "../../discord/probe.js"; -import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; -import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; -import { shouldLogVerbose } from "../../globals.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { getChatChannelMeta } from "../registry.js"; -import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js"; -import { discordMessageActions } from "./actions/discord.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "./config-helpers.js"; -import { resolveDiscordGroupRequireMention } from "./group-mentions.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize/discord.js"; -import { discordOnboardingAdapter } from "./onboarding/discord.js"; -import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import { collectDiscordStatusIssues } from "./status-issues/discord.js"; -import type { ChannelPlugin } from "./types.js"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, -} from "./directory-config.js"; -import { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../../discord/directory-live.js"; - -const meta = getChatChannelMeta("discord"); - -export const discordPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...meta, - }, - onboarding: discordOnboardingAdapter, - pairing: { - idLabel: "discordUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), - notifyApproval: async ({ id }) => { - await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE); - }, - }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), - config: { - listAccountIds: (cfg) => listDiscordAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "discord", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "discord", - accountId, - clearBaseFields: ["token", "name"], - }), - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.toLowerCase()), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]); - const allowFromPath = useAccountPath - ? `channels.discord.accounts.${resolvedAccountId}.dm.` - : "channels.discord.dm."; - return { - policy: account.config.dm?.policy ?? "pairing", - allowFrom: account.config.dm?.allowFrom ?? [], - allowFromPath, - approveHint: formatPairingApproveHint("discord"), - normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), - }; - }, - collectWarnings: ({ account, cfg }) => { - const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; - const guildEntries = account.config.guilds ?? {}; - const guildsConfigured = Object.keys(guildEntries).length > 0; - const channelAllowlistConfigured = guildsConfigured; - - if (groupPolicy === "open") { - if (channelAllowlistConfigured) { - warnings.push( - `- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels.`, - ); - } else { - warnings.push( - `- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels.`, - ); - } - } - - return warnings; - }, - }, - groups: { - resolveRequireMention: resolveDiscordGroupRequireMention, - }, - mentions: { - stripPatterns: () => ["<@!?\\d+>"], - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", - }, - messaging: { - normalizeTarget: normalizeDiscordMessagingTarget, - targetResolver: { - looksLikeId: looksLikeDiscordTargetId, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), - listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => listDiscordDirectoryPeersLive(params), - listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params), - }, - resolver: { - resolveTargets: async ({ cfg, accountId, inputs, kind }) => { - const account = resolveDiscordAccount({ cfg, accountId }); - const token = account.token?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Discord token", - })); - } - if (kind === "group") { - const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.channelId ?? entry.guildId, - name: - entry.channelName ?? - entry.guildName ?? - (entry.guildId && !entry.channelId ? entry.guildId : undefined), - note: entry.note, - })); - } - const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); - }, - }, - actions: discordMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "discord", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "DISCORD_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token) { - return "Discord requires token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "discord", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "discord", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, - }, - outbound: { - deliveryMode: "direct", - chunker: null, - textChunkLimit: 2000, - pollMaxOptions: 10, - sendText: async ({ to, text, accountId, deps, replyToId }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; - const result = await send(to, text, { - verbose: false, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; - const result = await send(to, text, { - verbose: false, - mediaUrl, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ to, poll, accountId }) => - await sendPollDiscord(to, poll, { - accountId: accountId ?? undefined, - }), - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: collectDiscordStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account, timeoutMs }) => - probeDiscord(account.token, timeoutMs, { includeApplication: true }), - auditAccount: async ({ account, timeoutMs, cfg }) => { - const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ - cfg, - accountId: account.accountId, - }); - if (!channelIds.length && unresolvedChannels === 0) return undefined; - const botToken = account.token?.trim(); - if (!botToken) { - return { - ok: unresolvedChannels === 0, - checkedChannels: 0, - unresolvedChannels, - channels: [], - elapsedMs: 0, - }; - } - const audit = await auditDiscordChannelPermissions({ - token: botToken, - accountId: account.accountId, - channelIds, - timeoutMs, - }); - return { ...audit, unresolvedChannels }; - }, - buildAccountSnapshot: ({ account, runtime, probe, audit }) => { - const configured = Boolean(account.token?.trim()); - const app = runtime?.application ?? (probe as { application?: unknown })?.application; - const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - tokenSource: account.tokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - application: app ?? undefined, - bot: bot ?? undefined, - probe, - audit, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }; - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const token = account.token.trim(); - let discordBotLabel = ""; - try { - const probe = await probeDiscord(token, 2500, { - includeApplication: true, - }); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) discordBotLabel = ` (@${username})`; - ctx.setStatus({ - accountId: account.accountId, - bot: probe.bot, - application: probe.application, - }); - const messageContent = probe.application?.intents?.messageContent; - if (messageContent === "disabled") { - ctx.log?.warn( - `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, - ); - } else if (messageContent === "limited") { - ctx.log?.info( - `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`, - ); - } - } catch (err) { - if (shouldLogVerbose()) { - ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); - } - } - ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorDiscordProvider } = await import("../../discord/index.js"); - return monitorDiscordProvider({ - token, - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - mediaMaxMb: account.config.mediaMaxMb, - historyLimit: account.config.historyLimit, - }); - }, - }, -}; diff --git a/src/channels/plugins/imessage.ts b/src/channels/plugins/imessage.ts deleted file mode 100644 index ca0d207fc..000000000 --- a/src/channels/plugins/imessage.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { chunkText } from "../../auto-reply/chunk.js"; -import { - listIMessageAccountIds, - type ResolvedIMessageAccount, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../../imessage/accounts.js"; -import { probeIMessage } from "../../imessage/probe.js"; -import { sendMessageIMessage } from "../../imessage/send.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { getChatChannelMeta } from "../registry.js"; -import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "./config-helpers.js"; -import { resolveIMessageGroupRequireMention } from "./group-mentions.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { resolveChannelMediaMaxBytes } from "./media-limits.js"; -import { imessageOnboardingAdapter } from "./onboarding/imessage.js"; -import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import type { ChannelPlugin } from "./types.js"; - -const meta = getChatChannelMeta("imessage"); - -export const imessagePlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...meta, - showConfigured: false, - }, - onboarding: imessageOnboardingAdapter, - pairing: { - idLabel: "imessageSenderId", - notifyApproval: async ({ id }) => { - await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); - }, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.imessage?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.imessage.accounts.${resolvedAccountId}.` - : "channels.imessage."; - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("imessage"), - }; - }, - collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - if (groupPolicy !== "open") return []; - return [ - `- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`, - ]; - }, - }, - groups: { - resolveRequireMention: resolveIMessageGroupRequireMention, - }, - messaging: { - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^(imessage:|chat_id:)/i.test(trimmed)) return true; - if (trimmed.includes("@")) return true; - return /^\+?\d{3,}$/.test(trimmed); - }, - hint: "", - }, - }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "imessage", - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "imessage", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "imessage", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - accounts: { - ...next.channels?.imessage?.accounts, - [accountId]: { - ...next.channels?.imessage?.accounts?.[accountId], - enabled: true, - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), - }, - }, - }, - }, - }; - }, - }, - outbound: { - deliveryMode: "direct", - chunker: chunkText, - textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { - const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - cliPath: null, - dbPath: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) return []; - return [ - { - channel: "imessage", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - cliPath: snapshot.cliPath ?? null, - dbPath: snapshot.dbPath ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs), - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - cliPath: runtime?.cliPath ?? account.config.cliPath ?? null, - dbPath: runtime?.dbPath ?? account.config.dbPath ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }), - resolveAccountState: ({ enabled }) => (enabled ? "enabled" : "disabled"), - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const cliPath = account.config.cliPath?.trim() || "imsg"; - const dbPath = account.config.dbPath?.trim(); - ctx.setStatus({ - accountId: account.accountId, - cliPath, - dbPath: dbPath ?? null, - }); - ctx.log?.info( - `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, - ); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorIMessageProvider } = await import("../../imessage/index.js"); - return monitorIMessageProvider({ - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - }); - }, - }, -}; diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 09571a7c6..d3861f7fe 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -1,6 +1,6 @@ -import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeChatChannelId } from "../registry.js"; +import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; -import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import { requireActivePluginRegistry } from "../../plugins/runtime.js"; // Channel plugins registry (runtime). // @@ -10,8 +10,7 @@ import { getActivePluginRegistry } from "../../plugins/runtime.js"; // // Channel plugins are registered by the plugin loader (extensions/ or configured paths). function listPluginChannels(): ChannelPlugin[] { - const registry = getActivePluginRegistry(); - if (!registry) return []; + const registry = requireActivePluginRegistry(); return registry.channels.map((entry) => entry.plugin); } @@ -46,18 +45,9 @@ export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { } export function normalizeChannelId(raw?: string | null): ChannelId | null { - // Channel docking: keep input normalization centralized in src/channels/registry.ts - // so CLI/API/protocol can rely on stable aliases without plugin init side effects. - const normalized = normalizeChatChannelId(raw); - if (normalized) return normalized; - const trimmed = raw?.trim(); - if (!trimmed) return null; - const key = trimmed.toLowerCase(); - const plugin = listChannelPlugins().find((entry) => { - if (entry.id.toLowerCase() === key) return true; - return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key); - }); - return plugin?.id ?? null; + // Channel docking: keep input normalization centralized in src/channels/registry.ts. + // Plugin registry must be initialized before calling. + return normalizeAnyChannelId(raw); } export { listDiscordDirectoryGroupsFromConfig, diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts deleted file mode 100644 index 5c9a43d61..000000000 --- a/src/channels/plugins/signal.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { chunkText } from "../../auto-reply/chunk.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { - listSignalAccountIds, - type ResolvedSignalAccount, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../../signal/accounts.js"; -import { probeSignal } from "../../signal/probe.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import { normalizeE164 } from "../../utils.js"; -import { getChatChannelMeta } from "../registry.js"; -import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "./config-helpers.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { resolveChannelMediaMaxBytes } from "./media-limits.js"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; -import { signalOnboardingAdapter } from "./onboarding/signal.js"; -import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import type { ChannelPlugin } from "./types.js"; - -const meta = getChatChannelMeta("signal"); - -export const signalPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...meta, - }, - onboarding: signalOnboardingAdapter, - pairing: { - idLabel: "signalNumber", - normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), - notifyApproval: async ({ id }) => { - await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); - }, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.signal?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.signal.accounts.${resolvedAccountId}.` - : "channels.signal."; - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("signal"), - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }; - }, - collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - if (groupPolicy !== "open") return []; - return [ - `- Signal groups: groupPolicy="open" allows any member to trigger the bot. Set channels.signal.groupPolicy="allowlist" + channels.signal.groupAllowFrom to restrict senders.`, - ]; - }, - }, - messaging: { - normalizeTarget: normalizeSignalMessagingTarget, - targetResolver: { - looksLikeId: looksLikeSignalTargetId, - hint: "", - }, - }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "signal", - accountId, - name, - }), - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "signal", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "signal", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }, - }, - }, - }, - }; - }, - }, - outbound: { - deliveryMode: "direct", - chunker: chunkText, - textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { - const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) return []; - return [ - { - channel: "signal", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - baseUrl: snapshot.baseUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account, timeoutMs }) => { - const baseUrl = account.baseUrl; - return await probeSignal(baseUrl, timeoutMs); - }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }), - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - baseUrl: account.baseUrl, - }); - ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorSignalProvider } = await import("../../signal/index.js"); - return monitorSignalProvider({ - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - mediaMaxMb: account.config.mediaMaxMb, - }); - }, - }, -}; diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts deleted file mode 100644 index aeb593645..000000000 --- a/src/channels/plugins/slack.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js"; -import { handleSlackAction } from "../../agents/tools/slack-actions.js"; -import { loadConfig } from "../../config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { - listEnabledSlackAccounts, - listSlackAccountIds, - type ResolvedSlackAccount, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../../slack/accounts.js"; -import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; -import { probeSlack } from "../../slack/probe.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { getChatChannelMeta } from "../registry.js"; -import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "./config-helpers.js"; -import { resolveSlackGroupRequireMention } from "./group-mentions.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize/slack.js"; -import { slackOnboardingAdapter } from "./onboarding/slack.js"; -import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; -import { - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, -} from "./directory-config.js"; -import { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../../slack/directory-live.js"; - -const meta = getChatChannelMeta("slack"); - -// Select the appropriate Slack token for read/write operations. -function getTokenForOperation( - account: ResolvedSlackAccount, - operation: "read" | "write", -): string | undefined { - const userToken = account.config.userToken?.trim() || undefined; - const botToken = account.botToken?.trim(); - const allowUserWrites = account.config.userTokenReadOnly === false; - if (operation === "read") return userToken ?? botToken; - if (!allowUserWrites) return botToken; - return botToken ?? userToken; -} - -export const slackPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...meta, - }, - onboarding: slackOnboardingAdapter, - pairing: { - idLabel: "slackUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), - notifyApproval: async ({ id }) => { - const cfg = loadConfig(); - const account = resolveSlackAccount({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - if (tokenOverride) { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { - token: tokenOverride, - }); - } else { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); - } - }, - }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - listAccountIds: (cfg) => listSlackAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "slack", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "slack", - accountId, - clearBaseFields: ["botToken", "appToken", "name"], - }), - isConfigured: (account) => Boolean(account.botToken && account.appToken), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.botToken && account.appToken), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map((entry) => String(entry)), - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.toLowerCase()), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.slack?.accounts?.[resolvedAccountId]); - const allowFromPath = useAccountPath - ? `channels.slack.accounts.${resolvedAccountId}.dm.` - : "channels.slack.dm."; - return { - policy: account.dm?.policy ?? "pairing", - allowFrom: account.dm?.allowFrom ?? [], - allowFromPath, - approveHint: formatPairingApproveHint("slack"), - normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), - }; - }, - collectWarnings: ({ account, cfg }) => { - const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; - const channelAllowlistConfigured = - Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; - - if (groupPolicy === "open") { - if (channelAllowlistConfigured) { - warnings.push( - `- Slack channels: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels.`, - ); - } else { - warnings.push( - `- Slack channels: groupPolicy="open" with no channel allowlist; any channel can trigger (mention-gated). Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels.`, - ); - } - } - - return warnings; - }, - }, - groups: { - resolveRequireMention: resolveSlackGroupRequireMention, - }, - threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", - allowTagsWhenOff: true, - buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { - const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; - const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode; - return { - currentChannelId: context.To?.startsWith("channel:") - ? context.To.slice("channel:".length) - : undefined, - currentThreadTs: context.ReplyToId, - replyToMode: effectiveReplyToMode, - hasRepliedRef, - }; - }, - }, - messaging: { - normalizeTarget: normalizeSlackMessagingTarget, - targetResolver: { - looksLikeId: looksLikeSlackTargetId, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), - listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => listSlackDirectoryPeersLive(params), - listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params), - }, - resolver: { - resolveTargets: async ({ cfg, accountId, inputs, kind }) => { - const account = resolveSlackAccount({ cfg, accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Slack token", - })); - } - if (kind === "group") { - const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.archived ? "archived" : undefined, - })); - } - const resolved = await resolveSlackUserAllowlist({ token, entries: inputs }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); - }, - }, - actions: { - listActions: ({ cfg }) => { - const accounts = listEnabledSlackAccounts(cfg).filter( - (account) => account.botTokenSource !== "none", - ); - if (accounts.length === 0) return []; - const isActionEnabled = (key: string, defaultValue = true) => { - for (const account of accounts) { - const gate = createActionGate( - (account.actions ?? cfg.channels?.slack?.actions) as Record< - string, - boolean | undefined - >, - ); - if (gate(key, defaultValue)) return true; - } - return false; - }; - - const actions = new Set(["send"]); - if (isActionEnabled("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (isActionEnabled("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (isActionEnabled("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (isActionEnabled("memberInfo")) actions.add("member-info"); - if (isActionEnabled("emojiList")) actions.add("emoji-list"); - return Array.from(actions); - }, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") return null; - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) return null; - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { - const resolveChannelId = () => - readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "message", { - required: true, - allowEmpty: true, - }); - const mediaUrl = readStringParam(params, "media", { trim: false }); - const threadId = readStringParam(params, "threadId"); - const replyTo = readStringParam(params, "replyTo"); - return await handleSlackAction( - { - action: "sendMessage", - to, - content, - mediaUrl: mediaUrl ?? undefined, - accountId: accountId ?? undefined, - threadTs: threadId ?? replyTo ?? undefined, - }, - cfg, - toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await handleSlackAction( - { - action: "react", - channelId: resolveChannelId(), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( - { - action: "reactions", - channelId: resolveChannelId(), - messageId, - limit, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( - { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const content = readStringParam(params, "message", { required: true }); - return await handleSlackAction( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - return await handleSlackAction( - { - action: "deleteMessage", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(params, "messageId", { required: true }); - return await handleSlackAction( - { - action: - action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(params, "userId", { required: true }); - return await handleSlackAction( - { action: "memberInfo", userId, accountId: accountId ?? undefined }, - cfg, - ); - } - - if (action === "emoji-list") { - return await handleSlackAction( - { action: "emojiList", accountId: accountId ?? undefined }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); - }, - }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "slack", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Slack env tokens can only be used for the default account."; - } - if (!input.useEnv && (!input.botToken || !input.appToken)) { - return "Slack requires --bot-token and --app-token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "slack", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "slack", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, - }, - outbound: { - deliveryMode: "direct", - chunker: null, - textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - const result = await send(to, text, { - threadTs: replyToId ?? undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - const result = await send(to, text, { - mediaUrl, - threadTs: replyToId ?? undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - botTokenSource: snapshot.botTokenSource ?? "none", - appTokenSource: snapshot.appTokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account, timeoutMs }) => { - const token = account.botToken?.trim(); - if (!token) return { ok: false, error: "missing token" }; - return await probeSlack(token, timeoutMs); - }, - buildAccountSnapshot: ({ account, runtime, probe }) => { - const configured = Boolean(account.botToken && account.appToken); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }; - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const botToken = account.botToken?.trim(); - const appToken = account.appToken?.trim(); - ctx.log?.info(`[${account.accountId}] starting provider`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorSlackProvider } = await import("../../slack/index.js"); - return monitorSlackProvider({ - botToken: botToken ?? "", - appToken: appToken ?? "", - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - mediaMaxMb: account.config.mediaMaxMb, - slashCommand: account.config.slashCommand, - }); - }, - }, -}; diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts deleted file mode 100644 index dc3dbe962..000000000 --- a/src/channels/plugins/telegram.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { chunkMarkdownText } from "../../auto-reply/chunk.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { writeConfigFile } from "../../config/config.js"; -import { shouldLogVerbose } from "../../globals.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { - listTelegramAccountIds, - type ResolvedTelegramAccount, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../../telegram/accounts.js"; -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../telegram/audit.js"; -import { probeTelegram } from "../../telegram/probe.js"; -import { sendMessageTelegram } from "../../telegram/send.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; -import { getChatChannelMeta } from "../registry.js"; -import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js"; -import { telegramMessageActions } from "./actions/telegram.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "./config-helpers.js"; -import { resolveTelegramGroupRequireMention } from "./group-mentions.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { - looksLikeTelegramTargetId, - normalizeTelegramMessagingTarget, -} from "./normalize/telegram.js"; -import { telegramOnboardingAdapter } from "./onboarding/telegram.js"; -import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import { collectTelegramStatusIssues } from "./status-issues/telegram.js"; -import type { ChannelPlugin } from "./types.js"; -import { - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, -} from "./directory-config.js"; - -const meta = getChatChannelMeta("telegram"); - -function parseReplyToMessageId(replyToId?: string | null) { - if (!replyToId) return undefined; - const parsed = Number.parseInt(replyToId, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function parseThreadId(threadId?: string | number | null) { - if (threadId == null) return undefined; - if (typeof threadId === "number") { - return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined; - } - const trimmed = threadId.trim(); - if (!trimmed) return undefined; - const parsed = Number.parseInt(trimmed, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} -export const telegramPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - onboarding: telegramOnboardingAdapter, - pairing: { - idLabel: "telegramUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), - notifyApproval: async ({ cfg, id }) => { - const { token } = resolveTelegramToken(cfg); - if (!token) throw new Error("telegram token not configured"); - await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token }); - }, - }, - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.telegram"] }, - configSchema: buildChannelConfigSchema(TelegramConfigSchema), - config: { - listAccountIds: (cfg) => listTelegramAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - }), - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.replace(/^(telegram|tg):/i, "")) - .map((entry) => entry.toLowerCase()), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.telegram?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.telegram.accounts.${resolvedAccountId}.` - : "channels.telegram."; - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("telegram"), - normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), - }; - }, - collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - if (groupPolicy !== "open") return []; - const groupAllowlistConfigured = - account.config.groups && Object.keys(account.config.groups).length > 0; - if (groupAllowlistConfigured) { - return [ - `- Telegram groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom to restrict senders.`, - ]; - } - return [ - `- Telegram groups: groupPolicy="open" with no channels.telegram.groups allowlist; any group can add + ping (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom or configure channels.telegram.groups.`, - ]; - }, - }, - groups: { - resolveRequireMention: resolveTelegramGroupRequireMention, - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", - }, - messaging: { - normalizeTarget: normalizeTelegramMessagingTarget, - targetResolver: { - looksLikeId: looksLikeTelegramTargetId, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), - listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), - }, - actions: telegramMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "telegram", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "TELEGRAM_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Telegram requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "telegram", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "telegram", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, - }, - outbound: { - deliveryMode: "direct", - chunker: chunkMarkdownText, - textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { - const send = deps?.sendTelegram ?? sendMessageTelegram; - const replyToMessageId = parseReplyToMessageId(replyToId); - const messageThreadId = parseThreadId(threadId); - const result = await send(to, text, { - verbose: false, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => { - const send = deps?.sendTelegram ?? sendMessageTelegram; - const replyToMessageId = parseReplyToMessageId(replyToId); - const messageThreadId = parseThreadId(threadId); - const result = await send(to, text, { - verbose: false, - mediaUrl, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - }); - return { channel: "telegram", ...result }; - }, - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: collectTelegramStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account, timeoutMs }) => - probeTelegram(account.token, timeoutMs, account.config.proxy), - auditAccount: async ({ account, timeoutMs, probe, cfg }) => { - const groups = - cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? - cfg.channels?.telegram?.groups; - const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = - collectTelegramUnmentionedGroupIds(groups); - if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { - return undefined; - } - const botId = - (probe as { ok?: boolean; bot?: { id?: number } })?.ok && - (probe as { bot?: { id?: number } }).bot?.id != null - ? (probe as { bot: { id: number } }).bot.id - : null; - if (!botId) { - return { - ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, - checkedGroups: 0, - unresolvedGroups, - hasWildcardUnmentionedGroups, - groups: [], - elapsedMs: 0, - }; - } - const audit = await auditTelegramGroupMembership({ - token: account.token, - botId, - groupIds, - proxyUrl: account.config.proxy, - timeoutMs, - }); - return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; - }, - buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { - const configured = Boolean(account.token?.trim()); - const groups = - cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? - cfg.channels?.telegram?.groups; - const allowUnmentionedGroups = - Boolean( - groups?.["*"] && (groups["*"] as { requireMention?: boolean }).requireMention === false, - ) || - Object.entries(groups ?? {}).some( - ([key, value]) => - key !== "*" && - Boolean(value) && - typeof value === "object" && - (value as { requireMention?: boolean }).requireMention === false, - ); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - tokenSource: account.tokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), - probe, - audit, - allowUnmentionedGroups, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }; - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const token = account.token.trim(); - let telegramBotLabel = ""; - try { - const probe = await probeTelegram(token, 2500, account.config.proxy); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) telegramBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); - } - } - ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorTelegramProvider } = await import("../../telegram/monitor.js"); - return monitorTelegramProvider({ - token, - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - useWebhook: Boolean(account.config.webhookUrl), - webhookUrl: account.config.webhookUrl, - webhookSecret: account.config.webhookSecret, - webhookPath: account.config.webhookPath, - }); - }, - logoutAccount: async ({ accountId, cfg }) => { - const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; - const nextCfg = { ...cfg } as ClawdbotConfig; - const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : undefined; - let cleared = false; - let changed = false; - if (nextTelegram) { - if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { - delete nextTelegram.botToken; - cleared = true; - changed = true; - } - const accounts = - nextTelegram.accounts && typeof nextTelegram.accounts === "object" - ? { ...nextTelegram.accounts } - : undefined; - if (accounts && accountId in accounts) { - const entry = accounts[accountId]; - if (entry && typeof entry === "object") { - const nextEntry = { ...entry } as Record; - if ("botToken" in nextEntry) { - const token = nextEntry.botToken; - if (typeof token === "string" ? token.trim() : token) { - cleared = true; - } - delete nextEntry.botToken; - changed = true; - } - if (Object.keys(nextEntry).length === 0) { - delete accounts[accountId]; - changed = true; - } else { - accounts[accountId] = nextEntry as typeof entry; - } - } - } - if (accounts) { - if (Object.keys(accounts).length === 0) { - delete nextTelegram.accounts; - changed = true; - } else { - nextTelegram.accounts = accounts; - } - } - } - if (changed) { - if (nextTelegram && Object.keys(nextTelegram).length > 0) { - nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram }; - } else { - const nextChannels = { ...nextCfg.channels }; - delete nextChannels.telegram; - if (Object.keys(nextChannels).length > 0) { - nextCfg.channels = nextChannels; - } else { - delete nextCfg.channels; - } - } - } - const resolved = resolveTelegramAccount({ - cfg: changed ? nextCfg : cfg, - accountId, - }); - const loggedOut = resolved.tokenSource === "none"; - if (changed) { - await writeConfigFile(nextCfg); - } - return { cleared, envToken: Boolean(envToken), loggedOut }; - }, - }, -}; diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts deleted file mode 100644 index 34b401776..000000000 --- a/src/channels/plugins/whatsapp.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { createActionGate, readStringParam } from "../../agents/tools/common.js"; -import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; -import { chunkText } from "../../auto-reply/chunk.js"; -import { shouldLogVerbose } from "../../globals.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { normalizeE164 } from "../../utils.js"; -import { - listWhatsAppAccountIds, - type ResolvedWhatsAppAccount, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, -} from "../../web/accounts.js"; -import { getActiveWebListener } from "../../web/active-listener.js"; -import { - getWebAuthAgeMs, - logoutWeb, - logWebSelfId, - readWebSelfId, - webAuthExists, -} from "../../web/auth-store.js"; -import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; -import { getChatChannelMeta } from "../registry.js"; -import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js"; -import { buildChannelConfigSchema } from "./config-schema.js"; -import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; -import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; -import { formatPairingApproveHint } from "./helpers.js"; -import { - looksLikeWhatsAppTargetId, - normalizeWhatsAppMessagingTarget, -} from "./normalize/whatsapp.js"; -import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "./setup-helpers.js"; -import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js"; -import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; -import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; -import { - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; - -const meta = getChatChannelMeta("whatsapp"); - -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -export const whatsappPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...meta, - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - onboarding: whatsappOnboardingAdapter, - agentTools: () => [createWhatsAppLoginTool()], - pairing: { - idLabel: "whatsappSenderId", - }, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled !== false && cfg.web?.enabled !== false, - disabledReason: () => "disabled", - isConfigured: async (account) => await webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => - resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.whatsapp?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.whatsapp.accounts.${resolvedAccountId}.` - : "channels.whatsapp."; - return { - policy: account.dmPolicy ?? "pairing", - allowFrom: account.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("whatsapp"), - normalizeEntry: (raw) => normalizeE164(raw), - }; - }, - collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - if (groupPolicy !== "open") return []; - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - if (groupAllowlistConfigured) { - return [ - `- WhatsApp groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.whatsapp.groupPolicy="allowlist" + channels.whatsapp.groupAllowFrom to restrict senders.`, - ]; - } - return [ - `- WhatsApp groups: groupPolicy="open" with no channels.whatsapp.groups allowlist; any group can add + ping (mention-gated). Set channels.whatsapp.groupPolicy="allowlist" + channels.whatsapp.groupAllowFrom or configure channels.whatsapp.groups.`, - ]; - }, - }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "whatsapp", - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, - }, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", - }, - mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) return []; - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, - }, - commands: { - enforceOwnerForCommands: true, - skipWhenConfigEmpty: true, - }, - messaging: { - normalizeTarget: normalizeWhatsAppMessagingTarget, - targetResolver: { - looksLikeId: looksLikeWhatsAppTargetId, - hint: "", - }, - }, - directory: { - self: async ({ cfg, accountId }) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - const { e164, jid } = readWebSelfId(account.authDir); - const id = e164 ?? jid; - if (!id) return null; - return { - kind: "user", - id, - name: account.name, - raw: { e164, jid }, - }; - }, - listPeers: async (params) => listWhatsAppDirectoryPeersFromConfig(params), - listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params), - }, - actions: { - listActions: ({ cfg }) => { - if (!cfg.channels?.whatsapp) return []; - const gate = createActionGate(cfg.channels.whatsapp.actions); - const actions = new Set(); - if (gate("reactions")) actions.add("react"); - if (gate("polls")) actions.add("poll"); - return Array.from(actions); - }, - supportsAction: ({ action }) => action === "react", - handleAction: async ({ action, params, cfg, accountId }) => { - if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); - } - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await handleWhatsAppAction( - { - action: "react", - chatJid: - readStringParam(params, "chatJid") ?? readStringParam(params, "to", { required: true }), - messageId, - emoji, - remove, - participant: readStringParam(params, "participant"), - accountId: accountId ?? undefined, - fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, - }, - cfg, - ); - }, - }, - outbound: { - deliveryMode: "gateway", - chunker: chunkText, - textChunkLimit: 4000, - pollMaxOptions: 12, - resolveTarget: ({ to, allowFrom, mode }) => { - const trimmed = to?.trim() ?? ""; - const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); - const hasWildcard = allowListRaw.includes("*"); - const allowList = allowListRaw - .filter((entry) => entry !== "*") - .map((entry) => normalizeWhatsAppTarget(entry)) - .filter((entry): entry is string => Boolean(entry)); - - if (trimmed) { - const normalizedTo = normalizeWhatsAppTarget(trimmed); - if (!normalizedTo) { - if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) { - return { ok: true, to: allowList[0] }; - } - return { - ok: false, - error: missingTargetError( - "WhatsApp", - " or channels.whatsapp.allowFrom[0]", - ), - }; - } - if (isWhatsAppGroupJid(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } - if (mode === "implicit" || mode === "heartbeat") { - if (hasWildcard || allowList.length === 0) { - return { ok: true, to: normalizedTo }; - } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } - return { ok: true, to: allowList[0] }; - } - return { ok: true, to: normalizedTo }; - } - - if (allowList.length > 0) { - return { ok: true, to: allowList[0] }; - } - return { - ok: false, - error: missingTargetError( - "WhatsApp", - " or channels.whatsapp.allowFrom[0]", - ), - }; - }, - sendText: async ({ to, text, accountId, deps, gifPlayback }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; - const result = await send(to, text, { - verbose: false, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; - const result = await send(to, text, { - verbose: false, - mediaUrl, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - }), - }, - auth: { - login: async ({ cfg, accountId, runtime, verbose }) => { - const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); - const { loginWeb } = await import("../../web/login.js"); - await loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); - }, - }, - heartbeat: { - checkReady: async ({ cfg, accountId, deps }) => { - if (cfg.web?.enabled === false) { - return { ok: false, reason: "whatsapp-disabled" }; - } - const account = resolveWhatsAppAccount({ cfg, accountId }); - const authExists = await (deps?.webAuthExists ?? webAuthExists)(account.authDir); - if (!authExists) { - return { ok: false, reason: "whatsapp-not-linked" }; - } - const listenerActive = deps?.hasActiveWebListener - ? deps.hasActiveWebListener() - : Boolean(getActiveWebListener()); - if (!listenerActive) { - return { ok: false, reason: "whatsapp-not-running" }; - } - return { ok: true, reason: "ok" }; - }, - resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts), - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, - }, - collectStatusIssues: collectWhatsAppStatusIssues, - buildChannelSummary: async ({ account, snapshot }) => { - const authDir = account.authDir; - const linked = - typeof snapshot.linked === "boolean" - ? snapshot.linked - : authDir - ? await webAuthExists(authDir) - : false; - const authAgeMs = linked && authDir ? getWebAuthAgeMs(authDir) : null; - const self = linked && authDir ? readWebSelfId(authDir) : { e164: null, jid: null }; - return { - configured: linked, - linked, - authAgeMs, - self, - running: snapshot.running ?? false, - connected: snapshot.connected ?? false, - lastConnectedAt: snapshot.lastConnectedAt ?? null, - lastDisconnect: snapshot.lastDisconnect ?? null, - reconnectAttempts: snapshot.reconnectAttempts, - lastMessageAt: snapshot.lastMessageAt ?? null, - lastEventAt: snapshot.lastEventAt ?? null, - lastError: snapshot.lastError ?? null, - }; - }, - buildAccountSnapshot: async ({ account, runtime }) => { - const linked = await webAuthExists(account.authDir); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: true, - linked, - running: runtime?.running ?? false, - connected: runtime?.connected ?? false, - reconnectAttempts: runtime?.reconnectAttempts, - lastConnectedAt: runtime?.lastConnectedAt ?? null, - lastDisconnect: runtime?.lastDisconnect ?? null, - lastMessageAt: runtime?.lastMessageAt ?? null, - lastEventAt: runtime?.lastEventAt ?? null, - lastError: runtime?.lastError ?? null, - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }; - }, - resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), - logSelfId: ({ account, runtime, includeChannelPrefix }) => { - logWebSelfId(account.authDir, runtime, includeChannelPrefix); - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const { e164, jid } = readWebSelfId(account.authDir); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorWebChannel } = await import("../web/index.js"); - return monitorWebChannel( - shouldLogVerbose(), - undefined, - true, - undefined, - ctx.runtime, - ctx.abortSignal, - { - statusSink: (next) => ctx.setStatus({ accountId: ctx.accountId, ...next }), - accountId: account.accountId, - }, - ); - }, - loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => - await (async () => { - const { startWebLoginWithQr } = await import("../../web/login-qr.js"); - return await startWebLoginWithQr({ - accountId, - force, - timeoutMs, - verbose, - }); - })(), - loginWithQrWait: async ({ accountId, timeoutMs }) => - await (async () => { - const { waitForWebLogin } = await import("../../web/login-qr.js"); - return await waitForWebLogin({ accountId, timeoutMs }); - })(), - logoutAccount: async ({ account, runtime }) => { - const cleared = await logoutWeb({ - authDir: account.authDir, - isLegacyAuthDir: account.isLegacyAuthDir, - runtime, - }); - return { cleared, loggedOut: cleared }; - }, - }, -}; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 5d3d0b4a0..92a3c9f9f 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,6 +1,6 @@ import type { ChannelMeta } from "./plugins/types.js"; import type { ChannelId } from "./plugins/types.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { requireActivePluginRegistry } from "../plugins/runtime.js"; // Channel docking: add new core channels here (order + meta + aliases), then // register the plugin in its extension entrypoint and keep protocol IDs in sync. @@ -113,21 +113,15 @@ export function normalizeChannelId(raw?: string | null): ChatChannelId | null { return normalizeChatChannelId(raw); } -// Normalizes core chat channels plus any *already-loaded* plugin channels. +// Normalizes registered channel plugins (bundled or external). // -// Keep this light: we do not import core channel plugins here (those are "heavy" and can pull in -// monitors, web login, etc). If plugins are not loaded (e.g. in many tests), only core channel IDs -// resolve. +// Keep this light: we do not import channel plugins here (those are "heavy" and can pull in +// monitors, web login, etc). The plugin registry must be initialized first. export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { - const core = normalizeChatChannelId(raw); - if (core) return core; - const key = normalizeChannelKey(raw); if (!key) return null; - const registry = getActivePluginRegistry(); - if (!registry) return null; - + const registry = requireActivePluginRegistry(); const hit = registry.channels.find((entry) => { const id = String(entry.plugin.id ?? "") .trim() diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 1b2fd4946..198440d02 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -20,9 +20,11 @@ import type { ClawdbotConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { agentCommand } from "./agent.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; const runtime: RuntimeEnv = { log: vi.fn(), @@ -254,6 +256,7 @@ describe("agentCommand", () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, undefined, { botToken: "t-1" }); + setTelegramRuntime(createPluginRuntime()); setActivePluginRegistry( createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), ); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index d2a5e5e82..e9fae83a2 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -25,6 +25,7 @@ vi.mock("../config/sessions.js", () => ({ resolveStorePath: () => "/tmp/sessions.json", loadSessionStore: () => testStore, recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + updateLastRoute: vi.fn().mockResolvedValue(undefined), })); vi.mock("../web/auth-store.js", () => ({ @@ -32,13 +33,17 @@ vi.mock("../web/auth-store.js", () => ({ getWebAuthAgeMs: vi.fn(() => 1234), readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), logWebSelfId: vi.fn(), + logoutWeb: vi.fn(), })); describe("getHealthSnapshot", () => { - beforeEach(() => { + beforeEach(async () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), ); + const { createPluginRuntime } = await import("../plugins/runtime/index.js"); + const { setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js"); + setTelegramRuntime(createPluginRuntime()); }); afterEach(() => { diff --git a/src/config/channel-capabilities.test.ts b/src/config/channel-capabilities.test.ts index d70dd8095..1fbe3c2e6 100644 --- a/src/config/channel-capabilities.test.ts +++ b/src/config/channel-capabilities.test.ts @@ -7,11 +7,11 @@ import type { ClawdbotConfig } from "./config.js"; describe("resolveChannelCapabilities", () => { beforeEach(() => { - setActivePluginRegistry(emptyRegistry); + setActivePluginRegistry(baseRegistry); }); afterEach(() => { - setActivePluginRegistry(emptyRegistry); + setActivePluginRegistry(baseRegistry); }); it("returns undefined for missing inputs", () => { @@ -139,7 +139,26 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => diagnostics: [], }); -const emptyRegistry = createRegistry([]); +const createStubPlugin = (id: string): ChannelPlugin => ({ + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test stub.", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, +}); + +const baseRegistry = createRegistry([ + { pluginId: "telegram", source: "test", plugin: createStubPlugin("telegram") }, + { pluginId: "slack", source: "test", plugin: createStubPlugin("slack") }, +]); const createMSTeamsPlugin = (): ChannelPlugin => ({ id: "msteams", diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 966279528..6f5ffec77 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -7,11 +7,15 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import type { CronJob } from "./types.js"; import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; +import { setDiscordRuntime } from "../../extensions/discord/src/runtime.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -90,6 +94,10 @@ describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([]); + const runtime = createPluginRuntime(); + setDiscordRuntime(runtime); + setTelegramRuntime(runtime); + setWhatsAppRuntime(runtime); setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, diff --git a/src/docs/slash-commands-doc.test.ts b/src/docs/slash-commands-doc.test.ts index f071a0fb5..39de4e1b4 100644 --- a/src/docs/slash-commands-doc.test.ts +++ b/src/docs/slash-commands-doc.test.ts @@ -1,8 +1,18 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { listChatCommands } from "../auto-reply/commands-registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; + +beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); + +afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); +}); function extractDocumentedSlashCommands(markdown: string): Set { const documented = new Set(); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index f702e6cf0..051b98305 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -346,6 +346,8 @@ vi.mock("../commands/status.js", () => ({ vi.mock("../web/outbound.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), + sendPollWhatsApp: (...args: unknown[]) => + (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), })); vi.mock("../channels/web/index.js", async () => { const actual = await vi.importActual( diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 6bc3b9c49..aa961a301 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -7,14 +7,20 @@ import type { ClawdbotConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); beforeEach(() => { + const runtime = createPluginRuntime(); + setTelegramRuntime(runtime); + setWhatsAppRuntime(runtime); setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index c46e9138f..5ba1d8db6 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -19,14 +19,20 @@ import { } from "./heartbeat-runner.js"; import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); beforeEach(() => { + const runtime = createPluginRuntime(); + setTelegramRuntime(runtime); + setWhatsAppRuntime(runtime); setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 28829ae0c..0b89ff31a 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -26,7 +26,15 @@ const whatsappConfig = { } as ClawdbotConfig; describe("runMessageAction context isolation", () => { - beforeEach(() => { + beforeEach(async () => { + const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); + const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"); + const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"); + const { setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js"); + const runtime = createPluginRuntime(); + setSlackRuntime(runtime); + setTelegramRuntime(runtime); + setWhatsAppRuntime(runtime); setActivePluginRegistry( createTestRegistry([ { diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts new file mode 100644 index 000000000..b0e713287 --- /dev/null +++ b/src/plugin-sdk/index.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import * as sdk from "./index.js"; + +describe("plugin-sdk exports", () => { + it("does not expose runtime modules", () => { + const forbidden = [ + "chunkMarkdownText", + "chunkText", + "resolveTextChunkLimit", + "hasControlCommand", + "isControlCommandMessage", + "shouldComputeCommandAuthorized", + "shouldHandleTextCommands", + "buildMentionRegexes", + "matchesMentionPatterns", + "resolveStateDir", + "loadConfig", + "writeConfigFile", + "runCommandWithTimeout", + "enqueueSystemEvent", + "detectMime", + "fetchRemoteMedia", + "saveMediaBuffer", + "formatAgentEnvelope", + "buildPairingReply", + "resolveAgentRoute", + "dispatchReplyFromConfig", + "createReplyDispatcherWithTyping", + "dispatchReplyWithBufferedBlockDispatcher", + "resolveCommandAuthorizedFromAuthorizers", + "monitorSlackProvider", + "monitorTelegramProvider", + "monitorIMessageProvider", + "monitorSignalProvider", + "sendMessageSlack", + "sendMessageTelegram", + "sendMessageIMessage", + "sendMessageSignal", + "sendMessageWhatsApp", + "probeSlack", + "probeTelegram", + "probeIMessage", + "probeSignal", + ]; + + for (const key of forbidden) { + expect(Object.prototype.hasOwnProperty.call(sdk, key)).toBe(false); + } + }); +}); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 18fdeedb5..ef9cd8e07 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -80,20 +80,6 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; -export { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; -export { - hasControlCommand, - isControlCommandMessage, - shouldComputeCommandAuthorized, -} from "../auto-reply/command-detection.js"; -export { shouldHandleTextCommands } from "../auto-reply/commands-registry.js"; -export { formatAgentEnvelope } from "../auto-reply/envelope.js"; -export { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "../auto-reply/inbound-debounce.js"; -export { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; -export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; export { buildPendingHistoryContextFromMap, clearHistoryEntries, @@ -101,11 +87,7 @@ export { recordPendingHistoryEntry, } from "../auto-reply/reply/history.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; -export { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; -export { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; -export { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../agents/identity.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; -export { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; export { resolveMentionGating } from "../channels/mention-gating.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { @@ -134,30 +116,7 @@ export { } from "../channels/plugins/directory-config.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; -export { - readChannelAllowFromStore, - upsertChannelPairingRequest, -} from "../pairing/pairing-store.js"; -export { resolveAgentRoute } from "../routing/resolve-route.js"; -export { - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; -export { resolveStateDir } from "../config/paths.js"; -export { loadConfig, writeConfigFile } from "../config/config.js"; export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js"; -export { danger } from "../globals.js"; -export { logVerbose, shouldLogVerbose } from "../globals.js"; -export { getChildLogger } from "../logging.js"; -export { enqueueSystemEvent } from "../infra/system-events.js"; -export { runCommandWithTimeout } from "../process/exec.js"; -export { loadWebMedia } from "../web/media.js"; -export { isVoiceCompatibleAudio } from "../media/audio.js"; -export { mediaKindFromMime } from "../media/constants.js"; -export { detectMime } from "../media/mime.js"; -export { getImageMetadata, resizeToJpeg } from "../media/image-ops.js"; -export { saveMediaBuffer } from "../media/store.js"; export type { PollInput } from "../polls.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; @@ -177,9 +136,6 @@ export { resolveIMessageAccount, type ResolvedIMessageAccount, } from "../imessage/accounts.js"; -export { monitorIMessageProvider } from "../imessage/monitor.js"; -export { probeIMessage } from "../imessage/probe.js"; -export { sendMessageIMessage } from "../imessage/send.js"; export type { ChannelOnboardingAdapter, @@ -196,12 +152,8 @@ export { readReactionParams, readStringParam, } from "../agents/tools/common.js"; -export { createMemoryGetTool, createMemorySearchTool } from "../agents/tools/memory-tool.js"; -export { registerMemoryCli } from "../cli/memory-cli.js"; - export { formatDocsLink } from "../terminal/links.js"; export type { HookEntry } from "../hooks/types.js"; -export { registerPluginHooksFromDir } from "../hooks/plugin-hooks.js"; export { normalizeE164 } from "../utils.js"; export { missingTargetError } from "../infra/outbound/target-errors.js"; @@ -212,17 +164,7 @@ export { resolveDiscordAccount, type ResolvedDiscordAccount, } from "../discord/accounts.js"; -export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "../discord/audit.js"; -export { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../discord/directory-live.js"; -export { probeDiscord } from "../discord/probe.js"; -export { resolveDiscordChannelAllowlist } from "../discord/resolve-channels.js"; -export { resolveDiscordUserAllowlist } from "../discord/resolve-users.js"; -export { sendMessageDiscord, sendPollDiscord } from "../discord/send.js"; -export { monitorDiscordProvider } from "../discord/monitor.js"; -export { discordMessageActions } from "../channels/plugins/actions/discord.js"; +export { collectDiscordAuditChannelIds } from "../discord/audit.js"; export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; export { looksLikeDiscordTargetId, @@ -238,16 +180,6 @@ export { resolveSlackAccount, type ResolvedSlackAccount, } from "../slack/accounts.js"; -export { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../slack/directory-live.js"; -export { probeSlack } from "../slack/probe.js"; -export { resolveSlackChannelAllowlist } from "../slack/resolve-channels.js"; -export { resolveSlackUserAllowlist } from "../slack/resolve-users.js"; -export { sendMessageSlack } from "../slack/send.js"; -export { monitorSlackProvider } from "../slack/index.js"; -export { handleSlackAction } from "../agents/tools/slack-actions.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { looksLikeSlackTargetId, @@ -261,15 +193,6 @@ export { resolveTelegramAccount, type ResolvedTelegramAccount, } from "../telegram/accounts.js"; -export { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../telegram/audit.js"; -export { probeTelegram } from "../telegram/probe.js"; -export { resolveTelegramToken } from "../telegram/token.js"; -export { sendMessageTelegram } from "../telegram/send.js"; -export { monitorTelegramProvider } from "../telegram/monitor.js"; -export { telegramMessageActions } from "../channels/plugins/actions/telegram.js"; export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; export { looksLikeTelegramTargetId, @@ -284,9 +207,6 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../signal/accounts.js"; -export { probeSignal } from "../signal/probe.js"; -export { sendMessageSignal } from "../signal/send.js"; -export { monitorSignalProvider } from "../signal/index.js"; export { signalOnboardingAdapter } from "../channels/plugins/onboarding/signal.js"; export { looksLikeSignalTargetId, @@ -300,21 +220,7 @@ export { resolveWhatsAppAccount, type ResolvedWhatsAppAccount, } from "../web/accounts.js"; -export { getActiveWebListener } from "../web/active-listener.js"; -export { - getWebAuthAgeMs, - logoutWeb, - logWebSelfId, - readWebSelfId, - webAuthExists, -} from "../web/auth-store.js"; -export { sendMessageWhatsApp, sendPollWhatsApp } from "../web/outbound.js"; export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; -export { loginWeb } from "../web/login.js"; -export { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; -export { monitorWebChannel } from "../channels/web/index.js"; -export { handleWhatsAppAction } from "../agents/tools/whatsapp-actions.js"; -export { createWhatsAppLoginTool } from "../channels/plugins/agent-tools/whatsapp-login.js"; export { whatsappOnboardingAdapter } from "../channels/plugins/onboarding/whatsapp.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; export { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index f814a0811..0553717d7 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,17 +1,55 @@ import type { PluginRegistry } from "./registry.js"; -let activeRegistry: PluginRegistry | null = null; -let activeRegistryKey: string | null = null; +const createEmptyRegistry = (): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + +const REGISTRY_STATE = Symbol.for("clawdbot.pluginRegistryState"); + +type RegistryState = { + registry: PluginRegistry | null; + key: string | null; +}; + +const state: RegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [REGISTRY_STATE]?: RegistryState; + }; + if (!globalState[REGISTRY_STATE]) { + globalState[REGISTRY_STATE] = { + registry: createEmptyRegistry(), + key: null, + }; + } + return globalState[REGISTRY_STATE] as RegistryState; +})(); export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) { - activeRegistry = registry; - activeRegistryKey = cacheKey ?? null; + state.registry = registry; + state.key = cacheKey ?? null; } export function getActivePluginRegistry(): PluginRegistry | null { - return activeRegistry; + return state.registry; +} + +export function requireActivePluginRegistry(): PluginRegistry { + if (!state.registry) { + state.registry = createEmptyRegistry(); + } + return state.registry; } export function getActivePluginRegistryKey(): string | null { - return activeRegistryKey; + return state.key; } diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 35d0adf15..3da8c2647 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,32 +1,99 @@ import { createRequire } from "node:module"; -import { chunkMarkdownText, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { hasControlCommand } from "../../auto-reply/command-detection.js"; +import { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { + hasControlCommand, + isControlCommandMessage, + shouldComputeCommandAuthorized, +} from "../../auto-reply/command-detection.js"; +import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; +import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; +import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; +import { handleSlackAction } from "../../agents/tools/slack-actions.js"; +import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; +import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; +import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; +import { monitorWebChannel } from "../../channels/web/index.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; import { resolveStateDir } from "../../config/paths.js"; +import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { + recordSessionMetaFromInbound, + resolveStorePath, + updateLastRoute, +} from "../../config/sessions.js"; +import { auditDiscordChannelPermissions } from "../../discord/audit.js"; +import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "../../discord/directory-live.js"; +import { monitorDiscordProvider } from "../../discord/monitor.js"; +import { probeDiscord } from "../../discord/probe.js"; +import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { monitorIMessageProvider } from "../../imessage/monitor.js"; +import { probeIMessage } from "../../imessage/probe.js"; +import { sendMessageIMessage } from "../../imessage/send.js"; import { shouldLogVerbose } from "../../globals.js"; import { getChildLogger } from "../../logging.js"; import { normalizeLogLevel } from "../../logging/levels.js"; +import { isVoiceCompatibleAudio } from "../../media/audio.js"; +import { mediaKindFromMime } from "../../media/constants.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; +import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; +import { detectMime } from "../../media/mime.js"; import { saveMediaBuffer } from "../../media/store.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; +import { runCommandWithTimeout } from "../../process/exec.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { monitorSignalProvider } from "../../signal/index.js"; +import { probeSignal } from "../../signal/probe.js"; +import { sendMessageSignal } from "../../signal/send.js"; +import { monitorSlackProvider } from "../../slack/index.js"; +import { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "../../slack/directory-live.js"; +import { probeSlack } from "../../slack/probe.js"; +import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; +import { sendMessageSlack } from "../../slack/send.js"; +import { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../telegram/audit.js"; +import { monitorTelegramProvider } from "../../telegram/monitor.js"; +import { probeTelegram } from "../../telegram/probe.js"; +import { sendMessageTelegram } from "../../telegram/send.js"; +import { resolveTelegramToken } from "../../telegram/token.js"; +import { loadWebMedia } from "../../web/media.js"; +import { getActiveWebListener } from "../../web/active-listener.js"; +import { + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, +} from "../../web/auth-store.js"; +import { loginWeb } from "../../web/login.js"; +import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; +import { registerMemoryCli } from "../../cli/memory-cli.js"; import type { PluginRuntime } from "./types.js"; @@ -48,17 +115,42 @@ function resolveVersion(): string { export function createPluginRuntime(): PluginRuntime { return { version: resolveVersion(), + config: { + loadConfig, + writeConfigFile, + }, + system: { + enqueueSystemEvent, + runCommandWithTimeout, + }, + media: { + loadWebMedia, + detectMime, + mediaKindFromMime, + isVoiceCompatibleAudio, + getImageMetadata, + resizeToJpeg, + }, + tools: { + createMemoryGetTool, + createMemorySearchTool, + registerMemoryCli, + }, channel: { - text: { - chunkMarkdownText, - resolveTextChunkLimit, - hasControlCommand, - }, + text: { + chunkMarkdownText, + chunkText, + resolveTextChunkLimit, + hasControlCommand, + }, reply: { dispatchReplyWithBufferedBlockDispatcher, createReplyDispatcherWithTyping, resolveEffectiveMessagesConfig, resolveHumanDelayConfig, + dispatchReplyFromConfig, + finalizeInboundContext, + formatAgentEnvelope, }, routing: { resolveAgentRoute, @@ -72,6 +164,11 @@ export function createPluginRuntime(): PluginRuntime { fetchRemoteMedia, saveMediaBuffer, }, + session: { + resolveStorePath, + recordSessionMetaFromInbound, + updateLastRoute, + }, mentions: { buildMentionRegexes, matchesMentionPatterns, @@ -84,8 +181,68 @@ export function createPluginRuntime(): PluginRuntime { createInboundDebouncer, resolveInboundDebounceMs, }, - commands: { - resolveCommandAuthorizedFromAuthorizers, + commands: { + resolveCommandAuthorizedFromAuthorizers, + isControlCommandMessage, + shouldComputeCommandAuthorized, + shouldHandleTextCommands, + }, + discord: { + messageActions: discordMessageActions, + auditChannelPermissions: auditDiscordChannelPermissions, + listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, + listDirectoryPeersLive: listDiscordDirectoryPeersLive, + probeDiscord, + resolveChannelAllowlist: resolveDiscordChannelAllowlist, + resolveUserAllowlist: resolveDiscordUserAllowlist, + sendMessageDiscord, + sendPollDiscord, + monitorDiscordProvider, + }, + slack: { + listDirectoryGroupsLive: listSlackDirectoryGroupsLive, + listDirectoryPeersLive: listSlackDirectoryPeersLive, + probeSlack, + resolveChannelAllowlist: resolveSlackChannelAllowlist, + resolveUserAllowlist: resolveSlackUserAllowlist, + sendMessageSlack, + monitorSlackProvider, + handleSlackAction, + }, + telegram: { + auditGroupMembership: auditTelegramGroupMembership, + collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, + probeTelegram, + resolveTelegramToken, + sendMessageTelegram, + monitorTelegramProvider, + messageActions: telegramMessageActions, + }, + signal: { + probeSignal, + sendMessageSignal, + monitorSignalProvider, + }, + imessage: { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, + }, + whatsapp: { + getActiveWebListener, + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, + sendMessageWhatsApp, + sendPollWhatsApp, + loginWeb, + startWebLoginWithQr, + waitForWebLogin, + monitorWebChannel, + handleWhatsAppAction, + createLoginTool: createWhatsAppLoginTool, }, }, logging: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 89f592a1f..0533931b7 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -31,8 +31,99 @@ type ResolveCommandAuthorizedFromAuthorizers = typeof import("../../channels/command-gating.js").resolveCommandAuthorizedFromAuthorizers; type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; +type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand; +type IsControlCommandMessage = + typeof import("../../auto-reply/command-detection.js").isControlCommandMessage; +type ShouldComputeCommandAuthorized = + typeof import("../../auto-reply/command-detection.js").shouldComputeCommandAuthorized; +type ShouldHandleTextCommands = + typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; +type DispatchReplyFromConfig = + typeof import("../../auto-reply/reply/dispatch-from-config.js").dispatchReplyFromConfig; +type FinalizeInboundContext = + typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext; +type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope; type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir; +type RecordSessionMetaFromInbound = + typeof import("../../config/sessions.js").recordSessionMetaFromInbound; +type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; +type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute; +type LoadConfig = typeof import("../../config/config.js").loadConfig; +type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; +type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent; +type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout; +type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia; +type DetectMime = typeof import("../../media/mime.js").detectMime; +type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime; +type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio; +type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata; +type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg; +type CreateMemoryGetTool = + typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool; +type CreateMemorySearchTool = + typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool; +type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli; +type DiscordMessageActions = + typeof import("../../channels/plugins/actions/discord.js").discordMessageActions; +type AuditDiscordChannelPermissions = + typeof import("../../discord/audit.js").auditDiscordChannelPermissions; +type ListDiscordDirectoryGroupsLive = + typeof import("../../discord/directory-live.js").listDiscordDirectoryGroupsLive; +type ListDiscordDirectoryPeersLive = + typeof import("../../discord/directory-live.js").listDiscordDirectoryPeersLive; +type ProbeDiscord = typeof import("../../discord/probe.js").probeDiscord; +type ResolveDiscordChannelAllowlist = + typeof import("../../discord/resolve-channels.js").resolveDiscordChannelAllowlist; +type ResolveDiscordUserAllowlist = + typeof import("../../discord/resolve-users.js").resolveDiscordUserAllowlist; +type SendMessageDiscord = typeof import("../../discord/send.js").sendMessageDiscord; +type SendPollDiscord = typeof import("../../discord/send.js").sendPollDiscord; +type MonitorDiscordProvider = typeof import("../../discord/monitor.js").monitorDiscordProvider; +type ListSlackDirectoryGroupsLive = + typeof import("../../slack/directory-live.js").listSlackDirectoryGroupsLive; +type ListSlackDirectoryPeersLive = + typeof import("../../slack/directory-live.js").listSlackDirectoryPeersLive; +type ProbeSlack = typeof import("../../slack/probe.js").probeSlack; +type ResolveSlackChannelAllowlist = + typeof import("../../slack/resolve-channels.js").resolveSlackChannelAllowlist; +type ResolveSlackUserAllowlist = + typeof import("../../slack/resolve-users.js").resolveSlackUserAllowlist; +type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack; +type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider; +type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction; +type AuditTelegramGroupMembership = + typeof import("../../telegram/audit.js").auditTelegramGroupMembership; +type CollectTelegramUnmentionedGroupIds = + typeof import("../../telegram/audit.js").collectTelegramUnmentionedGroupIds; +type ProbeTelegram = typeof import("../../telegram/probe.js").probeTelegram; +type ResolveTelegramToken = typeof import("../../telegram/token.js").resolveTelegramToken; +type SendMessageTelegram = typeof import("../../telegram/send.js").sendMessageTelegram; +type MonitorTelegramProvider = typeof import("../../telegram/monitor.js").monitorTelegramProvider; +type TelegramMessageActions = + typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; +type ProbeSignal = typeof import("../../signal/probe.js").probeSignal; +type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal; +type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider; +type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider; +type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage; +type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage; +type GetActiveWebListener = typeof import("../../web/active-listener.js").getActiveWebListener; +type GetWebAuthAgeMs = typeof import("../../web/auth-store.js").getWebAuthAgeMs; +type LogoutWeb = typeof import("../../web/auth-store.js").logoutWeb; +type LogWebSelfId = typeof import("../../web/auth-store.js").logWebSelfId; +type ReadWebSelfId = typeof import("../../web/auth-store.js").readWebSelfId; +type WebAuthExists = typeof import("../../web/auth-store.js").webAuthExists; +type SendMessageWhatsApp = typeof import("../../web/outbound.js").sendMessageWhatsApp; +type SendPollWhatsApp = typeof import("../../web/outbound.js").sendPollWhatsApp; +type LoginWeb = typeof import("../../web/login.js").loginWeb; +type StartWebLoginWithQr = typeof import("../../web/login-qr.js").startWebLoginWithQr; +type WaitForWebLogin = typeof import("../../web/login-qr.js").waitForWebLogin; +type MonitorWebChannel = typeof import("../../channels/web/index.js").monitorWebChannel; +type HandleWhatsAppAction = + typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; +type CreateWhatsAppLoginTool = + typeof import("../../channels/plugins/agent-tools/whatsapp-login.js").createWhatsAppLoginTool; export type RuntimeLogger = { debug?: (message: string) => void; @@ -43,9 +134,31 @@ export type RuntimeLogger = { export type PluginRuntime = { version: string; + config: { + loadConfig: LoadConfig; + writeConfigFile: WriteConfigFile; + }; + system: { + enqueueSystemEvent: EnqueueSystemEvent; + runCommandWithTimeout: RunCommandWithTimeout; + }; + media: { + loadWebMedia: LoadWebMedia; + detectMime: DetectMime; + mediaKindFromMime: MediaKindFromMime; + isVoiceCompatibleAudio: IsVoiceCompatibleAudio; + getImageMetadata: GetImageMetadata; + resizeToJpeg: ResizeToJpeg; + }; + tools: { + createMemoryGetTool: CreateMemoryGetTool; + createMemorySearchTool: CreateMemorySearchTool; + registerMemoryCli: RegisterMemoryCli; + }; channel: { text: { chunkMarkdownText: ChunkMarkdownText; + chunkText: ChunkText; resolveTextChunkLimit: ResolveTextChunkLimit; hasControlCommand: HasControlCommand; }; @@ -54,6 +167,9 @@ export type PluginRuntime = { createReplyDispatcherWithTyping: CreateReplyDispatcherWithTyping; resolveEffectiveMessagesConfig: ResolveEffectiveMessagesConfig; resolveHumanDelayConfig: ResolveHumanDelayConfig; + dispatchReplyFromConfig: DispatchReplyFromConfig; + finalizeInboundContext: FinalizeInboundContext; + formatAgentEnvelope: FormatAgentEnvelope; }; routing: { resolveAgentRoute: ResolveAgentRoute; @@ -67,6 +183,11 @@ export type PluginRuntime = { fetchRemoteMedia: FetchRemoteMedia; saveMediaBuffer: SaveMediaBuffer; }; + session: { + resolveStorePath: ResolveStorePath; + recordSessionMetaFromInbound: RecordSessionMetaFromInbound; + updateLastRoute: UpdateLastRoute; + }; mentions: { buildMentionRegexes: BuildMentionRegexes; matchesMentionPatterns: MatchesMentionPatterns; @@ -81,6 +202,66 @@ export type PluginRuntime = { }; commands: { resolveCommandAuthorizedFromAuthorizers: ResolveCommandAuthorizedFromAuthorizers; + isControlCommandMessage: IsControlCommandMessage; + shouldComputeCommandAuthorized: ShouldComputeCommandAuthorized; + shouldHandleTextCommands: ShouldHandleTextCommands; + }; + discord: { + messageActions: DiscordMessageActions; + auditChannelPermissions: AuditDiscordChannelPermissions; + listDirectoryGroupsLive: ListDiscordDirectoryGroupsLive; + listDirectoryPeersLive: ListDiscordDirectoryPeersLive; + probeDiscord: ProbeDiscord; + resolveChannelAllowlist: ResolveDiscordChannelAllowlist; + resolveUserAllowlist: ResolveDiscordUserAllowlist; + sendMessageDiscord: SendMessageDiscord; + sendPollDiscord: SendPollDiscord; + monitorDiscordProvider: MonitorDiscordProvider; + }; + slack: { + listDirectoryGroupsLive: ListSlackDirectoryGroupsLive; + listDirectoryPeersLive: ListSlackDirectoryPeersLive; + probeSlack: ProbeSlack; + resolveChannelAllowlist: ResolveSlackChannelAllowlist; + resolveUserAllowlist: ResolveSlackUserAllowlist; + sendMessageSlack: SendMessageSlack; + monitorSlackProvider: MonitorSlackProvider; + handleSlackAction: HandleSlackAction; + }; + telegram: { + auditGroupMembership: AuditTelegramGroupMembership; + collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds; + probeTelegram: ProbeTelegram; + resolveTelegramToken: ResolveTelegramToken; + sendMessageTelegram: SendMessageTelegram; + monitorTelegramProvider: MonitorTelegramProvider; + messageActions: TelegramMessageActions; + }; + signal: { + probeSignal: ProbeSignal; + sendMessageSignal: SendMessageSignal; + monitorSignalProvider: MonitorSignalProvider; + }; + imessage: { + monitorIMessageProvider: MonitorIMessageProvider; + probeIMessage: ProbeIMessage; + sendMessageIMessage: SendMessageIMessage; + }; + whatsapp: { + getActiveWebListener: GetActiveWebListener; + getWebAuthAgeMs: GetWebAuthAgeMs; + logoutWeb: LogoutWeb; + logWebSelfId: LogWebSelfId; + readWebSelfId: ReadWebSelfId; + webAuthExists: WebAuthExists; + sendMessageWhatsApp: SendMessageWhatsApp; + sendPollWhatsApp: SendPollWhatsApp; + loginWeb: LoginWeb; + startWebLoginWithQr: StartWebLoginWithQr; + waitForWebLogin: WaitForWebLogin; + monitorWebChannel: MonitorWebChannel; + handleWhatsAppAction: HandleWhatsAppAction; + createLoginTool: CreateWhatsAppLoginTool; }; }; logging: { diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 66ff0be87..525369abb 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -32,6 +32,7 @@ export const createIMessageTestPlugin = (params?: { selectionLabel: "iMessage (imsg)", docsPath: "/channels/imessage", blurb: "iMessage test stub.", + aliases: ["imsg"], }, capabilities: { chatTypes: ["direct", "group"], media: true }, config: {