feat(mattermost): add directory adapter, config schema, and channel tests

Port missing pieces from PR #18151:
- Directory adapter for channel/user name resolution (listGroups, listPeers)
- Config schema validation for interactions.callbackBaseUrl
- TypeScript types for interactions config
- Channel-level tests for send/buttons action support
- Fix listActions to include "send" alongside "react"
This commit is contained in:
Tony Dehnke
2026-02-21 12:29:48 +00:00
committed by Muhammed Mukhthar CM
parent 68fe16e053
commit 5b69954070
5 changed files with 209 additions and 13 deletions

View File

@@ -102,8 +102,9 @@ describe("mattermostPlugin", () => {
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).toContain("react");
expect(actions).not.toContain("send");
expect(actions).toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
});
it("hides react when mattermost is not configured", () => {
@@ -133,7 +134,7 @@ describe("mattermostPlugin", () => {
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("react");
expect(actions).not.toContain("send");
expect(actions).toContain("send");
});
it("respects per-account actions.reactions in listActions", () => {

View File

@@ -22,6 +22,10 @@ import {
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
listMattermostDirectoryGroups,
listMattermostDirectoryPeers,
} from "./mattermost/directory.js";
import {
buildButtonAttachments,
resolveInteractionCallbackUrl,
@@ -37,22 +41,30 @@ import { getMattermostRuntime } from "./runtime.js";
const mattermostMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = listMattermostAccountIds(cfg)
const enabledAccounts = listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
if (!hasReactionCapableAccount) {
return [];
const actions: string[] = [];
// Send (buttons) is available whenever there's at least one enabled account
if (enabledAccounts.length > 0) {
actions.push("send");
}
return ["react"];
// React requires per-account reactions config check
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
return actions;
},
supportsAction: ({ action }) => {
return action === "send" || action === "react";
@@ -331,6 +343,12 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
directory: {
listGroups: async (params) => listMattermostDirectoryGroups(params),
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
listPeers: async (params) => listMattermostDirectoryPeers(params),
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
},
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
targetResolver: {

View File

@@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z
})
.optional(),
commands: MattermostSlashCommandsSchema,
interactions: z
.object({
callbackBaseUrl: z.string().optional(),
})
.optional(),
})
.strict();

View File

@@ -0,0 +1,168 @@
import type { OpenClawConfig, ChannelDirectoryEntry, RuntimeEnv } from "openclaw/plugin-sdk";
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostMe,
type MattermostChannel,
type MattermostClient,
type MattermostUser,
} from "./client.js";
export type MattermostDirectoryParams = {
cfg: OpenClawConfig;
accountId?: string | null;
query?: string | null;
limit?: number | null;
runtime: RuntimeEnv;
};
function buildClient(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): MattermostClient | null {
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.botToken || !account.baseUrl) {
return null;
}
return createMattermostClient({ baseUrl: account.baseUrl, botToken: account.botToken });
}
/**
* Build clients from ALL enabled accounts (deduplicated by token).
*
* We always scan every account because:
* - Private channels are only visible to bots that are members
* - The requesting agent's account may have an expired/invalid token
*
* This means a single healthy bot token is enough for directory discovery.
*/
function buildClients(params: MattermostDirectoryParams): MattermostClient[] {
const accountIds = listMattermostAccountIds(params.cfg);
const seen = new Set<string>();
const clients: MattermostClient[] = [];
for (const id of accountIds) {
const client = buildClient({ cfg: params.cfg, accountId: id });
if (client && !seen.has(client.token)) {
seen.add(client.token);
clients.push(client);
}
}
return clients;
}
/**
* List channels (public + private) visible to any configured bot account.
*
* NOTE: Uses per_page=200 which covers most instances. Mattermost does not
* return a "has more" indicator, so very large instances (200+ channels per bot)
* may see incomplete results. Pagination can be added if needed.
*/
export async function listMattermostDirectoryGroups(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
const q = params.query?.trim().toLowerCase() || "";
const seenIds = new Set<string>();
const entries: ChannelDirectoryEntry[] = [];
for (const client of clients) {
try {
const me = await fetchMattermostMe(client);
const channels = await client.request<MattermostChannel[]>(
`/users/${me.id}/channels?per_page=200`,
);
for (const ch of channels) {
if (ch.type !== "O" && ch.type !== "P") continue;
if (seenIds.has(ch.id)) continue;
if (q) {
const name = (ch.name ?? "").toLowerCase();
const display = (ch.display_name ?? "").toLowerCase();
if (!name.includes(q) && !display.includes(q)) continue;
}
seenIds.add(ch.id);
entries.push({
kind: "group" as const,
id: `channel:${ch.id}`,
name: ch.name ?? undefined,
handle: ch.display_name ?? undefined,
});
}
} catch (err) {
// Token may be expired/revoked — skip this account and try others
console.debug?.(
"[mattermost-directory] listGroups: skipping account:",
(err as Error)?.message,
);
continue;
}
}
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
}
/**
* List team members as peer directory entries.
*
* Uses only the first available client since all bots in a team see the same
* user list (unlike channels where membership varies). Uses the first team
* returned — multi-team setups will only see members from that team.
*
* NOTE: per_page=200 for member listing; same pagination caveat as groups.
*/
export async function listMattermostDirectoryPeers(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
// All bots see the same user list, so one client suffices (unlike channels
// where private channel membership varies per bot).
const client = clients[0];
try {
const me = await fetchMattermostMe(client);
const teams = await client.request<{ id: string }[]>("/users/me/teams");
if (!teams.length) {
return [];
}
// Uses first team — multi-team setups may need iteration in the future
const teamId = teams[0].id;
const q = params.query?.trim().toLowerCase() || "";
let users: MattermostUser[];
if (q) {
users = await client.request<MattermostUser[]>("/users/search", {
method: "POST",
body: JSON.stringify({ term: q, team_id: teamId }),
});
} else {
const members = await client.request<{ user_id: string }[]>(
`/teams/${teamId}/members?per_page=200`,
);
const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id);
if (!userIds.length) {
return [];
}
users = await client.request<MattermostUser[]>("/users/ids", {
method: "POST",
body: JSON.stringify(userIds),
});
}
const entries = users
.filter((u) => u.id !== me.id)
.map((u) => ({
kind: "user" as const,
id: `user:${u.id}`,
name: u.username ?? undefined,
handle:
[u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined,
}));
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
} catch (err) {
console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message);
return [];
}
}

View File

@@ -70,6 +70,10 @@ export type MattermostAccountConfig = {
/** Explicit callback URL (e.g. behind reverse proxy). */
callbackUrl?: string;
};
interactions?: {
/** External base URL used for Mattermost interaction callbacks. */
callbackBaseUrl?: string;
};
};
export type MattermostConfig = {