feat: add Mattermost channel support
Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
This commit is contained in:
@@ -157,7 +157,10 @@ export async function runPreparedReply(
|
||||
|
||||
const isFirstTurnInSession = isNewSession || !currentSystemSent;
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
const originatingChannel =
|
||||
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
|
||||
const wasMentioned =
|
||||
ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
|
||||
const isHeartbeat = opts?.isHeartbeat === true;
|
||||
const typingMode = resolveTypingMode({
|
||||
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveMattermostGroupRequireMention,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
@@ -235,6 +236,30 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 = {
|
||||
@@ -184,6 +185,15 @@ 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,
|
||||
|
||||
38
src/channels/plugins/normalize/mattermost.ts
Normal file
38
src/channels/plugins/normalize/mattermost.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
|
||||
if (/^[@#]/.test(trimmed)) return true;
|
||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||
}
|
||||
189
src/channels/plugins/onboarding/mattermost.ts
Normal file
189
src/channels/plugins/onboarding/mattermost.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
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";
|
||||
|
||||
const channel = "mattermost" as const;
|
||||
|
||||
async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Mattermost System Console -> Integrations -> Bot Accounts",
|
||||
"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")}`,
|
||||
].join("\n"),
|
||||
"Mattermost bot token",
|
||||
);
|
||||
}
|
||||
|
||||
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listMattermostAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.baseUrl);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
|
||||
selectionHint: configured ? "configured" : "needs setup",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides.mattermost?.trim();
|
||||
const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Mattermost",
|
||||
currentId: accountId,
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveMattermostAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv &&
|
||||
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
|
||||
Boolean(process.env.MATTERMOST_URL?.trim());
|
||||
const hasConfigValues =
|
||||
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let baseUrl: string | null = null;
|
||||
|
||||
if (!accountConfigured) {
|
||||
await noteMattermostSetup(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv && !hasConfigValues) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (accountConfigured) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Mattermost credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (botToken || baseUrl) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
disable: (cfg: ClawdbotConfig) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export const CHAT_CHANNEL_ORDER = [
|
||||
"whatsapp",
|
||||
"discord",
|
||||
"slack",
|
||||
"mattermost",
|
||||
"signal",
|
||||
"imessage",
|
||||
] as const;
|
||||
@@ -67,6 +68,16 @@ 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",
|
||||
|
||||
@@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
|
||||
if (!trimmed) return "group";
|
||||
if (trimmed.startsWith("@")) return "user";
|
||||
if (/^<@!?/.test(trimmed)) return "user";
|
||||
if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
||||
if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
||||
return "user";
|
||||
}
|
||||
return "group";
|
||||
|
||||
@@ -52,6 +52,8 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"SLACK_BOT_TOKEN",
|
||||
"SLACK_APP_TOKEN",
|
||||
"MATTERMOST_BOT_TOKEN",
|
||||
"MATTERMOST_URL",
|
||||
"CLAWDBOT_GATEWAY_TOKEN",
|
||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||
];
|
||||
|
||||
@@ -124,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"mattermost",
|
||||
"signal",
|
||||
"imessage",
|
||||
"msteams",
|
||||
|
||||
@@ -17,6 +17,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
path: ["slack"],
|
||||
message: "slack config moved to channels.slack (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["mattermost"],
|
||||
message: "mattermost config moved to channels.mattermost (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["signal"],
|
||||
message: "signal config moved to channels.signal (auto-migrated on load).",
|
||||
|
||||
@@ -272,6 +272,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.telegram.customCommands": "Telegram Custom Commands",
|
||||
"channels.discord": "Discord",
|
||||
"channels.slack": "Slack",
|
||||
"channels.mattermost": "Mattermost",
|
||||
"channels.signal": "Signal",
|
||||
"channels.imessage": "iMessage",
|
||||
"channels.bluebubbles": "BlueBubbles",
|
||||
@@ -309,6 +310,11 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||
"channels.mattermost.botToken": "Mattermost Bot Token",
|
||||
"channels.mattermost.baseUrl": "Mattermost Base URL",
|
||||
"channels.mattermost.chatmode": "Mattermost Chat Mode",
|
||||
"channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
|
||||
"channels.mattermost.requireMention": "Mattermost Require Mention",
|
||||
"channels.signal.account": "Signal Account",
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
@@ -415,6 +421,15 @@ const FIELD_HELP: Record<string, string> = {
|
||||
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
|
||||
"channels.slack.thread.inheritParent":
|
||||
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
|
||||
"channels.mattermost.botToken":
|
||||
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
|
||||
"channels.mattermost.baseUrl":
|
||||
"Base URL for your Mattermost server (e.g., https://chat.example.com).",
|
||||
"channels.mattermost.chatmode":
|
||||
'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").',
|
||||
"channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).',
|
||||
"channels.mattermost.requireMention":
|
||||
"Require @mention in channels before responding (default: true).",
|
||||
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
|
||||
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
|
||||
"auth.cooldowns.billingBackoffHours":
|
||||
@@ -532,6 +547,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Allow Telegram to write config in response to channel events/commands (default: true).",
|
||||
"channels.slack.configWrites":
|
||||
"Allow Slack to write config in response to channel events/commands (default: true).",
|
||||
"channels.mattermost.configWrites":
|
||||
"Allow Mattermost to write config in response to channel events/commands (default: true).",
|
||||
"channels.discord.configWrites":
|
||||
"Allow Discord to write config in response to channel events/commands (default: true).",
|
||||
"channels.whatsapp.configWrites":
|
||||
@@ -606,6 +623,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
||||
"gateway.remote.sshTarget": "user@host",
|
||||
"gateway.controlUi.basePath": "/clawdbot",
|
||||
"channels.mattermost.baseUrl": "https://chat.example.com",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
@@ -162,13 +162,14 @@ export type AgentDefaultsConfig = {
|
||||
every?: string;
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "mattermost"
|
||||
| "msteams"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DiscordConfig } from "./types.discord.js";
|
||||
import type { IMessageConfig } from "./types.imessage.js";
|
||||
import type { MattermostConfig } from "./types.mattermost.js";
|
||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||
import type { SignalConfig } from "./types.signal.js";
|
||||
import type { SlackConfig } from "./types.slack.js";
|
||||
@@ -17,6 +18,7 @@ export type ChannelsConfig = {
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
slack?: SlackConfig;
|
||||
mattermost?: MattermostConfig;
|
||||
signal?: SignalConfig;
|
||||
imessage?: IMessageConfig;
|
||||
msteams?: MSTeamsConfig;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type HookMappingConfig = {
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "mattermost"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
|
||||
40
src/config/types.mattermost.ts
Normal file
40
src/config/types.mattermost.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { BlockStreamingCoalesceConfig } from "./types.base.js";
|
||||
|
||||
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;
|
||||
@@ -22,6 +22,7 @@ export type InboundDebounceByProvider = {
|
||||
telegram?: number;
|
||||
discord?: number;
|
||||
slack?: number;
|
||||
mattermost?: number;
|
||||
signal?: number;
|
||||
imessage?: number;
|
||||
msteams?: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type QueueModeByProvider = {
|
||||
telegram?: QueueMode;
|
||||
discord?: QueueMode;
|
||||
slack?: QueueMode;
|
||||
mattermost?: QueueMode;
|
||||
signal?: QueueMode;
|
||||
imessage?: QueueMode;
|
||||
msteams?: QueueMode;
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from "./types.hooks.js";
|
||||
export * from "./types.imessage.js";
|
||||
export * from "./types.messages.js";
|
||||
export * from "./types.models.js";
|
||||
export * from "./types.mattermost.js";
|
||||
export * from "./types.msteams.js";
|
||||
export * from "./types.plugins.js";
|
||||
export * from "./types.queue.js";
|
||||
|
||||
@@ -20,6 +20,7 @@ export const HeartbeatSchema = z
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("mattermost"),
|
||||
z.literal("msteams"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
|
||||
@@ -208,6 +208,7 @@ export const QueueModeBySurfaceSchema = z
|
||||
telegram: QueueModeSchema.optional(),
|
||||
discord: QueueModeSchema.optional(),
|
||||
slack: QueueModeSchema.optional(),
|
||||
mattermost: QueueModeSchema.optional(),
|
||||
signal: QueueModeSchema.optional(),
|
||||
imessage: QueueModeSchema.optional(),
|
||||
msteams: QueueModeSchema.optional(),
|
||||
@@ -222,6 +223,7 @@ export const DebounceMsBySurfaceSchema = z
|
||||
telegram: z.number().int().nonnegative().optional(),
|
||||
discord: z.number().int().nonnegative().optional(),
|
||||
slack: z.number().int().nonnegative().optional(),
|
||||
mattermost: z.number().int().nonnegative().optional(),
|
||||
signal: z.number().int().nonnegative().optional(),
|
||||
imessage: z.number().int().nonnegative().optional(),
|
||||
msteams: z.number().int().nonnegative().optional(),
|
||||
|
||||
@@ -23,6 +23,7 @@ export const HookMappingSchema = z
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("mattermost"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("msteams"),
|
||||
|
||||
@@ -367,6 +367,27 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
|
||||
}
|
||||
});
|
||||
|
||||
export 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(),
|
||||
});
|
||||
|
||||
export const SignalAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BlueBubblesConfigSchema,
|
||||
DiscordConfigSchema,
|
||||
IMessageConfigSchema,
|
||||
MattermostConfigSchema,
|
||||
MSTeamsConfigSchema,
|
||||
SignalConfigSchema,
|
||||
SlackConfigSchema,
|
||||
@@ -27,6 +28,7 @@ export const ChannelsSchema = z
|
||||
telegram: TelegramConfigSchema.optional(),
|
||||
discord: DiscordConfigSchema.optional(),
|
||||
slack: SlackConfigSchema.optional(),
|
||||
mattermost: MattermostConfigSchema.optional(),
|
||||
signal: SignalConfigSchema.optional(),
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
bluebubbles: BlueBubblesConfigSchema.optional(),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -33,6 +34,7 @@ export type OutboundSendDeps = {
|
||||
sendTelegram?: typeof sendMessageTelegram;
|
||||
sendDiscord?: typeof sendMessageDiscord;
|
||||
sendSlack?: typeof sendMessageSlack;
|
||||
sendMattermost?: typeof sendMessageMattermost;
|
||||
sendSignal?: typeof sendMessageSignal;
|
||||
sendIMessage?: typeof sendMessageIMessage;
|
||||
sendMatrix?: SendMatrixMessage;
|
||||
|
||||
114
src/mattermost/accounts.ts
Normal file
114
src/mattermost/accounts.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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 { normalizeMattermostBaseUrl } from "./client.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listMattermostAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as MattermostAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
|
||||
{}) as MattermostAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") return true;
|
||||
if (config.chatmode === "onmessage") return false;
|
||||
if (config.chatmode === "onchar") return true;
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
|
||||
const configToken = merged.botToken?.trim();
|
||||
const configUrl = merged.baseUrl?.trim();
|
||||
const botToken = configToken || envToken;
|
||||
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
|
||||
const requireMention = resolveMattermostRequireMention(merged);
|
||||
|
||||
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
|
||||
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
baseUrl,
|
||||
botTokenSource,
|
||||
baseUrlSource,
|
||||
config: merged,
|
||||
chatmode: merged.chatmode,
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
blockStreamingCoalesce: merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
208
src/mattermost/client.ts
Normal file
208
src/mattermost/client.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
export type MattermostClient = {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export type MattermostUser = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
nickname?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
type?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostPost = {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
message?: string | null;
|
||||
file_ids?: string[] | null;
|
||||
type?: string | null;
|
||||
root_id?: string | null;
|
||||
create_at?: number | null;
|
||||
props?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MattermostFileInfo = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||
}
|
||||
|
||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalized}/api/v4${suffix}`;
|
||||
}
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): MattermostClient {
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||
if (!baseUrl) throw new Error("Mattermost baseUrl is required");
|
||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||
const token = params.botToken.trim();
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
return { baseUrl, apiBaseUrl, token, request };
|
||||
}
|
||||
|
||||
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchMattermostUser(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostUserByUsername(
|
||||
client: MattermostClient,
|
||||
username: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostChannel(
|
||||
client: MattermostClient,
|
||||
channelId: string,
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export async function sendMattermostTyping(
|
||||
client: MattermostClient,
|
||||
params: { channelId: string; parentId?: string },
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
};
|
||||
const parentId = params.parentId?.trim();
|
||||
if (parentId) payload.parent_id = parentId;
|
||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostDirectChannel(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>("/channels/direct", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
rootId?: string;
|
||||
fileIds?: string[];
|
||||
},
|
||||
): Promise<MattermostPost> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
message: params.message,
|
||||
};
|
||||
if (params.rootId) payload.root_id = params.rootId;
|
||||
if (params.fileIds?.length) {
|
||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
||||
}
|
||||
return await client.request<MattermostPost>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<MattermostFileInfo> {
|
||||
const form = new FormData();
|
||||
const fileName = params.fileName?.trim() || "upload";
|
||||
const bytes = Uint8Array.from(params.buffer);
|
||||
const blob = params.contentType
|
||||
? new Blob([bytes], { type: params.contentType })
|
||||
: new Blob([bytes]);
|
||||
form.append("files", blob, fileName);
|
||||
form.append("channel_id", params.channelId);
|
||||
|
||||
const res = await fetch(`${client.apiBaseUrl}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
9
src/mattermost/index.ts
Normal file
9
src/mattermost/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
listEnabledMattermostAccounts,
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./accounts.js";
|
||||
export { monitorMattermostProvider } from "./monitor.js";
|
||||
export { probeMattermost } from "./probe.js";
|
||||
export { sendMessageMattermost } from "./send.js";
|
||||
774
src/mattermost/monitor.ts
Normal file
774
src/mattermost/monitor.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
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 {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntry,
|
||||
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";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
type MattermostPost,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
|
||||
export type MonitorMattermostOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
};
|
||||
|
||||
type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
||||
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
||||
return (
|
||||
opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) return text.trim();
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`@${escaped}\\b`, "gi");
|
||||
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) continue;
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
|
||||
function isSystemPost(post: MattermostPost): boolean {
|
||||
const type = post.type?.trim();
|
||||
return Boolean(type);
|
||||
}
|
||||
|
||||
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
|
||||
if (!channelType) return "channel";
|
||||
const normalized = channelType.trim().toUpperCase();
|
||||
if (normalized === "D") return "dm";
|
||||
if (normalized === "G") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
|
||||
if (kind === "dm") return "direct";
|
||||
if (kind === "group") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
kind: MediaKind;
|
||||
};
|
||||
|
||||
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
||||
if (mediaList.length === 0) return "";
|
||||
if (mediaList.length === 1) {
|
||||
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
||||
return `<media:${kind}>`;
|
||||
}
|
||||
const allImages = mediaList.every((media) => media.kind === "image");
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = mediaList.length === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${mediaList.length} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMattermostWsUrl(baseUrl: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const wsBase = normalized.replace(/^http/i, "ws");
|
||||
return `${wsBase}/api/v4/websocket`;
|
||||
}
|
||||
|
||||
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!botToken) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken });
|
||||
const botUser = await fetchMattermostMe(client);
|
||||
const botUserId = botUser.id;
|
||||
const botUsername = botUser.username?.trim() || undefined;
|
||||
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
||||
|
||||
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 mediaMaxBytes =
|
||||
resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: () => undefined,
|
||||
accountId: account.accountId,
|
||||
}) ?? 8 * 1024 * 1024;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const channelHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const fetchWithAuth: FetchLike = (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${client.token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
|
||||
if (ids.length === 0) return [];
|
||||
const out: MattermostMediaInfo[] = [];
|
||||
for (const fileId of ids) {
|
||||
try {
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||
fetchImpl: fetchWithAuth,
|
||||
filePathHint: fileId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? undefined,
|
||||
"inbound",
|
||||
mediaMaxBytes,
|
||||
);
|
||||
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType,
|
||||
kind: mediaKindFromMime(contentType),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
||||
try {
|
||||
await sendMattermostTyping(client, { channelId, parentId });
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = async (
|
||||
post: MattermostPost,
|
||||
payload: MattermostEventPayload,
|
||||
messageIds?: string[],
|
||||
) => {
|
||||
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
||||
if (!channelId) return;
|
||||
|
||||
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
||||
if (allMessageIds.length === 0) return;
|
||||
const dedupeEntries = allMessageIds.map((id) =>
|
||||
recentInboundMessages.check(`${account.accountId}:${id}`),
|
||||
);
|
||||
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
|
||||
|
||||
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
||||
if (!senderId) return;
|
||||
if (senderId === botUserId) return;
|
||||
if (isSystemPost(post)) return;
|
||||
|
||||
const channelInfo = await resolveChannelInfo(channelId);
|
||||
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
||||
const kind = channelKind(channelType);
|
||||
const chatType = channelChatType(kind);
|
||||
|
||||
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
||||
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
||||
const channelDisplay =
|
||||
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "dm" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadRootId = post.root_id?.trim() || undefined;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadRootId,
|
||||
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
||||
});
|
||||
const sessionKey = threadKeys.sessionKey;
|
||||
const historyKey = kind === "dm" ? null : sessionKey;
|
||||
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const rawText = post.message?.trim() || "";
|
||||
const wasMentioned =
|
||||
kind !== "dm" &&
|
||||
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
||||
matchesMentionPatterns(rawText, mentionRegexes));
|
||||
const pendingBody =
|
||||
rawText ||
|
||||
(post.file_ids?.length
|
||||
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
||||
: "");
|
||||
const pendingSender = payload.data?.sender_name?.trim() || senderId;
|
||||
const recordPendingHistory = () => {
|
||||
if (!historyKey || historyLimit <= 0) return;
|
||||
const trimmed = pendingBody.trim();
|
||||
if (!trimmed) return;
|
||||
recordPendingHistoryEntry({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: pendingSender,
|
||||
body: trimmed,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
messageId: post.id ?? undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
|
||||
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
||||
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
||||
const oncharResult = oncharEnabled
|
||||
? stripOncharPrefix(rawText, oncharPrefixes)
|
||||
: { triggered: false, stripped: rawText };
|
||||
const oncharTriggered = oncharResult.triggered;
|
||||
|
||||
const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
|
||||
const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
|
||||
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "channel" && shouldRequireMention && canDetectMention) {
|
||||
if (!effectiveWasMentioned) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderName =
|
||||
payload.data?.sender_name?.trim() ||
|
||||
(await resolveUserInfo(senderId))?.username?.trim() ||
|
||||
senderId;
|
||||
const mediaList = await resolveMattermostMedia(post.file_ids);
|
||||
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
||||
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
||||
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
||||
const bodyText = normalizeMention(baseText, botUsername);
|
||||
if (!bodyText) return;
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: kind !== "dm",
|
||||
groupLabel: channelDisplay || roomLabel,
|
||||
groupId: channelId,
|
||||
groupFallback: roomLabel || "Channel",
|
||||
directLabel: senderName,
|
||||
directId: senderId,
|
||||
});
|
||||
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel =
|
||||
kind === "dm"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
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({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
body: textWithId,
|
||||
chatType,
|
||||
sender: { name: senderName, id: senderId },
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body}${
|
||||
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
|
||||
}`,
|
||||
chatType,
|
||||
senderLabel: entry.sender,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
|
||||
authorizers: [],
|
||||
});
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From:
|
||||
kind === "dm"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: sessionKey,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
|
||||
GroupChannel: channelName ? `#${channelName}` : undefined,
|
||||
GroupSpace: teamId,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: post.id ?? undefined,
|
||||
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
|
||||
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
||||
MessageSidLast:
|
||||
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
||||
ReplyToId: threadRootId,
|
||||
MessageThreadId: threadRootId,
|
||||
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
OriginatingTo: to,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
if (kind === "dm") {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
deliveryContext: {
|
||||
channel: "mattermost",
|
||||
to,
|
||||
accountId: route.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerbose(
|
||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
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,
|
||||
});
|
||||
}
|
||||
} 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),
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.provider = ctx.provider;
|
||||
prefixContext.model = extractShortModelName(ctx.model);
|
||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: channelHistories, historyKey });
|
||||
}
|
||||
};
|
||||
|
||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
|
||||
const debouncer = createInboundDebouncer<{
|
||||
post: MattermostPost;
|
||||
payload: MattermostEventPayload;
|
||||
}>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (entry) => {
|
||||
const channelId =
|
||||
entry.post.channel_id ??
|
||||
entry.payload.data?.channel_id ??
|
||||
entry.payload.broadcast?.channel_id;
|
||||
if (!channelId) return null;
|
||||
const threadId = entry.post.root_id?.trim();
|
||||
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
||||
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
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);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) return;
|
||||
if (entries.length === 1) {
|
||||
await handlePost(last.post, last.payload);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.post.message?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const mergedPost: MattermostPost = {
|
||||
...last.post,
|
||||
message: combinedText,
|
||||
file_ids: [],
|
||||
};
|
||||
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
|
||||
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const wsUrl = buildMattermostWsUrl(baseUrl);
|
||||
let seq = 1;
|
||||
|
||||
const connectOnce = async (): Promise<void> => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const onAbort = () => ws.close();
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
ws.on("open", () => {
|
||||
opts.statusSink?.({
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
seq: seq++,
|
||||
action: "authentication_challenge",
|
||||
data: { token: botToken },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
const raw = rawDataToString(data);
|
||||
let payload: MattermostEventPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw) as MattermostEventPayload;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.event !== "posted") return;
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) return;
|
||||
let post: MattermostPost | null = null;
|
||||
if (typeof postData === "string") {
|
||||
try {
|
||||
post = JSON.parse(postData) as MattermostPost;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else if (typeof postData === "object") {
|
||||
post = postData as MattermostPost;
|
||||
}
|
||||
if (!post) return;
|
||||
try {
|
||||
await debouncer.enqueue({ post, payload });
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
const message = reason.length > 0 ? reason.toString("utf8") : "";
|
||||
opts.statusSink?.({
|
||||
connected: false,
|
||||
lastDisconnect: {
|
||||
at: Date.now(),
|
||||
status: code,
|
||||
error: message || undefined,
|
||||
},
|
||||
});
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
|
||||
opts.statusSink?.({
|
||||
lastError: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
await connectOnce();
|
||||
if (opts.abortSignal?.aborted) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
70
src/mattermost/probe.ts
Normal file
70
src/mattermost/probe.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
|
||||
|
||||
export type MattermostProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: detail || res.statusText,
|
||||
elapsedMs,
|
||||
};
|
||||
}
|
||||
const bot = (await res.json()) as MattermostUser;
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
bot,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
207
src/mattermost/send.ts
Normal file
207
src/mattermost/send.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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 { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannel,
|
||||
createMattermostPost,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = text.trim();
|
||||
const media = mediaUrl?.trim();
|
||||
return [trimmed, media].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
if (!id) throw new Error("Channel id is required for Mattermost sends");
|
||||
return { kind: "channel", id };
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
if (!username) {
|
||||
throw new Error("Username is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", username };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
||||
const key = cacheKey(baseUrl, token);
|
||||
const cached = botUserCache.get(key);
|
||||
if (cached) return cached;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostMe(client);
|
||||
botUserCache.set(key, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, username } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
||||
const cached = userByNameCache.get(key);
|
||||
if (cached?.id) return cached.id;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostUserByUsername(client, username);
|
||||
userByNameCache.set(key, user);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: {
|
||||
target: MattermostTarget;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}): Promise<string> {
|
||||
if (params.target.kind === "channel") return params.target.id;
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
});
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
});
|
||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = parseMattermostTarget(to);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
let message = text?.trim() ?? "";
|
||||
let fileIds: string[] | undefined;
|
||||
let uploadError: Error | undefined;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const media = await loadWebMedia(mediaUrl);
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export type {
|
||||
export {
|
||||
DiscordConfigSchema,
|
||||
IMessageConfigSchema,
|
||||
MattermostConfigSchema,
|
||||
MSTeamsConfigSchema,
|
||||
SignalConfigSchema,
|
||||
SlackConfigSchema,
|
||||
@@ -120,6 +121,7 @@ export {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveMattermostGroupRequireMention,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
@@ -236,6 +238,21 @@ export {
|
||||
normalizeSlackMessagingTarget,
|
||||
} from "../channels/plugins/normalize/slack.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,
|
||||
|
||||
@@ -57,6 +57,9 @@ 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";
|
||||
@@ -230,6 +233,11 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
monitorSlackProvider,
|
||||
handleSlackAction,
|
||||
},
|
||||
mattermost: {
|
||||
probeMattermost,
|
||||
sendMessageMattermost,
|
||||
monitorMattermostProvider,
|
||||
},
|
||||
telegram: {
|
||||
auditGroupMembership: auditTelegramGroupMembership,
|
||||
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
|
||||
|
||||
@@ -98,6 +98,10 @@ 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 =
|
||||
@@ -242,6 +246,11 @@ export type PluginRuntime = {
|
||||
monitorSlackProvider: MonitorSlackProvider;
|
||||
handleSlackAction: HandleSlackAction;
|
||||
};
|
||||
mattermost: {
|
||||
probeMattermost: ProbeMattermost;
|
||||
sendMessageMattermost: SendMessageMattermost;
|
||||
monitorMattermostProvider: MonitorMattermostProvider;
|
||||
};
|
||||
telegram: {
|
||||
auditGroupMembership: AuditTelegramGroupMembership;
|
||||
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
|
||||
|
||||
@@ -22,6 +22,7 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set<string>([
|
||||
"telegram",
|
||||
"signal",
|
||||
"discord",
|
||||
"mattermost",
|
||||
"tui",
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user