refactor: route channel runtime via plugin api

This commit is contained in:
Peter Steinberger
2026-01-18 11:00:19 +00:00
parent 676d41d415
commit ee6e534ccb
82 changed files with 1253 additions and 3167 deletions

View File

@@ -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}`,
});

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedDiscordAccount> = {
id: "discord",
meta: {
@@ -47,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
}));
}
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
);
}
} 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,

View File

@@ -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;
}

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedIMessageAccount> = {
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<ResolvedIMessageAccount> = {
},
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<ResolvedIMessageAccount> = {
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<ResolvedIMessageAccount> = {
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<ResolvedIMessageAccount> = {
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,

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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<M
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: loadConfig() as CoreConfig,
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,

View File

@@ -1,7 +1,7 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
@@ -46,7 +46,7 @@ function clean(value?: string): string {
}
export function resolveMatrixConfig(
cfg: CoreConfig = loadConfig() as CoreConfig,
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
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) {

View File

@@ -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 {

View File

@@ -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" },

View File

@@ -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)}`);
}
});
}

View File

@@ -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)}`);
}
};

View File

@@ -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,

View File

@@ -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) };
}

View File

@@ -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<void> {
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, {

View File

@@ -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 () => {

View File

@@ -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<MatrixImageInfo | undefined> {
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,

View File

@@ -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"] },
);

View File

@@ -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 });
},
};

View File

@@ -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", () => {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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-"));

View File

@@ -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 }], {

View File

@@ -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<unknown>;
@@ -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);

View File

@@ -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<T extends MSTeamsActivityHandler>(
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();
});

View File

@@ -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<string, HistoryEntry[]>();
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<MSTeamsDebounceEntry>({
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
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)}`);
},
});

View File

@@ -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<MonitorMSTeamsResult> {
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 =

View File

@@ -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 }) => {

View File

@@ -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?",

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<typeof getChildLoggerFn> | undefined;
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
let _log: ReturnType<GetChildLogger> | undefined;
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
if (_log) return _log;
const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" });

View File

@@ -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);
}

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedSignalAccount> = {
@@ -37,7 +36,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
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<ResolvedSignalAccount> = {
},
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<ResolvedSignalAccount> = {
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<ResolvedSignalAccount> = {
}),
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<ResolvedSignalAccount> = {
});
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,

View File

@@ -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;
}

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
}));
}
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
});
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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,

View File

@@ -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;
}

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedTelegramAccount> = {
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<ResolvedTelegramAccount> = {
},
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<ResolvedTelegramAccount> = {
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<ResolvedTelegramAccount> = {
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<ResolvedTelegramAccount> = {
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<ResolvedTelegramAccount> = {
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<ResolvedTelegramAccount> = {
});
const loggedOut = resolved.tokenSource === "none";
if (changed) {
await writeConfigFile(nextCfg);
await getTelegramRuntime().config.writeConfigFile(nextCfg);
}
return { cleared, envToken: Boolean(envToken), loggedOut };
},

View File

@@ -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;
}

View File

@@ -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 });
},
};

View File

@@ -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<ResolvedWhatsAppAccount> = {
preferSessionLookupForAnnounceTarget: true,
},
onboarding: whatsappOnboardingAdapter,
agentTools: () => [createWhatsAppLoginTool()],
agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()],
pairing: {
idLabel: "whatsappSenderId",
},
@@ -110,7 +100,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
});
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<ResolvedWhatsAppAccount> = {
},
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<ResolvedWhatsAppAccount> = {
};
},
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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
};
},
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<ResolvedWhatsAppAccount> = {
},
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<ResolvedWhatsAppAccount> = {
);
},
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,

View File

@@ -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;
}

View File

@@ -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 ? "<media:image>" : "");
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,

View File

@@ -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,