mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 00:18:26 +00:00
feat: add Mattermost channel support
Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
This commit is contained in:
207
src/mattermost/send.ts
Normal file
207
src/mattermost/send.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
|
||||
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<MattermostUser> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<MattermostSendResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user