diff --git a/README.md b/README.md index f52bc90..53bab96 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Microsoft Teams, Google Chat, and iMessage (BlueBubbles) with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, and iMessage (BlueBubbles) with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -161,6 +161,15 @@ teams: # Bot Framework messaging endpoint should point to: # POST https:///teams/events +# Optional: Mattermost +mattermost: + server_url: "${MATTERMOST_SERVER_URL}" + bot_token: "${MATTERMOST_BOT_TOKEN}" + allowed_channel_ids: [] # Recommended: explicit channel IDs + require_mention: true + mention_name: "flynn" + poll_interval_ms: 3000 + # Optional: Google Chat google_chat: service_account_key_file: "~/.config/flynn/google-chat-service-account.json" diff --git a/config/default.yaml b/config/default.yaml index 8755377..341cc39 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -20,6 +20,15 @@ telegram: # poll_interval_ms: 5000 # send_timeout_ms: 15000 +# Optional: Mattermost +# mattermost: +# server_url: ${MATTERMOST_SERVER_URL} +# bot_token: ${MATTERMOST_BOT_TOKEN} +# allowed_channel_ids: [] # Empty = allow all channels (pairing/mention rules still apply) +# require_mention: true +# mention_name: flynn +# poll_interval_ms: 3000 + # Optional: Microsoft Teams (Bot Framework) # teams: # app_id: ${TEAMS_APP_ID} diff --git a/docs/architecture/CONTRIBUTOR_MAP.md b/docs/architecture/CONTRIBUTOR_MAP.md index 4720ffa..1bfed3c 100644 --- a/docs/architecture/CONTRIBUTOR_MAP.md +++ b/docs/architecture/CONTRIBUTOR_MAP.md @@ -20,7 +20,7 @@ src/ hooks/ Confirm/log/silent policy + autonomy resolution sandbox/ Docker sandbox manager + sandboxed tool wrappers models/ Provider clients + model router + retry/cost/capabilities - channels/ Chat adapters + pairing gate + channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/etc.) gateway/ WebSocket JSON-RPC server + web UI + handlers memory/ Hybrid search + embeddings + persistence session/ SQLite store + session mgmt diff --git a/docs/plans/state.json b/docs/plans/state.json index cb13ca8..fc05ad2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -66,6 +66,32 @@ ], "test_status": "pnpm test:run src/gateway/lane-queue.test.ts src/gateway/handlers/agent.test.ts src/gateway/handlers/handlers.test.ts src/commands/builtin/index.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing" }, + "openclaw-gap-phase2-mattermost-channel": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Completed Phase 2 channel expansion with a first Mattermost adapter: polling-based inbound, outbound post send path, mention/allowlist/pairing gating, daemon wiring, schema + redaction + services discovery updates, and docs.", + "files_created": [ + "src/channels/mattermost/adapter.ts", + "src/channels/mattermost/adapter.test.ts", + "src/channels/mattermost/index.ts", + "src/daemon/channels.test.ts" + ], + "files_modified": [ + "src/channels/index.ts", + "src/daemon/channels.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/gateway/handlers/config.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/handlers/services.ts", + "src/gateway/handlers/services.test.ts", + "config/default.yaml", + "README.md", + "docs/architecture/CONTRIBUTOR_MAP.md" + ], + "test_status": "pnpm test:run src/channels/mattermost/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck + pnpm build passing" + }, "docs-gateway-auth-config-keys": { "status": "completed", "date": "2026-02-15", @@ -2989,12 +3015,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "116/128 match (91%), 0 partial (0%), 12 missing (9%)", + "feature_gap_scorecard": "117/128 match (91%), 0 partial (0%), 11 missing (9%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap phase 2: Mattermost channel adapter + wiring/docs/tests" + "next_up": "OpenClaw gap phase 3: companion-node capability/version negotiation foundation" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/channels/index.ts b/src/channels/index.ts index 0c1305b..71240d9 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -17,6 +17,7 @@ export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js'; export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js'; export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js'; export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js'; +export { MattermostAdapter, type MattermostAdapterConfig } from './mattermost/index.js'; export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js'; export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js'; export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js'; diff --git a/src/channels/mattermost/adapter.test.ts b/src/channels/mattermost/adapter.test.ts new file mode 100644 index 0000000..c16a604 --- /dev/null +++ b/src/channels/mattermost/adapter.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MattermostAdapter, type MattermostAdapterConfig } from './adapter.js'; +import type { InboundMessage } from '../types.js'; + +const mockFetch = vi.fn(); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response; +} + +describe('MattermostAdapter', () => { + const baseConfig: MattermostAdapterConfig = { + serverUrl: 'https://mm.example.com', + botToken: 'mm-token', + allowedChannelIds: ['chan-1'], + requireMention: true, + mentionName: 'flynn', + pollIntervalMs: 60_000, + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + }); + + it('sends outbound posts via /api/v4/posts', async () => { + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith('/api/v4/users/me')) { + return jsonResponse({ id: 'bot-user', username: 'flynnbot' }); + } + if (url.endsWith('/api/v4/channels/chan-1')) { + return jsonResponse({ id: 'chan-1', type: 'O' }); + } + if (url.includes('/api/v4/channels/chan-1/posts?since=')) { + return jsonResponse({ order: [], posts: {} }); + } + if (url.endsWith('/api/v4/posts') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + expect(body.channel_id).toBe('chan-1'); + expect(body.message).toBe('hello mattermost'); + return jsonResponse({ id: 'p1' }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const adapter = new MattermostAdapter(baseConfig); + await adapter.connect(); + await adapter.send('chan-1', { text: 'hello mattermost' }); + await adapter.disconnect(); + }); + + it('normalizes inbound message sender/session fields', async () => { + const adapter = new MattermostAdapter({ ...baseConfig, requireMention: false }); + const messages: InboundMessage[] = []; + adapter.onMessage((msg) => messages.push(msg)); + + (adapter as unknown as { botUserId: string }).botUserId = 'bot-user'; + await (adapter as unknown as { + processPosts: (channelId: string, response: unknown) => Promise; + }).processPosts('chan-1', { + order: ['p1'], + posts: { + p1: { + id: 'p1', + channel_id: 'chan-1', + user_id: 'user-123', + message: 'hello', + create_at: 1700000000000, + }, + }, + }); + + expect(messages).toHaveLength(1); + expect(messages[0].channel).toBe('mattermost'); + expect(messages[0].senderId).toBe('chan-1'); + expect(messages[0].metadata).toMatchObject({ + channelId: 'chan-1', + senderUserId: 'user-123', + }); + }); + + it('enforces channel allowlist and mention gating', async () => { + const adapter = new MattermostAdapter(baseConfig); + const messages: InboundMessage[] = []; + adapter.onMessage((msg) => messages.push(msg)); + + (adapter as unknown as { botUserId: string }).botUserId = 'bot-user'; + (adapter as unknown as { botUsername: string }).botUsername = 'flynnbot'; + + await (adapter as unknown as { + processPosts: (channelId: string, response: unknown) => Promise; + }).processPosts('chan-1', { + order: ['p1', 'p2'], + posts: { + p1: { + id: 'p1', + channel_id: 'chan-1', + user_id: 'user-1', + message: 'hello no mention', + create_at: 1000, + }, + p2: { + id: 'p2', + channel_id: 'chan-1', + user_id: 'user-1', + message: '@flynn hello yes mention', + create_at: 1001, + }, + }, + }); + + await (adapter as unknown as { + processPosts: (channelId: string, response: unknown) => Promise; + }).processPosts('chan-2', { + order: ['p3'], + posts: { + p3: { + id: 'p3', + channel_id: 'chan-2', + user_id: 'user-2', + message: '@flynn should be dropped by allowlist', + create_at: 1002, + }, + }, + }); + + expect(messages).toHaveLength(1); + expect(messages[0].text).toBe('hello yes mention'); + }); + + it('sets error status on connect failure and supports reconnect', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ error: 'bad token' }, 401)) + .mockResolvedValueOnce(jsonResponse({ id: 'bot-user', username: 'flynnbot' })) + .mockResolvedValueOnce(jsonResponse({ id: 'chan-1', type: 'O' })) + .mockResolvedValueOnce(jsonResponse({ order: [], posts: {} })); + + const adapter = new MattermostAdapter(baseConfig); + await expect(adapter.connect()).rejects.toThrow('Mattermost GET /api/v4/users/me failed'); + expect(adapter.status).toBe('error'); + + await adapter.connect(); + expect(adapter.status).toBe('connected'); + await adapter.disconnect(); + expect(adapter.status).toBe('disconnected'); + }); +}); diff --git a/src/channels/mattermost/adapter.ts b/src/channels/mattermost/adapter.ts new file mode 100644 index 0000000..7ae87b9 --- /dev/null +++ b/src/channels/mattermost/adapter.ts @@ -0,0 +1,332 @@ +import type { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + isAllowedByAllowlist, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from '../utils.js'; +import type { PairingManager } from '../pairing.js'; + +export interface MattermostAdapterConfig { + serverUrl: string; + botToken: string; + /** Allowed channel IDs for inbound processing. Empty/undefined = allow all channel IDs. */ + allowedChannelIds?: string[]; + /** Require mention in non-DM channels (default: true). */ + requireMention?: boolean; + /** Mention token used for mention detection/stripping (default: flynn). */ + mentionName?: string; + /** Poll interval for inbound post polling (default: 3000ms). */ + pollIntervalMs?: number; + /** Optional pairing manager for untrusted senders. */ + pairingManager?: PairingManager; +} + +interface MattermostUser { + id: string; + username: string; +} + +interface MattermostChannel { + id: string; + type?: string; +} + +interface MattermostPost { + id: string; + channel_id: string; + user_id: string; + message?: string; + create_at: number; + type?: string; +} + +interface MattermostPostsResponse { + order?: string[]; + posts?: Record; +} + +const DEFAULT_POLL_INTERVAL_MS = 3000; +const MAX_MESSAGE_LENGTH = 3500; + +export class MattermostAdapter implements ChannelAdapter { + readonly name = 'mattermost'; + + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + private readonly config: MattermostAdapterConfig; + private pollTimer: NodeJS.Timeout | null = null; + private polling = false; + private botUserId = ''; + private botUsername = ''; + private channelTypes = new Map(); + private channelCursorMs = new Map(); + + get status(): ChannelStatus { + return this._status; + } + + constructor(config: MattermostAdapterConfig) { + this.config = config; + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + async connect(): Promise { + this._status = 'connecting'; + try { + const me = await this.apiGet('/api/v4/users/me'); + this.botUserId = me.id; + this.botUsername = me.username; + + const now = Date.now(); + for (const channelId of this.config.allowedChannelIds ?? []) { + this.channelCursorMs.set(channelId, now); + try { + const channel = await this.apiGet(`/api/v4/channels/${channelId}`); + if (channel.type) { + this.channelTypes.set(channelId, channel.type); + } + } catch { + // Channel metadata fetch is best-effort; continue polling messages. + } + } + + const pollIntervalMs = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.pollTimer = setInterval(() => { + void this.pollOnce(); + }, pollIntervalMs); + void this.pollOnce(); + + this._status = 'connected'; + console.log(`Mattermost adapter connected as ${this.botUsername}`); + } catch (error) { + this._status = 'error'; + throw error; + } + } + + async disconnect(): Promise { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.polling = false; + this.botUserId = ''; + this.botUsername = ''; + this.channelTypes.clear(); + this.channelCursorMs.clear(); + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + if (this._status !== 'connected') { + throw new Error('Mattermost adapter not connected'); + } + + const text = (message.text ?? '').trim(); + if (text) { + const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; + for (const chunk of chunks) { + await this.postMessage(peerId, chunk, message.replyTo); + } + } + + if (message.attachments && message.attachments.length > 0) { + for (const a of message.attachments) { + if (a.url) { + const line = a.filename ? `${a.filename}: ${a.url}` : a.url; + await this.postMessage(peerId, line); + } else if (a.data) { + // Keep initial adapter implementation stable: only URL attachment echoes. + console.warn(`Mattermost: skipping attachment data (${a.mimeType}) — upload not implemented`); + } + } + } + } + + private async postMessage(channelId: string, text: string, rootId?: string): Promise { + if (!text.trim()) { + return; + } + await this.apiPost('/api/v4/posts', { + channel_id: channelId, + message: text, + ...(rootId ? { root_id: rootId } : {}), + }); + } + + private async pollOnce(): Promise { + if (this.polling || !this.messageHandler || this._status !== 'connected') { + return; + } + + const channels = this.config.allowedChannelIds ?? []; + if (channels.length === 0) { + return; + } + + this.polling = true; + try { + for (const channelId of channels) { + const since = this.channelCursorMs.get(channelId) ?? Date.now(); + const posts = await this.apiGet(`/api/v4/channels/${channelId}/posts?since=${since}`); + await this.processPosts(channelId, posts); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Mattermost polling failed: ${message}`); + } finally { + this.polling = false; + } + } + + private async processPosts(channelId: string, response: MattermostPostsResponse): Promise { + if (!this.messageHandler) { + return; + } + + const order = response.order ?? []; + const posts = response.posts ?? {}; + const sorted = order + .map((id) => posts[id]) + .filter((post): post is MattermostPost => Boolean(post)) + .sort((a, b) => a.create_at - b.create_at); + + let cursor = this.channelCursorMs.get(channelId) ?? Date.now(); + for (const post of sorted) { + cursor = Math.max(cursor, post.create_at + 1); + if (post.user_id === this.botUserId) { + continue; + } + + const rawText = (post.message ?? '').trim(); + if (!rawText || post.type?.startsWith('system_')) { + continue; + } + + const allowedByChannel = isAllowedByAllowlist(post.channel_id, this.config.allowedChannelIds); + const allowMessage = allowedByChannel || await allowTrustedOrPairedSender({ + pairingManager: this.config.pairingManager, + channel: 'mattermost', + senderId: post.user_id, + text: rawText, + isTrusted: false, + }); + if (!allowMessage) { + continue; + } + + const isDm = this.isDirectChannel(channelId); + const mentionsBot = this.isBotMentioned(rawText); + if (!isDm && shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot, + })) { + continue; + } + + const text = normalizeResetCommandText(this.stripMention(rawText).trim()); + if (!text) { + continue; + } + + if (text === '!reset') { + this.messageHandler(buildResetInboundMessage({ + id: post.id, + channel: 'mattermost', + senderId: post.channel_id, + senderName: post.user_id, + timestamp: post.create_at, + })); + continue; + } + + this.messageHandler({ + id: post.id, + channel: 'mattermost', + senderId: post.channel_id, + senderName: post.user_id, + text, + timestamp: post.create_at, + metadata: { + channelId: post.channel_id, + senderUserId: post.user_id, + }, + }); + } + + this.channelCursorMs.set(channelId, cursor); + } + + private isDirectChannel(channelId: string): boolean { + const t = this.channelTypes.get(channelId); + return t === 'D' || t === 'G'; + } + + private isBotMentioned(text: string): boolean { + const lower = text.toLowerCase(); + const mentionName = (this.config.mentionName ?? 'flynn').trim().toLowerCase(); + if (mentionName && lower.includes(`@${mentionName}`)) { + return true; + } + if (this.botUsername && lower.includes(`@${this.botUsername.toLowerCase()}`)) { + return true; + } + return false; + } + + private stripMention(text: string): string { + const mentionNames = [this.config.mentionName ?? 'flynn', this.botUsername].filter(Boolean); + let cleaned = text; + for (const name of mentionNames) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + cleaned = cleaned.replace(new RegExp(`^\\s*@${escaped}(?::|\\b)\\s*`, 'i'), ''); + } + return cleaned; + } + + private makeUrl(path: string): string { + const base = this.config.serverUrl.replace(/\/+$/, ''); + const suffix = path.startsWith('/') ? path : `/${path}`; + return `${base}${suffix}`; + } + + private async apiGet(path: string): Promise { + const res = await fetch(this.makeUrl(path), { + method: 'GET', + headers: { + Authorization: `Bearer ${this.config.botToken}`, + 'Content-Type': 'application/json', + }, + }); + if (!res.ok) { + throw new Error(`Mattermost GET ${path} failed (${res.status}): ${await res.text()}`); + } + return await res.json() as T; + } + + private async apiPost(path: string, body: Record): Promise { + const res = await fetch(this.makeUrl(path), { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.botToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`Mattermost POST ${path} failed (${res.status}): ${await res.text()}`); + } + } +} diff --git a/src/channels/mattermost/index.ts b/src/channels/mattermost/index.ts new file mode 100644 index 0000000..922c957 --- /dev/null +++ b/src/channels/mattermost/index.ts @@ -0,0 +1 @@ +export { MattermostAdapter, type MattermostAdapterConfig } from './adapter.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4d15df0..9494183 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -438,6 +438,38 @@ describe('configSchema — audio talk mode', () => { }); }); +describe('configSchema — mattermost', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts mattermost config and defaults optional fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + mattermost: { + server_url: 'https://mattermost.example.com', + bot_token: 'mm-token', + }, + }); + + expect(result.mattermost).toBeDefined(); + if (!result.mattermost) { + throw new Error('Expected mattermost config'); + } + expect(result.mattermost.server_url).toBe('https://mattermost.example.com'); + expect(result.mattermost.allowed_channel_ids).toEqual([]); + expect(result.mattermost.require_mention).toBe(true); + expect(result.mattermost.mention_name).toBe('flynn'); + expect(result.mattermost.poll_interval_ms).toBe(3000); + }); + + it('mattermost config is optional', () => { + const result = configSchema.parse(minimalConfig); + expect(result.mattermost).toBeUndefined(); + }); +}); + describe('configSchema — teams', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index b74f1db..8bba95f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -449,6 +449,15 @@ const signalSchema = z.object({ send_timeout_ms: z.number().min(1000).max(60000).default(15000), }).optional(); +const mattermostSchema = z.object({ + server_url: z.string().url('Mattermost server_url must be a valid URL'), + bot_token: z.string().min(1, 'Mattermost bot_token is required'), + allowed_channel_ids: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), + mention_name: z.string().default('flynn'), + poll_interval_ms: z.number().min(1000).max(60000).default(3000), +}).optional(); + const teamsSchema = z.object({ app_id: z.string().min(1, 'Teams app_id is required'), app_password: z.string().min(1, 'Teams app_password is required'), @@ -648,6 +657,7 @@ export const configSchema = z.object({ whatsapp: whatsappSchema, matrix: matrixSchema, signal: signalSchema, + mattermost: mattermostSchema, teams: teamsSchema, google_chat: googleChatSchema, bluebubbles: bluebubblesSchema, @@ -696,6 +706,7 @@ export type SlackConfig = z.infer; export type WhatsAppConfig = z.infer; export type MatrixConfig = z.infer; export type SignalConfig = z.infer; +export type MattermostConfig = z.infer; export type TeamsConfig = z.infer; export type GoogleChatConfig = z.infer; export type BlueBubblesConfig = z.infer; diff --git a/src/daemon/channels.test.ts b/src/daemon/channels.test.ts new file mode 100644 index 0000000..0c3e475 --- /dev/null +++ b/src/daemon/channels.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { configSchema } from '../config/schema.js'; +import { ChannelRegistry } from '../channels/index.js'; +import { HookEngine } from '../hooks/index.js'; +import { registerChannels } from './channels.js'; + +describe('registerChannels', () => { + it('registers Mattermost adapter when configured', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + mattermost: { + server_url: 'https://mattermost.example.com', + bot_token: 'mm-token', + allowed_channel_ids: ['chan-1'], + }, + }); + + const channelRegistry = new ChannelRegistry(); + const gateway = { + setWebhookHandler: vi.fn(), + setGmailHandler: vi.fn(), + setTeamsHandler: vi.fn(), + setGoogleChatHandler: vi.fn(), + setBlueBubblesHandler: vi.fn(), + }; + + registerChannels({ + config, + channelRegistry, + hookEngine: new HookEngine(config.hooks), + gateway: gateway as unknown as Parameters[0]['gateway'], + }); + + const names = channelRegistry.list().map((adapter) => adapter.name); + expect(names).toContain('mattermost'); + expect(names).toContain('webchat'); + }); +}); diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index aa3e114..f7fdd99 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,6 +1,6 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -101,6 +101,20 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { channelRegistry.register(signalAdapter); } + // Register Mattermost adapter (if configured) + if (config.mattermost) { + const mattermostAdapter = new MattermostAdapter({ + serverUrl: config.mattermost.server_url, + botToken: config.mattermost.bot_token, + allowedChannelIds: config.mattermost.allowed_channel_ids.length > 0 ? config.mattermost.allowed_channel_ids : undefined, + requireMention: config.mattermost.require_mention, + mentionName: config.mattermost.mention_name, + pollIntervalMs: config.mattermost.poll_interval_ms, + pairingManager, + }); + channelRegistry.register(mattermostAdapter); + } + // Register Microsoft Teams adapter (if configured) if (config.teams) { const teamsAdapter = new TeamsAdapter({ diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index 05a1654..62faff9 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -11,7 +11,7 @@ export interface ConfigHandlerDeps { * Redact sensitive values from config before returning. * Replaces API keys, tokens, passwords, and other credentials with "***". * - * Covers: telegram, discord, slack, matrix, server, models (tiers + fallbacks + local_providers), + * Covers: telegram, discord, slack, matrix, mattermost, server, models (tiers + fallbacks + local_providers), * web_search, audio, memory.embedding, automation (webhooks + gmail), and mcp server env vars. */ export function redactConfig(config: Config): Record { @@ -37,6 +37,9 @@ export function redactConfig(config: Config): Record { // Matrix redact(raw.matrix as Record, 'access_token'); + // Mattermost + redact(raw.mattermost as Record, 'bot_token'); + // Server (gateway bearer token) redact(raw.server as Record, 'token'); diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 43ebbed..b81fe09 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -891,6 +891,7 @@ describe('redactConfig – comprehensive credential redaction', () => { discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true }, slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false }, matrix: { homeserver_url: 'https://matrix.example.org', access_token: 'mx-secret', allowed_room_ids: ['!room1:example.org'], require_mention: true }, + mattermost: { server_url: 'https://mattermost.example.org', bot_token: 'mm-secret', allowed_channel_ids: [], require_mention: true, mention_name: 'flynn', poll_interval_ms: 3000 }, server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true }, models: { default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def', @@ -958,6 +959,11 @@ describe('redactConfig – comprehensive credential redaction', () => { expect(getPath(result, 'matrix', 'access_token')).toBe('***'); }); + it('redacts mattermost.bot_token', () => { + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'mattermost', 'bot_token')).toBe('***'); + }); + it('redacts server.token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'server', 'token')).toBe('***'); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index 7e9ebec..ef92020 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -31,6 +31,7 @@ function makeBaseConfig(): Config { whatsapp: undefined, matrix: undefined, signal: undefined, + mattermost: undefined, teams: undefined, google_chat: undefined, bluebubbles: undefined, @@ -48,6 +49,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'telegram', status: 'not_configured' }), expect.objectContaining({ name: 'matrix', status: 'not_configured' }), expect.objectContaining({ name: 'signal', status: 'not_configured' }), + expect.objectContaining({ name: 'mattermost', status: 'not_configured' }), expect.objectContaining({ name: 'teams', status: 'not_configured' }), expect.objectContaining({ name: 'google_chat', status: 'not_configured' }), expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index fb234d0..8d6e20d 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -54,6 +54,7 @@ export function discoverServices( { key: 'whatsapp', name: 'whatsapp', description: 'WhatsApp gateway' }, { key: 'matrix', name: 'matrix', description: 'Matrix bot' }, { key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' }, + { key: 'mattermost', name: 'mattermost', description: 'Mattermost bot' }, { key: 'teams', name: 'teams', description: 'Microsoft Teams bot' }, { key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' }, { key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' },