Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "chat" ADD COLUMN "mattermost_thread_id" text;--> statement-breakpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Postgres migration checks will fail for this new migration because the matching Drizzle meta snapshot/journal entry was not added. Regenerate or commit the 0056 snapshot and _journal.json update with this SQL migration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/migrations-postgres/0056_add_mattermost_channel.sql, line 1:

<comment>Postgres migration checks will fail for this new migration because the matching Drizzle meta snapshot/journal entry was not added. Regenerate or commit the 0056 snapshot and _journal.json update with this SQL migration.</comment>

<file context>
@@ -0,0 +1,3 @@
+ALTER TABLE "chat" ADD COLUMN "mattermost_thread_id" text;--> statement-breakpoint
+ALTER TABLE "project" ADD COLUMN "mattermost_settings" jsonb;--> statement-breakpoint
+CREATE INDEX "chat_mattermost_thread_idx" ON "chat" USING btree ("mattermost_thread_id");
</file context>

ALTER TABLE "project" ADD COLUMN "mattermost_settings" jsonb;--> statement-breakpoint
CREATE INDEX "chat_mattermost_thread_idx" ON "chat" USING btree ("mattermost_thread_id");
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE `chat` ADD `mattermost_thread_id` text;--> statement-breakpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: SQLite migration checks will fail because the new 0056 migration has no matching migrations-sqlite/meta/0056_snapshot.json or journal entry. Adding the generated Drizzle meta files alongside this SQL keeps CI and migration discovery in sync.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/migrations-sqlite/0056_add_mattermost_channel.sql, line 1:

<comment>SQLite migration checks will fail because the new 0056 migration has no matching `migrations-sqlite/meta/0056_snapshot.json` or journal entry. Adding the generated Drizzle meta files alongside this SQL keeps CI and migration discovery in sync.</comment>

<file context>
@@ -0,0 +1,3 @@
+ALTER TABLE `chat` ADD `mattermost_thread_id` text;--> statement-breakpoint
+CREATE INDEX `chat_mattermost_thread_idx` ON `chat` (`mattermost_thread_id`);--> statement-breakpoint
+ALTER TABLE `project` ADD `mattermost_settings` text;
</file context>

CREATE INDEX `chat_mattermost_thread_idx` ON `chat` (`mattermost_thread_id`);--> statement-breakpoint
ALTER TABLE `project` ADD `mattermost_settings` text;
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"better-auth": "^1.6.3",
"better-sqlite3": "^12.5.0",
"chat": "^4.15.0",
"chat-adapter-mattermost": "^1.1.3",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The Mattermost adapter requires chat@^4.29.0 as a peer dependency, but the project currently specifies chat@^4.15.0. If the resolved chat version is older than 4.29.0 the adapter may encounter missing APIs or behavioral changes at runtime. Consider bumping the project's chat dependency to ^4.29.0 to ensure compatibility with chat-adapter-mattermost@^1.1.3.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/package.json, line 81:

<comment>The Mattermost adapter requires chat@^4.29.0 as a peer dependency, but the project currently specifies chat@^4.15.0. If the resolved chat version is older than 4.29.0 the adapter may encounter missing APIs or behavioral changes at runtime. Consider bumping the project's chat dependency to ^4.29.0 to ensure compatibility with chat-adapter-mattermost@^1.1.3.</comment>

<file context>
@@ -78,6 +78,7 @@
 		"better-auth": "^1.6.3",
 		"better-sqlite3": "^12.5.0",
 		"chat": "^4.15.0",
+		"chat-adapter-mattermost": "^1.1.3",
 		"cheerio": "^1.2.0",
 		"cron-parser": "^5.5.0",
</file context>

"cheerio": "^1.2.0",
"cron-parser": "^5.5.0",
"dotenv": "^17.2.3",
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { imageRoutes } from './routes/image';
import { mcpOAuthRoutes } from './routes/mcp-oauth';
import { slackRoutes } from './routes/slack';
import { teamsRoutes } from './routes/teams';
import { mattermostRoutes } from './routes/mattermost';
import { telegramRoutes } from './routes/telegram';
import { testRoutes } from './routes/test';
import { whatsappRoutes } from './routes/whatsapp';
Expand All @@ -47,6 +48,7 @@ import { logLicenseStatus } from './services/license-startup';
import { pingLicensesServer } from './services/ping';
import { posthog, PostHogEvent } from './services/posthog';
import { ensureRecurring, registerJob, startScheduler } from './services/scheduler.service';
import { mattermostService } from './services/mattermost';
import { slackService } from './services/slack';
import { TrpcRouter, trpcRouter } from './trpc/router';
import { createContext } from './trpc/trpc';
Expand Down Expand Up @@ -195,6 +197,10 @@ app.register(teamsRoutes, {
prefix: '/api/webhooks/teams',
});

