fix(mattermost): refine probe and onboarding flows

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:14 +00:00
parent 0a421d7409
commit e80c66a571
5 changed files with 163 additions and 86 deletions

View File

@@ -54,6 +54,25 @@ describe("mattermostPlugin", () => {
resetMattermostReactionBotUserCacheForTests();
});
const runReactAction = async (params: Record<string, unknown>, fetchMode: "add" | "remove") => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: fetchMode,
postId: "POST1",
emojiName: "thumbsup",
});
return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
return await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params,
cfg,
accountId: "default",
} as any);
});
};
it("exposes react when mattermost is configured", () => {
const cfg: OpenClawConfig = {
channels: {
@@ -152,51 +171,32 @@ describe("mattermostPlugin", () => {
});
it("handles react by calling Mattermost reactions API", async () => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params: { messageId: "POST1", emoji: "thumbsup" },
cfg,
accountId: "default",
} as any);
return result;
});
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
expect(result?.details).toEqual({});
});
it("only treats boolean remove flag as removal", async () => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params: { messageId: "POST1", emoji: "thumbsup", remove: "true" },
cfg,
accountId: "default",
} as any);
return result;
});
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: "true" },
"add",
);
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
});
it("removes reaction when remove flag is boolean true", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: true },
"remove",
);
expect(result?.content).toEqual([
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
]);
expect(result?.details).toEqual({});
});
});
describe("config", () => {

View File

@@ -58,7 +58,7 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string {
return `${normalized}/api/v4${suffix}`;
}
async function readMattermostError(res: Response): Promise<string> {
export async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const mockFetch = vi.fn<typeof fetch>();
describe("probeMattermost", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns baseUrl missing for empty base URL", async () => {
await expect(probeMattermost(" ", "token")).resolves.toEqual({
ok: false,
error: "baseUrl missing",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("normalizes base URL and returns bot info", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
expect(mockFetch).toHaveBeenCalledWith(
"https://mm.example.com/api/v4/users/me",
expect.objectContaining({
headers: { Authorization: "Bearer bot-token" },
}),
);
expect(result).toEqual(
expect.objectContaining({
ok: true,
status: 200,
bot: { id: "bot-1", username: "clawbot" },
}),
);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
});
it("returns API error details from JSON response", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "invalid auth token" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
}),
);
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 401,
error: "invalid auth token",
}),
);
});
it("falls back to statusText when error body is empty", async () => {
mockFetch.mockResolvedValueOnce(
new Response("", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
}),
);
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 403,
error: "Forbidden",
}),
);
});
it("returns fetch error when request throws", async () => {
mockFetch.mockRejectedValueOnce(new Error("network down"));
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: null,
error: "network down",
}),
);
});
});

View File

@@ -1,5 +1,5 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
export type MattermostProbe = BaseProbeResult & {
status?: number | null;
@@ -7,18 +7,6 @@ export type MattermostProbe = BaseProbeResult & {
bot?: MattermostUser;
};
async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) {
return data.message;
}
return JSON.stringify(data);
}
return await res.text();
}
export async function probeMattermost(
baseUrl: string,
botToken: string,

View File

@@ -22,6 +22,25 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
);
}
async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{
botToken: string;
baseUrl: string;
}> {
const botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { botToken, baseUrl };
}
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
@@ -90,18 +109,9 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else if (accountConfigured) {
const keep = await prompter.confirm({
@@ -109,32 +119,14 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (!keep) {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
if (botToken || baseUrl) {