feat(channels): add mattermost adapter and wiring
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MattermostAdapter, type MattermostAdapterConfig } from './adapter.js';
|
||||
@@ -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] },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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('***');
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user