import { loadConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { loadWebMedia } from "../web/media.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, createMattermostDirectChannel, createMattermostPost, fetchMattermostMe, fetchMattermostUserByUsername, normalizeMattermostBaseUrl, uploadMattermostFile, type MattermostUser, } from "./client.js"; export type MattermostSendOpts = { botToken?: string; baseUrl?: string; accountId?: string; mediaUrl?: string; replyToId?: string; }; export type MattermostSendResult = { messageId: string; channelId: string; }; type MattermostTarget = | { kind: "channel"; id: string } | { kind: "user"; id?: string; username?: string }; const botUserCache = new Map(); const userByNameCache = new Map(); function cacheKey(baseUrl: string, token: string): string { return `${baseUrl}::${token}`; } function normalizeMessage(text: string, mediaUrl?: string): string { const trimmed = text.trim(); const media = mediaUrl?.trim(); return [trimmed, media].filter(Boolean).join("\n"); } function isHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value); } function parseMattermostTarget(raw: string): MattermostTarget { const trimmed = raw.trim(); if (!trimmed) throw new Error("Recipient is required for Mattermost sends"); const lower = trimmed.toLowerCase(); if (lower.startsWith("channel:")) { const id = trimmed.slice("channel:".length).trim(); if (!id) throw new Error("Channel id is required for Mattermost sends"); return { kind: "channel", id }; } if (lower.startsWith("user:")) { const id = trimmed.slice("user:".length).trim(); if (!id) throw new Error("User id is required for Mattermost sends"); return { kind: "user", id }; } if (lower.startsWith("mattermost:")) { const id = trimmed.slice("mattermost:".length).trim(); if (!id) throw new Error("User id is required for Mattermost sends"); return { kind: "user", id }; } if (trimmed.startsWith("@")) { const username = trimmed.slice(1).trim(); if (!username) { throw new Error("Username is required for Mattermost sends"); } return { kind: "user", username }; } return { kind: "channel", id: trimmed }; } async function resolveBotUser(baseUrl: string, token: string): Promise { const key = cacheKey(baseUrl, token); const cached = botUserCache.get(key); if (cached) return cached; const client = createMattermostClient({ baseUrl, botToken: token }); const user = await fetchMattermostMe(client); botUserCache.set(key, user); return user; } async function resolveUserIdByUsername(params: { baseUrl: string; token: string; username: string; }): Promise { const { baseUrl, token, username } = params; const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`; const cached = userByNameCache.get(key); if (cached?.id) return cached.id; const client = createMattermostClient({ baseUrl, botToken: token }); const user = await fetchMattermostUserByUsername(client, username); userByNameCache.set(key, user); return user.id; } async function resolveTargetChannelId(params: { target: MattermostTarget; baseUrl: string; token: string; }): Promise { if (params.target.kind === "channel") return params.target.id; const userId = params.target.id ? params.target.id : await resolveUserIdByUsername({ baseUrl: params.baseUrl, token: params.token, username: params.target.username ?? "", }); const botUser = await resolveBotUser(params.baseUrl, params.token); const client = createMattermostClient({ baseUrl: params.baseUrl, botToken: params.token, }); const channel = await createMattermostDirectChannel(client, [botUser.id, userId]); return channel.id; } export async function sendMessageMattermost( to: string, text: string, opts: MattermostSendOpts = {}, ): Promise { const cfg = loadConfig(); const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, }); const token = opts.botToken?.trim() || account.botToken?.trim(); if (!token) { throw new Error( `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`, ); } const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl); if (!baseUrl) { throw new Error( `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`, ); } const target = parseMattermostTarget(to); const channelId = await resolveTargetChannelId({ target, baseUrl, token, }); const client = createMattermostClient({ baseUrl, botToken: token }); let message = text?.trim() ?? ""; let fileIds: string[] | undefined; let uploadError: Error | undefined; const mediaUrl = opts.mediaUrl?.trim(); if (mediaUrl) { try { const media = await loadWebMedia(mediaUrl); const fileInfo = await uploadMattermostFile(client, { channelId, buffer: media.buffer, fileName: media.fileName ?? "upload", contentType: media.contentType ?? undefined, }); fileIds = [fileInfo.id]; } catch (err) { uploadError = err instanceof Error ? err : new Error(String(err)); if (shouldLogVerbose()) { logVerbose( `mattermost send: media upload failed, falling back to URL text: ${String(err)}`, ); } message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : ""); } } if (!message && (!fileIds || fileIds.length === 0)) { if (uploadError) { throw new Error(`Mattermost media upload failed: ${uploadError.message}`); } throw new Error("Mattermost message is empty"); } const post = await createMattermostPost(client, { channelId, message, rootId: opts.replyToId, fileIds, }); recordChannelActivity({ channel: "mattermost", accountId: account.accountId, direction: "outbound", }); return { messageId: post.id ?? "unknown", channelId, }; }