app.register(mattermostRoutes, {
prefix: '/api/webhooks/mattermost',
});

app.register(telegramRoutes, {
prefix: '/api/webhooks/telegram',
});
Expand Down Expand Up @@ -373,6 +379,7 @@ export const startServer = async (opts: { port: number; host: string }) => {

void pingLicensesServer();
void slackService.startSocketModeForAllProjects();
void mattermostService.startForAllProjects();

posthog.capture(undefined, PostHogEvent.ServerStarted, { ...opts, address });

Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/db/pg-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { LLM_INFERENCE_TYPES, type ModelSettingsMap } from '../types/llm';
import { LOG_LEVELS, LOG_SOURCES } from '../types/log';
import { McpEndpointSettings } from '../types/mcp-endpoint';
import { MEMORY_CATEGORIES } from '../types/memory';
import { SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider';
import { MattermostSettings, SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider';
import { ORG_ROLES } from '../types/organization';

export const user = pgTable('user', {
Expand Down Expand Up @@ -199,6 +199,7 @@ export const project = pgTable(
teamsSettings: jsonb('teams_settings').$type<TeamsSettings>(),
telegramSettings: jsonb('telegram_settings').$type<TelegramSettings>(),
whatsappSettings: jsonb('whatsapp_settings').$type<WhatsappSettings>(),
mattermostSettings: jsonb('mattermost_settings').$type<MattermostSettings>(),
mcpEndpointSettings: jsonb('mcp_endpoint_settings').$type<McpEndpointSettings>(),
displaySettings: jsonb('display_settings').$type<DisplaySettings>(),

Expand Down Expand Up @@ -255,6 +256,7 @@ export const chat = pgTable(
teamsThreadId: text('teams_thread_id'),
telegramThreadId: text('telegram_thread_id'),
whatsappThreadId: text('whatsapp_thread_id'),
mattermostThreadId: text('mattermost_thread_id'),
forkMetadata: jsonb('fork_metadata').$type<ForkMetadata>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
Expand All @@ -269,6 +271,7 @@ export const chat = pgTable(
index('chat_teams_thread_idx').on(table.teamsThreadId),
index('chat_telegram_thread_idx').on(table.telegramThreadId),
index('chat_whatsapp_thread_idx').on(table.whatsappThreadId),
index('chat_mattermost_thread_idx').on(table.mattermostThreadId),
],
);

Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/db/sqlite-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { LLM_INFERENCE_TYPES, type ModelSettingsMap } from '../types/llm';
import { LOG_LEVELS, LOG_SOURCES } from '../types/log';
import { McpEndpointSettings } from '../types/mcp-endpoint';
import { MEMORY_CATEGORIES } from '../types/memory';
import { SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider';
import { MattermostSettings, SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider';
import { ORG_ROLES } from '../types/organization';

export const user = sqliteTable('user', {
Expand Down Expand Up @@ -214,6 +214,7 @@ export const project = sqliteTable(
teamsSettings: text('teams_settings', { mode: 'json' }).$type<TeamsSettings>(),
telegramSettings: text('telegram_settings', { mode: 'json' }).$type<TelegramSettings>(),
whatsappSettings: text('whatsapp_settings', { mode: 'json' }).$type<WhatsappSettings>(),
mattermostSettings: text('mattermost_settings', { mode: 'json' }).$type<MattermostSettings>(),
mcpEndpointSettings: text('mcp_endpoint_settings', { mode: 'json' }).$type<McpEndpointSettings>(),
displaySettings: text('display_settings', { mode: 'json' }).$type<DisplaySettings>(),

Expand Down Expand Up @@ -274,6 +275,7 @@ export const chat = sqliteTable(
teamsThreadId: text('teams_thread_id'),
telegramThreadId: text('telegram_thread_id'),
whatsappThreadId: text('whatsapp_thread_id'),
mattermostThreadId: text('mattermost_thread_id'),
forkMetadata: text('fork_metadata', { mode: 'json' }).$type<ForkMetadata>(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
Expand All @@ -290,6 +292,7 @@ export const chat = sqliteTable(
index('chat_teams_thread_idx').on(table.teamsThreadId),
index('chat_telegram_thread_idx').on(table.telegramThreadId),
index('chat_whatsapp_thread_idx').on(table.whatsappThreadId),
index('chat_mattermost_thread_idx').on(table.mattermostThreadId),
],
);

Expand Down
10 changes: 10 additions & 0 deletions apps/backend/src/queries/chat.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,16 @@ export const getChatByTelegramThread = async (threadId: string): Promise<{ id: s
return result.at(0) || null;
};

export const getChatByMattermostThread = async (threadId: string): Promise<{ id: string; title: string } | null> => {
const result = await db
.select({ id: s.chat.id, title: s.chat.title })
.from(s.chat)
.where(eq(s.chat.mattermostThreadId, threadId))
.limit(1)
.execute();
return result.at(0) || null;
};

export const getChatByWhatsappThread = async (threadId: string): Promise<{ id: string; title: string } | null> => {
const result = await db
.select({ id: s.chat.id, title: s.chat.title })
Expand Down
134 changes: 134 additions & 0 deletions apps/backend/src/queries/project-mattermost-config.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { LlmProvider, LlmSelectedModel } from '@nao/shared/types';
import { eq } from 'drizzle-orm';

import s from '../db/abstractSchema';
import { db } from '../db/db';
import { env } from '../env';
import { llmProviderSchema } from '../types/llm';
import { MatterMostReplyMode } from '../types/messaging-provider';
import { takeFirstOrThrow } from '../utils/queries';

function toLlmSelectedModel(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This adds another copy of the same LLM model-selection parser used by other messaging config queries. Consider moving toLlmSelectedModel to a shared utility so provider parsing behavior stays consistent across connectors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/queries/project-mattermost-config.queries.ts, line 11:

<comment>This adds another copy of the same LLM model-selection parser used by other messaging config queries. Consider moving `toLlmSelectedModel` to a shared utility so provider parsing behavior stays consistent across connectors.</comment>

<file context>
@@ -0,0 +1,134 @@
+import { MatterMostReplyMode } from '../types/messaging-provider';
+import { takeFirstOrThrow } from '../utils/queries';
+
+function toLlmSelectedModel(
+	provider: string | null | undefined,
+	modelId: string | null | undefined,
</file context>

provider: string | null | undefined,
modelId: string | null | undefined,
): LlmSelectedModel | undefined {
if (!provider || !modelId) {
return undefined;
}
const parsed = llmProviderSchema.safeParse(provider);
return parsed.success ? { provider: parsed.data, modelId } : undefined;
}

export const listMattermostConfigs = async (): Promise<MatterMostConfig[]> => {
const projects = await db.select().from(s.project).execute();
const configs: MatterMostConfig[] = [];
for (const project of projects) {
const settings = project.mattermostSettings;
if (!settings?.mattermostBaseUrl || !settings?.mattermostBotToken) {
continue;
}
configs.push({
projectId: project.id,
baseUrl: settings.mattermostBaseUrl,
botToken: settings.mattermostBotToken,
redirectUrl: env.BETTER_AUTH_URL || 'http://localhost:3000/',
replyMode: 'thread',
modelSelection: toLlmSelectedModel(settings.mattermostLlmProvider, settings.mattermostLlmModelId),
});
}
return configs;
};

export const getProjectMattermostConfig = async (projectId: string): Promise<MatterMostConfig | null> => {
const [project] = await db.select().from(s.project).where(eq(s.project.id, projectId)).execute();
const settings = project?.mattermostSettings;

if (!settings?.mattermostBaseUrl || !settings?.mattermostBotToken) {
return null;
}

return {
projectId,
baseUrl: settings.mattermostBaseUrl,
botToken: settings.mattermostBotToken,
redirectUrl: env.BETTER_AUTH_URL || 'http://localhost:3000/',
replyMode: 'thread',
modelSelection: toLlmSelectedModel(settings.mattermostLlmProvider, settings.mattermostLlmModelId),
};
};

export const upsertProjectMattermostConfig = async (data: {
projectId: string;
baseUrl: string;
botToken: string;
modelProvider?: LlmProvider;
modelId?: string;
}): Promise<{
baseUrl: string;
botToken: string;
modelSelection?: LlmSelectedModel;
}> => {
const updated = await takeFirstOrThrow(
db
.update(s.project)
.set({
mattermostSettings: {
mattermostBaseUrl: data.baseUrl,
mattermostBotToken: data.botToken,
mattermostLlmProvider: data.modelProvider ?? '',
mattermostLlmModelId: data.modelId ?? '',
},
})
.where(eq(s.project.id, data.projectId))
.returning()
.execute(),
`Project not found: ${data.projectId}`,
);

const settings = updated.mattermostSettings;
return {
baseUrl: settings?.mattermostBaseUrl || '',
botToken: settings?.mattermostBotToken || '',
modelSelection: toLlmSelectedModel(settings?.mattermostLlmProvider, settings?.mattermostLlmModelId),
};
};

export const updateProjectMattermostModel = async (
projectId: string,
modelProvider: LlmProvider | null,
modelId: string | null,
): Promise<void> => {
await db.transaction(async (tx) => {
const project = await takeFirstOrThrow(
tx.select().from(s.project).where(eq(s.project.id, projectId)).execute(),
`Project not found: ${projectId}`,
);
const existing = project.mattermostSettings;

await tx
.update(s.project)
.set({
mattermostSettings: {
mattermostBaseUrl: existing?.mattermostBaseUrl ?? '',
mattermostBotToken: existing?.mattermostBotToken ?? '',
mattermostLlmProvider: modelProvider ?? '',
mattermostLlmModelId: modelId ?? '',
},
})
.where(eq(s.project.id, projectId))
.execute();
});
};

export const deleteProjectMattermostConfig = async (projectId: string): Promise<void> => {
await db.update(s.project).set({ mattermostSettings: null }).where(eq(s.project.id, projectId)).execute();
};

export interface MatterMostConfig {
projectId: string;
botToken: string;
baseUrl: string;
redirectUrl: string;
replyMode: MatterMostReplyMode;
modelSelection?: LlmSelectedModel;
}
35 changes: 35 additions & 0 deletions apps/backend/src/routes/mattermost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { App } from '../app';
import { getProjectMattermostConfig } from '../queries/project-mattermost-config.queries';
import { mattermostService } from '../services/mattermost';
import { convertHeaders } from '../utils/utils';

export const mattermostRoutes = async (app: App) => {
app.post('/:projectId', async (request, reply) => {
const { projectId } = request.params as { projectId: string };
const webRequest = new Request(`http://localhost${request.url}`, {
method: request.method,
headers: convertHeaders(request.headers),
body: JSON.stringify(request.body),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Mattermost outgoing webhooks configured as application/x-www-form-urlencoded can fail to parse or validate because this re-serializes Fastify's parsed body as JSON while preserving the original form content-type. Consider enabling rawBody like the Slack route and forwarding the exact raw payload, or updating the content-type to match the serialized body.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/routes/mattermost.ts, line 12:

<comment>Mattermost outgoing webhooks configured as `application/x-www-form-urlencoded` can fail to parse or validate because this re-serializes Fastify's parsed body as JSON while preserving the original form content-type. Consider enabling `rawBody` like the Slack route and forwarding the exact raw payload, or updating the content-type to match the serialized body.</comment>

<file context>
@@ -0,0 +1,35 @@
+		const webRequest = new Request(`http://localhost${request.url}`, {
+			method: request.method,
+			headers: convertHeaders(request.headers),
+			body: JSON.stringify(request.body),
+		});
+
</file context>

});

const mattermostConfig = await getProjectMattermostConfig(projectId);
if (!mattermostConfig) {
reply.status(200).send('OK');
return;
}

const webhooks = await mattermostService.getWebhooks(mattermostConfig);
if (!webhooks) {
reply.status(200).send('OK');
return;
}

const response = await webhooks.mattermost(webRequest, {
waitUntil: (task: Promise<unknown>) => task,
});

reply.status(response.status);
response.headers.forEach((value, key) => reply.header(key, value));
return reply.send(await response.text());
});
};
Loading
Loading