diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 8ba462f45..f98f3c446 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -6,6 +6,20 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "mattermost", + "label": "Mattermost", + "selectionLabel": "Mattermost (plugin)", + "docsPath": "/channels/mattermost", + "docsLabel": "mattermost", + "blurb": "self-hosted Slack-style chat; install the plugin to enable.", + "order": 65 + }, + "install": { + "npmSpec": "@clawdbot/mattermost", + "localPath": "extensions/mattermost", + "defaultChoice": "npm" + } } } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 840772a17..b365fc61e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -3,26 +3,42 @@ import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - getChatChannelMeta, - listMattermostAccountIds, - looksLikeMattermostTargetId, migrateBaseNameToDefaultAccount, normalizeAccountId, - normalizeMattermostBaseUrl, - normalizeMattermostMessagingTarget, - resolveDefaultMattermostAccountId, - resolveMattermostAccount, - resolveMattermostGroupRequireMention, setAccountEnabledInConfigSection, - mattermostOnboardingAdapter, - MattermostConfigSchema, type ChannelPlugin, - type ResolvedMattermostAccount, } from "clawdbot/plugin-sdk"; +import { MattermostConfigSchema } from "./config-schema.js"; +import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; +import { + looksLikeMattermostTargetId, + normalizeMattermostMessagingTarget, +} from "./normalize.js"; +import { mattermostOnboardingAdapter } from "./onboarding.js"; +import { + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; +import { monitorMattermostProvider } from "./mattermost/monitor.js"; +import { probeMattermost } from "./mattermost/probe.js"; +import { sendMessageMattermost } from "./mattermost/send.js"; import { getMattermostRuntime } from "./runtime.js"; -const meta = getChatChannelMeta("mattermost"); +const meta = { + id: "mattermost", + label: "Mattermost", + selectionLabel: "Mattermost (plugin)", + detailLabel: "Mattermost Bot", + docsPath: "/channels/mattermost", + docsLabel: "mattermost", + blurb: "self-hosted Slack-style chat; install the plugin to enable.", + systemImage: "bubble.left.and.bubble.right", + order: 65, +} as const; export const mattermostPlugin: ChannelPlugin = { id: "mattermost", @@ -96,8 +112,7 @@ export const mattermostPlugin: ChannelPlugin = { return { ok: true, to: trimmed }; }, sendText: async ({ to, text, accountId, deps, replyToId }) => { - const send = - deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost; + const send = deps?.sendMattermost ?? sendMessageMattermost; const result = await send(to, text, { accountId: accountId ?? undefined, replyToId: replyToId ?? undefined, @@ -105,8 +120,7 @@ export const mattermostPlugin: ChannelPlugin = { return { channel: "mattermost", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { - const send = - deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost; + const send = deps?.sendMattermost ?? sendMessageMattermost; const result = await send(to, text, { accountId: accountId ?? undefined, mediaUrl, @@ -144,11 +158,7 @@ export const mattermostPlugin: ChannelPlugin = { if (!token || !baseUrl) { return { ok: false, error: "bot token or baseUrl missing" }; } - return await getMattermostRuntime().channel.mattermost.probeMattermost( - baseUrl, - token, - timeoutMs, - ); + return await probeMattermost(baseUrl, token, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, @@ -256,7 +266,7 @@ export const mattermostPlugin: ChannelPlugin = { botTokenSource: account.botTokenSource, }); ctx.log?.info(`[${account.accountId}] starting channel`); - return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({ + return monitorMattermostProvider({ botToken: account.botToken ?? undefined, baseUrl: account.baseUrl ?? undefined, accountId: account.accountId, diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts new file mode 100644 index 000000000..3cbecaf34 --- /dev/null +++ b/extensions/mattermost/src/config-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk"; + +const MattermostAccountSchema = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + configWrites: z.boolean().optional(), + botToken: z.string().optional(), + baseUrl: z.string().optional(), + chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(), + oncharPrefixes: z.array(z.string()).optional(), + requireMention: z.boolean().optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + }) + .strict(); + +export const MattermostConfigSchema = MattermostAccountSchema.extend({ + accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), +}); diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts new file mode 100644 index 000000000..773e655ff --- /dev/null +++ b/extensions/mattermost/src/group-mentions.ts @@ -0,0 +1,14 @@ +import type { ChannelGroupContext } from "clawdbot/plugin-sdk"; + +import { resolveMattermostAccount } from "./mattermost/accounts.js"; + +export function resolveMattermostGroupRequireMention( + params: ChannelGroupContext, +): boolean | undefined { + const account = resolveMattermostAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (typeof account.requireMention === "boolean") return account.requireMention; + return true; +} diff --git a/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts similarity index 96% rename from src/mattermost/accounts.ts rename to extensions/mattermost/src/mattermost/accounts.ts index 08ffa2f94..e75f34593 100644 --- a/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -1,6 +1,7 @@ -import type { ClawdbotConfig } from "../config/config.js"; -import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { MattermostAccountConfig, MattermostChatMode } from "../types.js"; import { normalizeMattermostBaseUrl } from "./client.js"; export type MattermostTokenSource = "env" | "config" | "none"; diff --git a/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts similarity index 100% rename from src/mattermost/client.ts rename to extensions/mattermost/src/mattermost/client.ts diff --git a/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts similarity index 100% rename from src/mattermost/index.ts rename to extensions/mattermost/src/mattermost/index.ts diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts new file mode 100644 index 000000000..8c68a4f25 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -0,0 +1,150 @@ +import { Buffer } from "node:buffer"; + +import type WebSocket from "ws"; + +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +export type ResponsePrefixContext = { + model?: string; + modelFull?: string; + provider?: string; + thinkingLevel?: string; + identityName?: string; +}; + +export function extractShortModelName(fullModel: string): string { + const slash = fullModel.lastIndexOf("/"); + const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel; + return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); +} + +export function formatInboundFromLabel(params: { + isGroup: boolean; + groupLabel?: string; + groupId?: string; + directLabel: string; + directId?: string; + groupFallback?: string; +}): string { + if (params.isGroup) { + const label = params.groupLabel?.trim() || params.groupFallback || "Group"; + const id = params.groupId?.trim(); + return id ? `${label} id:${id}` : label; + } + + const directLabel = params.directLabel.trim(); + const directId = params.directId?.trim(); + if (!directId || directId === directLabel) return directLabel; + return `${directLabel} id:${directId}`; +} + +type DedupeCache = { + check: (key: string | undefined | null, now?: number) => boolean; +}; + +export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache { + const ttlMs = Math.max(0, options.ttlMs); + const maxSize = Math.max(0, Math.floor(options.maxSize)); + const cache = new Map(); + + const touch = (key: string, now: number) => { + cache.delete(key); + cache.set(key, now); + }; + + const prune = (now: number) => { + const cutoff = ttlMs > 0 ? now - ttlMs : undefined; + if (cutoff !== undefined) { + for (const [entryKey, entryTs] of cache) { + if (entryTs < cutoff) { + cache.delete(entryKey); + } + } + } + if (maxSize <= 0) { + cache.clear(); + return; + } + while (cache.size > maxSize) { + const oldestKey = cache.keys().next().value as string | undefined; + if (!oldestKey) break; + cache.delete(oldestKey); + } + }; + + return { + check: (key, now = Date.now()) => { + if (!key) return false; + const existing = cache.get(key); + if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) { + touch(key, now); + return true; + } + touch(key, now); + prune(now); + return false; + }, + }; +} + +export function rawDataToString( + data: WebSocket.RawData, + encoding: BufferEncoding = "utf8", +): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString(encoding); + if (Array.isArray(data)) return Buffer.concat(data).toString(encoding); + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString(encoding); + } + return Buffer.from(String(data)).toString(encoding); +} + +function normalizeAgentId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return "main"; + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || "main" + ); +} + +type AgentEntry = NonNullable["list"]>[number]; + +function listAgents(cfg: ClawdbotConfig): AgentEntry[] { + const list = cfg.agents?.list; + if (!Array.isArray(list)) return []; + return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); +} + +function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined { + const id = normalizeAgentId(agentId); + return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); +} + +export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined { + const entry = resolveAgentEntry(cfg, agentId); + return entry?.identity?.name?.trim() || undefined; +} + +export function resolveThreadSessionKeys(params: { + baseSessionKey: string; + threadId?: string | null; + parentSessionKey?: string; + useSuffix?: boolean; +}): { sessionKey: string; parentSessionKey?: string } { + const threadId = (params.threadId ?? "").trim(); + if (!threadId) { + return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; + } + const useSuffix = params.useSuffix ?? true; + const sessionKey = useSuffix + ? `${params.baseSessionKey}:thread:${threadId}` + : params.baseSessionKey; + return { sessionKey, parentSessionKey: params.parentSessionKey }; +} diff --git a/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts similarity index 80% rename from src/mattermost/monitor.ts rename to extensions/mattermost/src/mattermost/monitor.ts index fb8bd00db..7c0d98fca 100644 --- a/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,52 +1,21 @@ import WebSocket from "ws"; -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../agents/identity.js"; -import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { hasControlCommand } from "../auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js"; -import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; -import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +import type { + ChannelAccountSnapshot, + ClawdbotConfig, + ReplyPayload, + RuntimeEnv, +} from "clawdbot/plugin-sdk"; import { buildPendingHistoryContextFromMap, clearHistoryEntries, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntry, + resolveChannelMediaMaxBytes, type HistoryEntry, -} from "../auto-reply/reply/history.js"; -import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../auto-reply/reply/response-prefix-template.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { ClawdbotConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; -import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; -import { createDedupeCache } from "../infra/dedupe.js"; -import { rawDataToString } from "../infra/ws.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import { enqueueSystemEvent } from "../infra/system-events.js"; -import { getChildLogger } from "../logging.js"; -import { mediaKindFromMime, type MediaKind } from "../media/constants.js"; -import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js"; -import { saveMediaBuffer } from "../media/store.js"; -import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; -import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; -import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; +} from "clawdbot/plugin-sdk"; + +import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -59,6 +28,15 @@ import { type MattermostPost, type MattermostUser, } from "./client.js"; +import { + createDedupeCache, + extractShortModelName, + formatInboundFromLabel, + rawDataToString, + resolveIdentityName, + resolveThreadSessionKeys, + type ResponsePrefixContext, +} from "./monitor-helpers.js"; import { sendMessageMattermost } from "./send.js"; export type MonitorMattermostOpts = { @@ -71,6 +49,9 @@ export type MonitorMattermostOpts = { statusSink?: (patch: Partial) => void; }; +type FetchLike = typeof fetch; +type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + type MattermostEventPayload = { event?: string; data?: { @@ -208,8 +189,9 @@ function buildMattermostWsUrl(baseUrl: string): string { } export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise { + const core = getMattermostRuntime(); const runtime = resolveRuntime(opts); - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, @@ -235,7 +217,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const channelCache = new Map(); const userCache = new Map(); - const logger = getChildLogger({ module: "mattermost" }); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const logVerboseMessage = (message: string) => { + if (!core.logging.shouldLogVerbose()) return; + logger.debug?.(message); + }; const mediaMaxBytes = resolveChannelMediaMaxBytes({ cfg, @@ -262,13 +248,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const out: MattermostMediaInfo[] = []; for (const fileId of ids) { try { - const fetched = await fetchRemoteMedia({ + const fetched = await core.channel.media.fetchRemoteMedia({ url: `${client.apiBaseUrl}/files/${fileId}`, fetchImpl: fetchWithAuth, filePathHint: fileId, maxBytes: mediaMaxBytes, }); - const saved = await saveMediaBuffer( + const saved = await core.channel.media.saveMediaBuffer( fetched.buffer, fetched.contentType ?? undefined, "inbound", @@ -278,7 +264,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} out.push({ path: saved.path, contentType, - kind: mediaKindFromMime(contentType), + kind: core.media.mediaKindFromMime(contentType), }); } catch (err) { logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`); @@ -366,7 +352,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName; const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; - const route = resolveAgentRoute({ + const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "mattermost", accountId: account.accountId, @@ -387,12 +373,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const sessionKey = threadKeys.sessionKey; const historyKey = kind === "dm" ? null : sessionKey; - const mentionRegexes = buildMentionRegexes(cfg, route.agentId); + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); const rawText = post.message?.trim() || ""; const wasMentioned = kind !== "dm" && ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) || - matchesMentionPatterns(rawText, mentionRegexes)); + core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes)); const pendingBody = rawText || (post.file_ids?.length @@ -416,11 +402,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); }; - const allowTextCommands = shouldHandleTextCommands({ + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", }); - const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg); + const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); const oncharEnabled = account.chatmode === "onchar" && kind !== "dm"; const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : []; const oncharResult = oncharEnabled @@ -456,7 +442,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const bodyText = normalizeMention(baseText, botUsername); if (!bodyText) return; - recordChannelActivity({ + core.channel.activity.record({ channel: "mattermost", accountId: account.accountId, direction: "inbound", @@ -476,13 +462,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} kind === "dm" ? `Mattermost DM from ${senderName}` : `Mattermost message in ${roomLabel} from ${senderName}`; - enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { sessionKey, contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`, }); const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`; - const body = formatInboundEnvelope({ + const body = core.channel.reply.formatInboundEnvelope({ channel: "Mattermost", from: fromLabel, timestamp: typeof post.create_at === "number" ? post.create_at : undefined, @@ -498,7 +484,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatInboundEnvelope({ + core.channel.reply.formatInboundEnvelope({ channel: "Mattermost", from: fromLabel, timestamp: entry.timestamp, @@ -513,11 +499,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`; const mediaPayload = buildMattermostMediaPayload(mediaList); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups: cfg.commands?.useAccessGroups ?? false, authorizers: [], }); - const ctxPayload = finalizeInboundContext({ + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, RawBody: bodyText, CommandBody: bodyText, @@ -557,10 +543,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} if (kind === "dm") { const sessionCfg = cfg.session; - const storePath = resolveStorePath(sessionCfg?.store, { + const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, { agentId: route.agentId, }); - await updateLastRoute({ + await core.channel.session.updateLastRoute({ storePath, sessionKey: route.mainSessionKey, deliveryContext: { @@ -571,14 +557,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); } - if (shouldLogVerbose()) { - const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n"); - logVerbose( - `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, - ); - } + const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage( + `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, + ); - const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, { + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { fallbackLimit: account.textChunkLimit ?? 4000, }); @@ -586,43 +570,45 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} identityName: resolveIdentityName(cfg, route.agentId), }; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), - deliver: async (payload: ReplyPayload) => { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - if (mediaUrls.length === 0) { - const chunks = chunkMarkdownText(text, textLimit); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) continue; - await sendMessageMattermost(to, chunk, { - accountId: account.accountId, - replyToId: threadRootId, - }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, + responsePrefixContextProvider: () => prefixContext, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + if (mediaUrls.length === 0) { + const chunks = core.channel.text.chunkMarkdownText(text, textLimit); + for (const chunk of chunks.length > 0 ? chunks : [text]) { + if (!chunk) continue; + await sendMessageMattermost(to, chunk, { + accountId: account.accountId, + replyToId: threadRootId, + }); + } + } else { + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await sendMessageMattermost(to, caption, { + accountId: account.accountId, + mediaUrl, + replyToId: threadRootId, + }); + } } - } else { - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await sendMessageMattermost(to, caption, { - accountId: account.accountId, - mediaUrl, - replyToId: threadRootId, - }); - } - } - runtime.log?.(`delivered reply to ${to}`); - }, - onError: (err, info) => { - runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`)); - }, - onReplyStart: () => sendTypingIndicator(channelId, threadRootId), - }); + runtime.log?.(`delivered reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: () => sendTypingIndicator(channelId, threadRootId), + }); - await dispatchReplyFromConfig({ + await core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, @@ -644,8 +630,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } }; - const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" }); - const debouncer = createInboundDebouncer<{ + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "mattermost", + }); + const debouncer = core.channel.debounce.createInboundDebouncer<{ post: MattermostPost; payload: MattermostEventPayload; }>({ @@ -664,7 +653,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} if (entry.post.file_ids && entry.post.file_ids.length > 0) return false; const text = entry.post.message?.trim() ?? ""; if (!text) return false; - return !hasControlCommand(text, cfg); + return !core.channel.text.hasControlCommand(text, cfg); }, onFlush: async (entries) => { const last = entries.at(-1); @@ -686,7 +675,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined); }, onError: (err) => { - runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`)); + runtime.error?.(`mattermost debounce flush failed: ${String(err)}`); }, }); @@ -739,7 +728,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} try { await debouncer.enqueue({ post, payload }); } catch (err) { - runtime.error?.(danger(`mattermost handler failed: ${String(err)}`)); + runtime.error?.(`mattermost handler failed: ${String(err)}`); } }); @@ -758,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); ws.on("error", (err) => { - runtime.error?.(danger(`mattermost websocket error: ${String(err)}`)); + runtime.error?.(`mattermost websocket error: ${String(err)}`); opts.statusSink?.({ lastError: String(err), }); diff --git a/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts similarity index 100% rename from src/mattermost/probe.ts rename to extensions/mattermost/src/mattermost/probe.ts diff --git a/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts similarity index 93% rename from src/mattermost/send.ts rename to extensions/mattermost/src/mattermost/send.ts index 40f038cc0..f5b22c768 100644 --- a/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,7 +1,4 @@ -import { loadConfig } from "../config/config.js"; -import { logVerbose, shouldLogVerbose } from "../globals.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import { loadWebMedia } from "../web/media.js"; +import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -34,6 +31,8 @@ type MattermostTarget = const botUserCache = new Map(); const userByNameCache = new Map(); +const getCore = () => getMattermostRuntime(); + function cacheKey(baseUrl: string, token: string): string { return `${baseUrl}::${token}`; } @@ -129,7 +128,9 @@ export async function sendMessageMattermost( text: string, opts: MattermostSendOpts = {}, ): Promise { - const cfg = loadConfig(); + const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const cfg = core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, @@ -161,7 +162,7 @@ export async function sendMessageMattermost( const mediaUrl = opts.mediaUrl?.trim(); if (mediaUrl) { try { - const media = await loadWebMedia(mediaUrl); + const media = await core.media.loadWebMedia(mediaUrl); const fileInfo = await uploadMattermostFile(client, { channelId, buffer: media.buffer, @@ -171,8 +172,8 @@ export async function sendMessageMattermost( fileIds = [fileInfo.id]; } catch (err) { uploadError = err instanceof Error ? err : new Error(String(err)); - if (shouldLogVerbose()) { - logVerbose( + if (core.logging.shouldLogVerbose()) { + logger.debug?.( `mattermost send: media upload failed, falling back to URL text: ${String(err)}`, ); } @@ -194,7 +195,7 @@ export async function sendMessageMattermost( fileIds, }); - recordChannelActivity({ + core.channel.activity.record({ channel: "mattermost", accountId: account.accountId, direction: "outbound", diff --git a/src/channels/plugins/normalize/mattermost.ts b/extensions/mattermost/src/normalize.ts similarity index 100% rename from src/channels/plugins/normalize/mattermost.ts rename to extensions/mattermost/src/normalize.ts diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts new file mode 100644 index 000000000..8a5d1f585 --- /dev/null +++ b/extensions/mattermost/src/onboarding-helpers.ts @@ -0,0 +1,42 @@ +import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +type PromptAccountIdParams = { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + label: string; + currentId?: string; + listAccountIds: (cfg: ClawdbotConfig) => string[]; + defaultAccountId: string; +}; + +export async function promptAccountId(params: PromptAccountIdParams): Promise { + const existingIds = params.listAccountIds(params.cfg); + const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; + const choice = (await params.prompter.select({ + message: `${params.label} account`, + options: [ + ...existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + { value: "__new__", label: "Add a new account" }, + ], + initialValue: initial, + })) as string; + + if (choice !== "__new__") return normalizeAccountId(choice); + + const entered = await params.prompter.text({ + message: `New ${params.label} account id`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const normalized = normalizeAccountId(String(entered)); + if (String(entered).trim() !== normalized) { + await params.prompter.note( + `Normalized account id to "${normalized}".`, + `${params.label} account`, + ); + } + return normalized; +} diff --git a/src/channels/plugins/onboarding/mattermost.ts b/extensions/mattermost/src/onboarding.ts similarity index 91% rename from src/channels/plugins/onboarding/mattermost.ts rename to extensions/mattermost/src/onboarding.ts index 3c7ffe2db..431c648ae 100644 --- a/src/channels/plugins/onboarding/mattermost.ts +++ b/extensions/mattermost/src/onboarding.ts @@ -1,14 +1,12 @@ -import type { ClawdbotConfig } from "../../../config/config.js"; +import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + import { listMattermostAccountIds, resolveDefaultMattermostAccountId, resolveMattermostAccount, -} from "../../../mattermost/accounts.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter } from "../onboarding-types.js"; -import { promptAccountId } from "./helpers.js"; +} from "./mattermost/accounts.js"; +import { promptAccountId } from "./onboarding-helpers.js"; const channel = "mattermost" as const; @@ -19,7 +17,7 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise { "2) Create a bot + copy its token", "3) Use your server base URL (e.g., https://chat.example.com)", "Tip: the bot must be a member of any channel you want it to monitor.", - `Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`, + "Docs: https://docs.clawd.bot/channels/mattermost", ].join("\n"), "Mattermost bot token", ); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts new file mode 100644 index 000000000..43e509763 --- /dev/null +++ b/extensions/mattermost/src/types.ts @@ -0,0 +1,40 @@ +import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk"; + +export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; + +export type MattermostAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this Mattermost account. Default: true. */ + enabled?: boolean; + /** Bot token for Mattermost. */ + botToken?: string; + /** Base URL for the Mattermost server (e.g., https://chat.example.com). */ + baseUrl?: string; + /** + * Controls when channel messages trigger replies. + * - "oncall": only respond when mentioned + * - "onmessage": respond to every channel message + * - "onchar": respond when a trigger character prefixes the message + */ + chatmode?: MattermostChatMode; + /** Prefix characters that trigger onchar mode (default: [">", "!"]). */ + oncharPrefixes?: string[]; + /** Require @mention to respond in channels. Default: true. */ + requireMention?: boolean; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; +}; + +export type MattermostConfig = { + /** Optional per-account Mattermost configuration (multi-account). */ + accounts?: Record; +} & MattermostAccountConfig; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index a2adbf312..524b6ddaf 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -159,8 +159,7 @@ export async function runPreparedReply( const isGroupChat = sessionCtx.ChatType === "group"; const originatingChannel = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? ""; - const wasMentioned = - ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat); + const wasMentioned = ctx.WasMentioned === true; const isHeartbeat = opts?.isHeartbeat === true; const typingMode = resolveTypingMode({ configured: sessionCfg?.typingMode ?? agentCfg?.typingMode, diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 469880482..81b07c36a 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -12,7 +12,6 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, - resolveMattermostGroupRequireMention, resolveSlackGroupRequireMention, resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, @@ -231,30 +230,6 @@ const DOCKS: Record = { buildToolContext: (params) => buildSlackThreadingToolContext(params), }, }, - mattermost: { - id: "mattermost", - capabilities: { - chatTypes: ["direct", "channel", "group", "thread"], - media: true, - threads: true, - }, - outbound: { textChunkLimit: 4000 }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - groups: { - resolveRequireMention: resolveMattermostGroupRequireMention, - }, - threading: { - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: context.To?.startsWith("channel:") - ? context.To.slice("channel:".length) - : undefined, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), - }, - }, signal: { id: "signal", capabilities: { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index bb7a111e8..79dfa0320 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -1,7 +1,6 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; import type { DiscordConfig } from "../../config/types.js"; -import { resolveMattermostAccount } from "../../mattermost/accounts.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; type GroupMentionParams = { @@ -185,15 +184,6 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo return true; } -export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean { - const account = resolveMattermostAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (typeof account.requireMention === "boolean") return account.requireMention; - return true; -} - export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelGroupRequireMention({ cfg: params.cfg, diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 25fb13502..52e7a5f01 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -9,7 +9,6 @@ export const CHAT_CHANNEL_ORDER = [ "whatsapp", "discord", "slack", - "mattermost", "signal", "imessage", ] as const; @@ -68,16 +67,6 @@ const CHAT_CHANNEL_META: Record = { blurb: "supported (Socket Mode).", systemImage: "number", }, - mattermost: { - id: "mattermost", - label: "Mattermost", - selectionLabel: "Mattermost (Bot Token)", - detailLabel: "Mattermost Bot", - docsPath: "/channels/mattermost", - docsLabel: "mattermost", - blurb: "self-hosted Slack-style chat (bot token + URL).", - systemImage: "bubble.left.and.bubble.right", - }, signal: { id: "signal", label: "Signal", diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 74caa18c6..2d874d7e9 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { sendMessageDiscord } from "../../discord/send.js"; import type { sendMessageIMessage } from "../../imessage/send.js"; -import type { sendMessageMattermost } from "../../mattermost/send.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; import { sendMessageSignal } from "../../signal/send.js"; import type { sendMessageSlack } from "../../slack/send.js"; @@ -29,12 +28,18 @@ type SendMatrixMessage = ( opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number }, ) => Promise<{ messageId: string; roomId: string }>; +type SendMattermostMessage = ( + to: string, + text: string, + opts?: { accountId?: string; mediaUrl?: string; replyToId?: string }, +) => Promise<{ messageId: string; channelId: string }>; + export type OutboundSendDeps = { sendWhatsApp?: typeof sendMessageWhatsApp; sendTelegram?: typeof sendMessageTelegram; sendDiscord?: typeof sendMessageDiscord; sendSlack?: typeof sendMessageSlack; - sendMattermost?: typeof sendMessageMattermost; + sendMattermost?: SendMattermostMessage; sendSignal?: typeof sendMessageSignal; sendIMessage?: typeof sendMessageIMessage; sendMatrix?: SendMatrixMessage; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ef8f22d55..1da3650fe 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -81,7 +81,6 @@ export type { export { DiscordConfigSchema, IMessageConfigSchema, - MattermostConfigSchema, MSTeamsConfigSchema, SignalConfigSchema, SlackConfigSchema, @@ -121,7 +120,6 @@ export { resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, - resolveMattermostGroupRequireMention, resolveSlackGroupRequireMention, resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, @@ -241,21 +239,6 @@ export { } from "../channels/plugins/normalize/slack.js"; export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; -// Channel: Mattermost -export { - listEnabledMattermostAccounts, - listMattermostAccountIds, - resolveDefaultMattermostAccountId, - resolveMattermostAccount, - type ResolvedMattermostAccount, -} from "../mattermost/accounts.js"; -export { normalizeMattermostBaseUrl } from "../mattermost/client.js"; -export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js"; -export { - looksLikeMattermostTargetId, - normalizeMattermostMessagingTarget, -} from "../channels/plugins/normalize/mattermost.js"; - // Channel: Telegram export { listTelegramAccountIds, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index e564ad2f8..4765c71c7 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -57,9 +57,6 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { probeIMessage } from "../../imessage/probe.js"; import { sendMessageIMessage } from "../../imessage/send.js"; -import { monitorMattermostProvider } from "../../mattermost/monitor.js"; -import { probeMattermost } from "../../mattermost/probe.js"; -import { sendMessageMattermost } from "../../mattermost/send.js"; import { shouldLogVerbose } from "../../globals.js"; import { getChildLogger } from "../../logging.js"; import { normalizeLogLevel } from "../../logging/levels.js"; @@ -233,11 +230,6 @@ export function createPluginRuntime(): PluginRuntime { monitorSlackProvider, handleSlackAction, }, - mattermost: { - probeMattermost, - sendMessageMattermost, - monitorMattermostProvider, - }, telegram: { auditGroupMembership: auditTelegramGroupMembership, collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 31350693c..089e20c37 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -98,10 +98,6 @@ type ResolveSlackUserAllowlist = type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack; type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider; type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction; -type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost; -type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost; -type MonitorMattermostProvider = - typeof import("../../mattermost/monitor.js").monitorMattermostProvider; type AuditTelegramGroupMembership = typeof import("../../telegram/audit.js").auditTelegramGroupMembership; type CollectTelegramUnmentionedGroupIds = @@ -246,11 +242,6 @@ export type PluginRuntime = { monitorSlackProvider: MonitorSlackProvider; handleSlackAction: HandleSlackAction; }; - mattermost: { - probeMattermost: ProbeMattermost; - sendMessageMattermost: SendMessageMattermost; - monitorMattermostProvider: MonitorMattermostProvider; - }; telegram: { auditGroupMembership: AuditTelegramGroupMembership; collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds; diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 96a6b8556..d9f148764 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -101,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } - return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"]; + return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"]; } function renderChannel(