feat(channels): add mattermost adapter and wiring

This commit is contained in:
William Valentin
2026-02-16 12:09:44 -08:00
parent 813a0dc5c5
commit de0c1f41b3
16 changed files with 645 additions and 6 deletions
+10 -1
View File
@@ -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://<your-flynn-host>/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"
+9
View File
@@ -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}
+1 -1
View File
@@ -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
+28 -2
View File
@@ -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",
+1
View File
@@ -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';
+153
View File
@@ -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<void>;
}).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<void>;
}).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<void>;
}).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');
});
});
+332
View File
@@ -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<string, MattermostPost>;
}
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<string, string>();
private channelCursorMs = new Map<string, number>();
get status(): ChannelStatus {
return this._status;
}
constructor(config: MattermostAdapterConfig) {
this.config = config;
}
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
async connect(): Promise<void> {
this._status = 'connecting';
try {
const me = await this.apiGet<MattermostUser>('/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<MattermostChannel>(`/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<void> {
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<void> {
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<void> {
if (!text.trim()) {
return;
}
await this.apiPost('/api/v4/posts', {
channel_id: channelId,
message: text,
...(rootId ? { root_id: rootId } : {}),
});
}
private async pollOnce(): Promise<void> {
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<MattermostPostsResponse>(`/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<void> {
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<T>(path: string): Promise<T> {
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<string, unknown>): Promise<void> {
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()}`);
}
}
}
+1
View File
@@ -0,0 +1 @@
export { MattermostAdapter, type MattermostAdapterConfig } from './adapter.js';
+32
View File
@@ -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] },
+11
View File
@@ -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<typeof slackSchema>;
export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
export type MatrixConfig = z.infer<typeof matrixSchema>;
export type SignalConfig = z.infer<typeof signalSchema>;
export type MattermostConfig = z.infer<typeof mattermostSchema>;
export type TeamsConfig = z.infer<typeof teamsSchema>;
export type GoogleChatConfig = z.infer<typeof googleChatSchema>;
export type BlueBubblesConfig = z.infer<typeof bluebubblesSchema>;
+39
View File
@@ -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<typeof registerChannels>[0]['gateway'],
});
const names = channelRegistry.list().map((adapter) => adapter.name);
expect(names).toContain('mattermost');
expect(names).toContain('webchat');
});
});
+15 -1
View File
@@ -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({
+4 -1
View File
@@ -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<string, unknown> {
@@ -37,6 +37,9 @@ export function redactConfig(config: Config): Record<string, unknown> {
// Matrix
redact(raw.matrix as Record<string, unknown>, 'access_token');
// Mattermost
redact(raw.mattermost as Record<string, unknown>, 'bot_token');
// Server (gateway bearer token)
redact(raw.server as Record<string, unknown>, 'token');
+6
View File
@@ -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('***');
+2
View File
@@ -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' }),
+1
View File
@@ -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' },