mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
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:
committed by
Muhammed Mukhthar CM
parent
68fe16e053
commit
5b69954070
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z
|
||||
})
|
||||
.optional(),
|
||||
commands: MattermostSlashCommandsSchema,
|
||||
interactions: z
|
||||
.object({
|
||||
callbackBaseUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
168
extensions/mattermost/src/mattermost/directory.ts
Normal file
168
extensions/mattermost/src/mattermost/directory.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user