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
+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';