refactor: extract mattermost channel plugin to extension

Move mattermost channel implementation from core to extensions/mattermost plugin. Extract config schema, group mentions, normalize utilities, and all mattermost-specific logic (accounts, client, monitor, probe, send) into the extension. Update imports to use plugin SDK and local modules. Add channel metadata directly in plugin definition instead of using getChatChannelMeta. Update package.json with channel and install configuration.
This commit is contained in:
Dominic Damoah
2026-01-22 12:02:30 -05:00
parent 91278d8b4e
commit 495a39b5a9
24 changed files with 442 additions and 235 deletions

View File

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

View File

@@ -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<ResolvedMattermostAccount> = {
id: "mattermost",
@@ -96,8 +112,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
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,

View File

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

View File

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

View File

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

View File

@@ -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<string, number>();
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<NonNullable<ClawdbotConfig["agents"]>["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 };
}

View File

@@ -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<ChannelAccountSnapshot>) => 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<void> {
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<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
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),
});

View File

@@ -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<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
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<MattermostSendResult> {
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",

View File

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

View File

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

View File

@@ -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<string, MattermostAccountConfig>;
} & MattermostAccountConfig;

View File

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

View File

@@ -12,7 +12,6 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -231,30 +230,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
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: {

View File

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

View File

@@ -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<ChatChannelId, ChannelMeta> = {
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",

View File

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

View File

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

View File

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

View File

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

View File

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