feat(channels): add mattermost adapter and wiring
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user