Files
openclaw/extensions/mattermost/src/channel.test.ts
goffern c1dcb015e0 fix(mattermost): react action rejects raw emoji glyphs (#125370)
* fix(mattermost): react action rejects raw emoji glyphs

The react message action forwarded the caller's emoji verbatim (only
stripping wrapping colons), so a raw Unicode glyph such as a thumbs-up
character was sent as the `emoji_name`. Mattermost's reaction API accepts
only emoji short names, so the server rejects a raw glyph and the
reaction never appears.

Add a Mattermost-local normalizer that maps the common glyphs to their
short name (skin-tone modifiers and variation selectors stripped before
lookup) and leaves unknown emoji unchanged, mirroring the Slack plugin's
existing handling.

* test(mattermost): cover glyph normalization through the react action boundary

The normalizer previously had only direct helper coverage. Drive a raw
thumbs-up glyph through handleAction for both add and remove so the
boundary that serializes emoji_name is exercised: on pre-fix code the
add request body would carry the raw glyph and the remove URL would
embed it, failing the existing thumbsup request expectations.

* fix(mattermost): preserve inherited custom emoji names

* fix(mattermost): preserve lint-safe emoji decoration ranges

* fix(mattermost): preserve skin tones when normalizing glyph reactions

Stripping Fitzpatrick modifiers before lookup silently turned a toned
reaction such as a medium-tone thumbs-up into the untoned 👍.
Mattermost names toned system emoji `<base>_<tone>_skin_tone` (verified
against SystemEmojis for all five modifier-capable mapped bases), so
compose that name when the base glyph is mapped and tone-capable,
mirroring the Slack plugin's modifier handling. Stray modifiers on
non-modifier bases still resolve to the base name, and unknown glyphs
still pass through unchanged.

---------

Co-authored-by: goffern <goffern@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-25 00:03:03 -07:00

2189 lines
67 KiB
TypeScript

// Mattermost tests cover channel plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createChannelMessageReplyPipeline } from "../runtime-api.js";
const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({
sendMessageMattermostMock: vi.fn(),
mockFetchGuard: vi.fn(async (p: { url: string; init?: RequestInit }) => {
const response = await globalThis.fetch(p.url, p.init);
return { response, release: async () => {}, finalUrl: p.url };
}),
}));
vi.mock("./mattermost/send.js", () => ({
sendMessageMattermost: sendMessageMattermostMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
string,
unknown
>;
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
});
import { mattermostPlugin } from "./channel.js";
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
requestUrl,
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.js";
describe("mattermost target classification", () => {
it("requires an explicit user namespace for direct targets", () => {
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "user:owner" })).toBe("direct");
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "channel:operators" })).toBe(
"channel",
);
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "ambiguous" })).toBeUndefined();
});
});
type MattermostHandleAction = NonNullable<
NonNullable<typeof mattermostPlugin.actions>["handleAction"]
>;
type MattermostActionContext = Parameters<MattermostHandleAction>[0];
type MattermostSendText = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendText"]>;
type MattermostSendTextParams = Parameters<MattermostSendText>[0];
type MattermostSendMedia = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendMedia"]>;
type MattermostSendMediaParams = Parameters<MattermostSendMedia>[0];
type MattermostRenderPresentation = NonNullable<
NonNullable<typeof mattermostPlugin.outbound>["renderPresentation"]
>;
type MattermostSendPayload = NonNullable<
NonNullable<typeof mattermostPlugin.outbound>["sendPayload"]
>;
function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[] {
return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
}
function requireMattermostNormalizeTarget() {
const normalize = mattermostPlugin.messaging?.normalizeTarget;
if (!normalize) {
throw new Error("mattermost messaging.normalizeTarget missing");
}
return normalize;
}
function requireMattermostTargetResolver() {
const resolveTarget = mattermostPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error("mattermost messaging.targetResolver.resolveTarget missing");
}
return resolveTarget;
}
function requireMattermostPairingNormalizer() {
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
if (!normalize) {
throw new Error("mattermost pairing.normalizeAllowEntry missing");
}
return normalize;
}
function requireMattermostReplyToModeResolver() {
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("mattermost threading.resolveReplyToMode missing");
}
return resolveReplyToMode;
}
function requireMattermostThreadTargetMatcher() {
const matchesToolContextTarget = mattermostPlugin.threading?.matchesToolContextTarget;
if (!matchesToolContextTarget) {
throw new Error("mattermost threading.matchesToolContextTarget missing");
}
return matchesToolContextTarget;
}
function requireMattermostSendText() {
const sendText = mattermostPlugin.outbound?.sendText;
if (!sendText) {
throw new Error("mattermost outbound.sendText missing");
}
return sendText;
}
function requireMattermostSendMedia() {
const sendMedia = mattermostPlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("mattermost outbound.sendMedia missing");
}
return sendMedia;
}
function requireMattermostChunker() {
const chunker = mattermostPlugin.outbound?.chunker;
if (!chunker) {
throw new Error("mattermost outbound.chunker missing");
}
return chunker;
}
function requireMattermostRenderPresentation(): MattermostRenderPresentation {
const renderPresentation = mattermostPlugin.outbound?.renderPresentation;
if (!renderPresentation) {
throw new Error("mattermost outbound.renderPresentation missing");
}
return renderPresentation;
}
function requireMattermostSendPayload(): MattermostSendPayload {
const sendPayload = mattermostPlugin.outbound?.sendPayload;
if (!sendPayload) {
throw new Error("mattermost outbound.sendPayload missing");
}
return sendPayload;
}
function createMattermostActionContext(
overrides: Partial<MattermostActionContext>,
): MattermostActionContext {
return {
channel: "mattermost",
action: "send",
params: {},
cfg: createMattermostTestConfig(),
...overrides,
};
}
function expectSingleMattermostSend(to: string, text: string): Record<string, unknown> {
expect(sendMessageMattermostMock).toHaveBeenCalledTimes(1);
const [call] = sendMessageMattermostMock.mock.calls;
if (!call) {
throw new Error("expected Mattermost send call");
}
const [actualTo, actualText, options] = call;
expect(actualTo).toBe(to);
expect(actualText).toBe(text);
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new Error("expected Mattermost send options object");
}
return options as Record<string, unknown>;
}
describe("mattermostPlugin", () => {
beforeEach(() => {
sendMessageMattermostMock.mockReset();
sendMessageMattermostMock.mockResolvedValue({
messageId: "post-1",
channelId: "channel-1",
});
});
it("opts into account-scoped config restarts", () => {
expect(mattermostPlugin.reload).toMatchObject({ accountScopedRestart: true });
});
it("keeps sibling resolution stable across named-account additions and edits", () => {
const before: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "first",
accounts: {
beta: {
baseUrl: "https://beta.example.com",
chatmode: "onmessage",
},
},
},
},
};
const afterAdd: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "first",
accounts: {
alpha: {
baseUrl: "https://alpha.example.com",
chatmode: "oncall",
},
beta: {
baseUrl: "https://beta.example.com",
chatmode: "onmessage",
},
},
},
},
};
const afterEdit: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "first",
accounts: {
alpha: {
baseUrl: "https://alpha-new.example.com",
chatmode: "onchar",
},
beta: {
baseUrl: "https://beta.example.com",
chatmode: "onmessage",
},
},
},
},
};
const expectedBeta = mattermostPlugin.config.resolveAccount(before, "beta");
expect(mattermostPlugin.config.resolveAccount(afterAdd, "beta")).toEqual(expectedBeta);
expect(mattermostPlugin.config.resolveAccount(afterEdit, "beta")).toEqual(expectedBeta);
});
describe("messaging", () => {
it("keeps @username targets", () => {
const normalize = requireMattermostNormalizeTarget();
expect(normalize("@Alice")).toBe("@Alice");
expect(normalize("@alice")).toBe("@alice");
});
it("normalizes spaced mattermost prefixes to user targets", () => {
const normalize = requireMattermostNormalizeTarget();
expect(normalize("mattermost:USER123")).toBe("user:USER123");
expect(normalize(" mattermost:USER123 ")).toBe("user:USER123");
});
});
describe("pairing", () => {
it("normalizes allowlist entries", () => {
const normalize = requireMattermostPairingNormalizer();
expect(normalize("@Alice")).toBe("alice");
expect(normalize("user:USER123")).toBe("user123");
expect(normalize(" @Alice ")).toBe("alice");
expect(normalize(" mattermost:USER123 ")).toBe("user123");
});
});
describe("threading", () => {
it("builds tool context from the effective Mattermost thread root", () => {
const buildToolContext = mattermostPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("mattermost threading.buildToolContext missing");
}
const hasRepliedRef = { value: false };
expect(
buildToolContext({
cfg: createMattermostTestConfig(),
accountId: "default",
context: {
To: "channel:C1",
ChatType: "channel",
CurrentMessageId: "child-1",
MessageThreadId: "root-1",
},
hasRepliedRef,
}),
).toEqual({
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "all",
hasRepliedRef,
sameChannelThreadRequired: true,
});
});
it.each(["first", "batched"] as const)(
"preserves %s mode when the current post starts the thread",
(replyToMode) => {
const buildToolContext = mattermostPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("mattermost threading.buildToolContext missing");
}
const context = buildToolContext({
cfg: {
channels: {
mattermost: {
replyToMode,
},
},
},
accountId: "default",
context: {
To: "channel:C1",
ChatType: "channel",
CurrentMessageId: "post-1",
MessageThreadId: "post-1",
},
});
expect(context?.replyToMode).toBe(replyToMode);
},
);
it("matches bare Mattermost channel ids against the active channel target", () => {
const matchesToolContextTarget = requireMattermostThreadTargetMatcher();
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(true);
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:kqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(false);
});
it("exposes the effective reply root as the transport thread", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
throw new Error("mattermost threading.resolveReplyTransport missing");
}
expect(
resolveReplyTransport({
cfg: {},
replyToId: "post-parent",
threadId: "other-thread",
}),
).toEqual({
replyToId: "other-thread",
threadId: "other-thread",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: true,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: false,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
threadId: 42,
}),
).toEqual({
replyToId: "42",
threadId: "42",
});
});
it("matches final delivery routing for existing threads and direct messages", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
throw new Error("mattermost threading.resolveReplyTransport missing");
}
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
threadId: "root-post",
replyDelivery: {
chatType: "channel",
replyToMode: "all",
},
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "other-root",
replyToIsExplicit: true,
threadId: "ambient-root",
replyDelivery: {
chatType: "channel",
replyToMode: "all",
},
}),
).toEqual({
replyToId: "other-root",
threadId: "other-root",
});
for (const replyToMode of ["first", "all", "batched"] as const) {
expect(
resolveReplyTransport({
cfg: {},
replyToId: "dm-post",
replyDelivery: {
chatType: "direct",
replyToMode,
},
}),
).toEqual({
replyToId: "dm-post",
threadId: "dm-post",
});
}
expect(
resolveReplyTransport({
cfg: {},
replyToId: "dm-post",
replyDelivery: {
chatType: "direct",
replyToMode: "off",
},
}),
).toEqual({
replyToId: null,
threadId: null,
});
});
it("extracts explicit and implicit send thread evidence", () => {
const extractToolSend = mattermostPlugin.actions?.extractToolSend;
if (!extractToolSend) {
throw new Error("mattermost actions.extractToolSend missing");
}
expect(
extractToolSend({
args: { action: "send", to: "channel:C1", replyTo: "root-1" },
}),
).toMatchObject({
to: "channel:C1",
threadId: "root-1",
});
expect(
extractToolSend({
args: { action: "send", to: "channel:C1" },
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
const extractToolSendResult = mattermostPlugin.actions?.extractToolSendResult;
if (!extractToolSendResult) {
throw new Error("mattermost actions.extractToolSendResult missing");
}
expect(
extractToolSendResult({
send: { to: "channel:C1" },
result: {
details: {
toolSend: {
to: "channel:C1",
threadId: "root-1",
},
},
},
}),
).toEqual({
to: "channel:C1",
threadId: "root-1",
});
expect(
extractToolSendResult({
send: { to: "user:U1" },
result: {
details: {
toolSend: {
to: "channel:DM1",
},
},
},
}),
).toEqual({
to: "user:U1",
});
});
it("resolves the active Mattermost root for same-channel sends", () => {
const resolveAutoThreadId = mattermostPlugin.threading?.resolveAutoThreadId;
if (!resolveAutoThreadId) {
throw new Error("mattermost threading.resolveAutoThreadId missing");
}
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
replyToId: "child-1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "off",
},
}),
).toBe("root-1");
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C2",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
replyToMode: "all",
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
currentThreadTs: "root-1",
replyToMode: "all",
},
}),
).toBe("root-1");
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
replyToId: "other-root",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "all",
},
}),
).toBe("other-root");
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "root-1",
replyToMode: "first",
hasRepliedRef: { value: true },
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "root-1",
replyToMode: "batched",
},
}),
).toBeUndefined();
});
it("uses replyToMode for channel messages and keeps direct messages off", () => {
const resolveReplyToMode = requireMattermostReplyToModeResolver();
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "channel",
}),
).toBe("all");
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const resolveReplyToMode = requireMattermostReplyToModeResolver();
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "alerts",
replyToMode: "off",
accounts: {
alerts: {
replyToMode: "all",
botToken: "alerts-token",
baseUrl: "https://alerts.example.com",
},
},
},
},
};
expect(
resolveReplyToMode({
cfg,
chatType: "channel",
}),
).toBe("all");
});
});
describe("messageActions", () => {
let reactionActionSequence = 0;
const runReactAction = async (
params: Record<string, unknown>,
fetchMode: "add" | "remove",
emojiName = "thumbsup",
) => {
const cfg = createMattermostTestConfig(`message-action-${++reactionActionSequence}`);
const fetchImpl = createMattermostReactionFetchMock({
mode: fetchMode,
postId: "POST1",
emojiName,
});
return await withMockedGlobalFetch(fetchImpl, async () => {
return await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params,
cfg,
accountId: "default",
conversationReadOrigin: "direct-operator",
}),
);
});
};
it("keeps message reads hidden until they are explicitly enabled", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
expect(actions).not.toContain("read");
expect(actions).toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
expect(mattermostPlugin.actions?.supportsAction?.({ action: "read" })).toBe(true);
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
});
it("hides react when mattermost is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toStrictEqual([]);
});
it("declares presentation capability for message sends", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
const discovery = mattermostPlugin.actions?.describeMessageTool?.({ cfg });
expect(discovery?.capabilities).toContain("presentation");
expect(discovery?.schema).toBeUndefined();
});
it("prepares supported sends for the core durable lifecycle", async () => {
const prepareSendPayload = mattermostPlugin.actions?.prepareSendPayload;
if (!prepareSendPayload) {
throw new Error("mattermost actions.prepareSendPayload missing");
}
const payload = { text: "report" };
const prepared = await prepareSendPayload({
ctx: createMattermostActionContext({
params: {
to: "channel:CHAN1",
message: "report",
filePath: "/tmp/workspace/report.md",
replyToId: "post-root",
},
}),
to: "channel:CHAN1",
payload,
replyToId: "post-root",
});
expect(prepared).toEqual({
text: "report",
mediaUrl: "/tmp/workspace/report.md",
mediaUrls: ["/tmp/workspace/report.md"],
});
});
it("carries provider attachment text through core payload delivery", async () => {
const prepareSendPayload = mattermostPlugin.actions?.prepareSendPayload;
if (!prepareSendPayload) {
throw new Error("mattermost actions.prepareSendPayload missing");
}
const prepared = await prepareSendPayload({
ctx: createMattermostActionContext({
params: {
to: "channel:CHAN1",
message: "report",
attachmentText: "native attachment",
},
}),
to: "channel:CHAN1",
payload: { text: "report" },
});
expect(prepared).toMatchObject({
channelData: { mattermost: { attachmentText: "native attachment" } },
});
await requireMattermostSendPayload()({
cfg: createMattermostTestConfig(),
to: "channel:CHAN1",
text: "report",
payload: prepared!,
});
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.attachmentText).toBe("native attachment");
});
it.each([
["buffer attachments", { buffer: "cmVwb3J0" }, "buffer/base64 payloads"],
[
"multiple attachments",
{ mediaUrls: ["https://example.com/one.png", "https://example.com/two.png"] },
"supports one attachment per message",
],
])("rejects unsupported %s before provider dispatch", async (_label, extraParams, error) => {
const prepareSendPayload = mattermostPlugin.actions?.prepareSendPayload;
if (!prepareSendPayload) {
throw new Error("mattermost actions.prepareSendPayload missing");
}
await expect(async () =>
prepareSendPayload({
ctx: createMattermostActionContext({
params: { to: "channel:CHAN1", message: "report", ...extraParams },
}),
to: "channel:CHAN1",
payload: { text: "report" },
}),
).rejects.toThrow(error);
expect(sendMessageMattermostMock).not.toHaveBeenCalled();
});
it("keeps read opt in when reactions are disabled", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).not.toContain("react");
expect(actions).not.toContain("read");
expect(actions).toContain("send");
});
it("exposes read when actions.messages is true", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token-placeholder",
baseUrl: "https://chat.example.com",
actions: { messages: true },
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toContain("read");
expect(actions).toContain("react");
expect(actions).toContain("send");
});
it("respects per-account actions.messages in message discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { messages: false },
accounts: {
default: {
enabled: true,
botToken: "test-token-placeholder",
baseUrl: "https://chat.example.com",
actions: { messages: true },
},
},
},
},
};
expect(getDescribedActions(cfg)).toContain("read");
});
it("respects per-account actions.reactions in message discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: true },
},
},
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
});
it("honors the selected Mattermost account during discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
work: {
enabled: true,
botToken: "work-token",
baseUrl: "https://chat.example.com",
actions: { reactions: true },
},
},
},
},
};
expect(getDescribedActions(cfg, "default")).toEqual(["send"]);
expect(getDescribedActions(cfg, "work")).toEqual(["send", "react"]);
});
it("blocks react when default account disables reactions and accountId is omitted", async () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: true },
accounts: {
default: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
},
},
};
await expect(
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params: { messageId: "POST1", emoji: "thumbsup" },
cfg,
}),
),
).rejects.toThrow("Mattermost reactions are disabled in config");
});
it("blocks read when the selected account disables messages", async () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { messages: true },
accounts: {
default: {
enabled: true,
botToken: "test-token-placeholder",
baseUrl: "https://chat.example.com",
actions: { messages: false },
},
},
},
},
};
const fetchImpl = vi.fn<typeof fetch>();
await expect(
withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "read",
params: { target: "channel:CURRENT" },
cfg,
accountId: "default",
conversationReadOrigin: "direct-operator",
}),
),
),
).rejects.toThrow("Mattermost message reads are disabled in config");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks read when actions.messages is not configured", async () => {
const cfg = createMattermostTestConfig(`read-disabled-${++reactionActionSequence}`);
const fetchImpl = vi.fn<typeof fetch>();
await expect(
withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "read",
params: { target: "channel:CURRENT" },
cfg,
accountId: "default",
conversationReadOrigin: "direct-operator",
}),
),
),
).rejects.toThrow("Mattermost message reads are disabled in config");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("reads posts into the shared JSON result with normalized timestamps", async () => {
const cfg = createMattermostTestConfig(`read-action-${++reactionActionSequence}`);
const mattermostConfig = cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config fixture");
}
mattermostConfig.actions = { messages: true };
const fetchImpl = vi.fn<typeof fetch>(async (input) => {
const url = requestUrl(input);
if (!url.includes("/api/v4/channels/CURRENT/posts?per_page=2")) {
throw new Error(`Unexpected Mattermost request: ${url}`);
}
return Response.json({
order: ["post-2", "post-1"],
posts: {
"post-1": { id: "post-1", message: "older", create_at: 1_700_000_001_000 },
"post-2": { id: "post-2", message: "newer", create_at: 1_700_000_002_000 },
},
});
});
const result = await withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "read",
params: { target: "channel:CURRENT", to: "channel:CURRENT", limit: 2 },
cfg,
accountId: "default",
requesterAccountId: "default",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelProvider: "mattermost",
currentChannelId: "channel:CURRENT",
},
}),
),
);
expect(result?.details).toMatchObject({
ok: true,
channelId: "CURRENT",
messages: [
{ id: "post-2", message: "newer", timestampMs: 1_700_000_002_000 },
{ id: "post-1", message: "older", timestampMs: 1_700_000_001_000 },
],
hasMore: false,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("rejects invalid read cursors and limits before provider access", async () => {
const cfg = createMattermostTestConfig(`read-validation-${++reactionActionSequence}`);
const mattermostConfig = cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config fixture");
}
mattermostConfig.actions = { messages: true };
const fetchImpl = vi.fn<typeof fetch>();
for (const params of [
{ target: "channel:CURRENT", before: "p1", after: "p2" },
{ target: "channel:CURRENT", limit: 0 },
]) {
await expect(
withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "read",
params,
cfg,
accountId: "default",
conversationReadOrigin: "direct-operator",
}),
),
),
).rejects.toThrow();
}
expect(fetchImpl).not.toHaveBeenCalled();
});
it("rejects a disabled account before provider access", async () => {
const cfg = createMattermostTestConfig(`disabled-reaction-${++reactionActionSequence}`);
const mattermostConfig = cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config fixture");
}
mattermostConfig.accounts = { default: { enabled: false } };
const fetchImpl = vi.fn<typeof fetch>();
await expect(
withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params: {
target: "channel:CHAN1",
to: "channel:CHAN1",
messageId: "POST1",
emoji: "thumbsup",
},
cfg,
accountId: "default",
conversationReadOrigin: "direct-operator",
}),
),
),
).rejects.toThrow('Mattermost account "default" is disabled');
expect(fetchImpl).not.toHaveBeenCalled();
});
it("rejects disabled accounts before opaque target resolution provider access", async () => {
const cfg = createMattermostTestConfig(`disabled-target-${++reactionActionSequence}`);
const mattermostConfig = cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config fixture");
}
mattermostConfig.accounts = { default: { enabled: false } };
const fetchImpl = vi.fn<typeof fetch>();
await expect(
withMockedGlobalFetch(fetchImpl, async () =>
requireMattermostTargetResolver()({
cfg,
accountId: "default",
input: "disabled12abcd1234abcd1234",
normalized: "disabled12abcd1234abcd1234",
}),
),
).rejects.toThrow('Mattermost account "default" is disabled');
expect(fetchImpl).not.toHaveBeenCalled();
});
it("handles react by calling Mattermost reactions API", async () => {
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
expect(result?.details).toStrictEqual({});
});
it.each([
{
label: "named channel add",
rawTarget: "#town-square",
resolvedTarget: "channel:CHAN1",
mode: "add" as const,
remove: false,
postChannelId: "CHAN1",
expectedText: "Reacted with :thumbsup: on POST1",
},
{
label: "named channel remove",
rawTarget: "#town-square",
resolvedTarget: "channel:CHAN1",
mode: "remove" as const,
remove: true,
postChannelId: "CHAN1",
expectedText: "Removed reaction :thumbsup: from POST1",
},
{
label: "named user add",
rawTarget: "@alice",
resolvedTarget: "user:PEER1",
mode: "add" as const,
remove: false,
postChannelId: "DMCHAN1",
channelType: "D",
channelName: "BOT123__PEER1",
expectedText: "Reacted with :thumbsup: on POST1",
},
{
label: "named user remove",
rawTarget: "@alice",
resolvedTarget: "user:PEER1",
mode: "remove" as const,
remove: true,
postChannelId: "DMCHAN1",
channelType: "D",
channelName: "BOT123__PEER1",
expectedText: "Removed reaction :thumbsup: from POST1",
},
])("uses the resolved target for $label", async (fixture) => {
const cfg = createMattermostTestConfig(`delegated-reaction-${++reactionActionSequence}`);
const fetchImpl = createMattermostReactionFetchMock({
mode: fixture.mode,
postId: "POST1",
postChannelId: fixture.postChannelId,
channelType: fixture.channelType,
channelName: fixture.channelName,
emojiName: "thumbsup",
});
const result = await withMockedGlobalFetch(fetchImpl, async () =>
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params: {
target: fixture.rawTarget,
to: fixture.resolvedTarget,
messageId: "POST1",
emoji: "thumbsup",
remove: fixture.remove,
},
cfg,
accountId: "default",
conversationReadOrigin: "delegated",
}),
),
);
expect(result?.content).toEqual([{ type: "text", text: fixture.expectedText }]);
});
it("only treats boolean remove flag as removal", async () => {
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).toStrictEqual({});
});
it("normalizes a raw emoji glyph through the react action boundary", async () => {
const result = await runReactAction({ messageId: "POST1", emoji: "👍" }, "add");
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
expect(result?.details).toStrictEqual({});
});
it("normalizes a raw emoji glyph when removing a reaction", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "👍", remove: true },
"remove",
);
expect(result?.content).toEqual([
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
]);
expect(result?.details).toStrictEqual({});
});
it("preserves the skin tone when adding a toned glyph reaction", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "👍🏽" },
"add",
"thumbsup_medium_skin_tone",
);
expect(result?.content).toEqual([
{ type: "text", text: "Reacted with :thumbsup_medium_skin_tone: on POST1" },
]);
expect(result?.details).toStrictEqual({});
});
it("preserves the skin tone when removing a toned glyph reaction", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "👍🏽", remove: true },
"remove",
"thumbsup_medium_skin_tone",
);
expect(result?.content).toEqual([
{ type: "text", text: "Removed reaction :thumbsup_medium_skin_tone: from POST1" },
]);
expect(result?.details).toStrictEqual({});
});
it("maps replyTo to replyToId for send actions", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyTo: "post-root",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.accountId).toBe("default");
expect(options.replyToId).toBe("post-root");
});
it("uses threadId as the Mattermost root when generic replyTo names a child post", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
threadId: "post-root",
replyTo: "child-post",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("post-root");
});
it("keeps explicit replyToId precedence when threadId is also provided", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: "explicit-root",
threadId: "post-root",
replyTo: "child-post",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("explicit-root");
});
it("routes filePath send actions through Mattermost media upload options", async () => {
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
filePath: "/tmp/workspace/report.md",
},
cfg,
accountId: "default",
mediaLocalRoots: ["/tmp/workspace"],
mediaReadFile,
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.mediaUrl).toBe("/tmp/workspace/report.md");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.mediaReadFile).toBe(mediaReadFile);
expect(options.requireMediaUpload).toBe(true);
});
it("preserves workspaceDir for relative filePath send actions", async () => {
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
filePath: "report.md",
},
cfg,
accountId: "default",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
workspaceDir: "/tmp/workspace",
},
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.mediaUrl).toBe("report.md");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.mediaReadFile).toBe(mediaReadFile);
expect(options.workspaceDir).toBe("/tmp/workspace");
expect(options.requireMediaUpload).toBe(true);
});
it("routes structured attachment send actions through Mattermost media upload options", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
attachments: [{ filePath: "/tmp/workspace/report.md" }],
},
cfg,
accountId: "default",
mediaLocalRoots: ["/tmp/workspace"],
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.mediaUrl).toBe("/tmp/workspace/report.md");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.requireMediaUpload).toBe(true);
});
it("routes media_urls send actions through Mattermost media upload options", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
media_urls: ["/tmp/workspace/report.md"],
},
cfg,
accountId: "default",
mediaLocalRoots: ["/tmp/workspace"],
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.mediaUrl).toBe("/tmp/workspace/report.md");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.requireMediaUpload).toBe(true);
});
it("preserves HTTP media send fallback behavior", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
mediaUrl: "https://example.com/report.md",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "report");
expect(options.mediaUrl).toBe("https://example.com/report.md");
expect(options.requireMediaUpload).toBeUndefined();
});
it("rejects multiple Mattermost send attachments instead of dropping extras", async () => {
const cfg = createMattermostTestConfig();
await expect(
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "reports",
media_urls: ["/tmp/workspace/one.md", "/tmp/workspace/two.md"],
},
cfg,
accountId: "default",
mediaLocalRoots: ["/tmp/workspace"],
}),
),
).rejects.toThrow("supports one attachment per message");
expect(sendMessageMattermostMock).not.toHaveBeenCalled();
});
it.each(["buffer", "base64"] as const)(
"rejects unsupported %s-only Mattermost send attachments",
async (field) => {
const cfg = createMattermostTestConfig();
await expect(
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
[field]: "cmVwb3J0",
filename: "report.md",
},
cfg,
accountId: "default",
}),
),
).rejects.toThrow("buffer/base64 payloads are not supported");
expect(sendMessageMattermostMock).not.toHaveBeenCalled();
},
);
it.each([
{ location: "top-level", params: { buffer: "", base64: " " } },
{
location: "nested",
params: { attachments: [{ buffer: "", base64: " " }] },
},
])("ignores blank $location attachment payload fields", async ({ params }) => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "plain text",
...params,
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "plain text");
expect(options.mediaUrl).toBeUndefined();
});
it("rejects mixed supported and unsupported Mattermost send attachments", async () => {
const cfg = createMattermostTestConfig();
await expect(
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "report",
attachments: [
{ filePath: "/tmp/workspace/report.md" },
{ buffer: "cmVwb3J0", filename: "report-copy.md" },
],
},
cfg,
accountId: "default",
mediaLocalRoots: ["/tmp/workspace"],
}),
),
).rejects.toThrow("buffer/base64 payloads are not supported");
expect(sendMessageMattermostMock).not.toHaveBeenCalled();
});
it("maps legacy presentation buttons without using interactive conversion", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Deploy finished",
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Open",
value: "open",
style: "primary",
},
{ label: "Docs", url: "https://example.com/docs" },
],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend(
"channel:CHAN1",
"Deploy finished\n\n- Open\n- Docs: https://example.com/docs",
);
expect(options.buttons).toStrictEqual([
[
{
id: "open",
text: "Open",
callback_data: "open",
context: { callback_data: "open" },
style: "primary",
},
],
]);
});
it("does not render callback action buttons that Mattermost cannot round-trip", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Pick",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Inspect", action: { type: "callback", value: "inspect" } }],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "Pick\n\n- Inspect");
expect(options.buttons).toBeUndefined();
});
it("does not render command action buttons that Mattermost cannot execute", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Pick",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Plugins", action: { type: "command", command: "/codex" } }],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "Pick\n\n- Plugins: `/codex`");
expect(options.buttons).toBeUndefined();
});
it("keeps unsupported select commands actionable without exposing callback values", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Pick",
presentation: {
blocks: [
{
type: "select",
placeholder: "Environment",
options: [
{
label: "Production",
action: { type: "command", command: "/deploy production" },
},
{
label: "Opaque",
action: { type: "callback", value: "private-callback-token" },
},
],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend(
"channel:CHAN1",
"Pick\n\nEnvironment:\n- Production: `/deploy production`\n- Opaque",
);
expect(options.buttons).toBeUndefined();
});
it("falls back to trimmed replyTo when replyToId is blank", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: " ",
replyTo: " post-root ",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.accountId).toBe("default");
expect(options.replyToId).toBe("post-root");
});
});
describe("outbound", () => {
it.each([
{
name: "text",
send: async (onDeliveryResult: MattermostSendTextParams["onDeliveryResult"]) =>
await requireMattermostSendText()({
cfg: createMattermostTestConfig(),
to: "channel:CHAN1",
text: "provider-final",
onDeliveryResult,
}),
},
{
name: "media",
send: async (onDeliveryResult: MattermostSendMediaParams["onDeliveryResult"]) =>
await requireMattermostSendMedia()({
cfg: createMattermostTestConfig(),
to: "channel:CHAN1",
text: "provider-final",
mediaUrl: "https://example.com/report.png",
onDeliveryResult,
}),
},
{
name: "payload",
send: async (onDeliveryResult: MattermostSendTextParams["onDeliveryResult"]) =>
await requireMattermostSendPayload()({
cfg: createMattermostTestConfig(),
to: "channel:CHAN1",
text: "provider-final",
payload: {
text: "provider-final",
channelData: {
mattermost: {
attachmentText: "attachment",
},
},
},
onDeliveryResult,
}),
},
])("reports $name provider progress before a later bookkeeping failure", async ({ send }) => {
const onDeliveryResult = vi.fn();
sendMessageMattermostMock.mockImplementationOnce(
async (_to: string, _text: string, options: Record<string, unknown>) => {
const report = options.onDeliveryResult as
| ((result: Record<string, unknown>) => Promise<void>)
| undefined;
await report?.({
messageId: "post-final",
channelId: "CHAN1",
content: "provider-final",
});
throw new Error("activity store unavailable");
},
);
await expect(send(onDeliveryResult)).rejects.toThrow("activity store unavailable");
expect(onDeliveryResult).toHaveBeenCalledTimes(1);
expect(onDeliveryResult).toHaveBeenCalledWith({
channel: "mattermost",
messageId: "post-final",
target: { kind: "channel", id: "CHAN1" },
content: "provider-final",
});
});
it("renders presentation buttons for normal reply payload delivery", async () => {
const renderPresentation = requireMattermostRenderPresentation();
const sendPayload = requireMattermostSendPayload();
const cfg = createMattermostTestConfig();
const presentation = {
blocks: [
{ type: "text" as const, text: "Deploy finished" },
{
type: "buttons" as const,
buttons: [
{ label: "Open", value: "open", style: "primary" as const },
{ label: "Docs", url: "https://example.com/docs" },
],
},
],
};
const rendered = await renderPresentation({
payload: { presentation },
presentation,
ctx: {
cfg,
to: "channel:CHAN1",
text: "",
payload: { presentation },
},
});
expect(rendered).toMatchObject({
text: "Deploy finished\n\n- Open\n- Docs: https://example.com/docs",
channelData: {
mattermost: {
presentationButtons: [[{ text: "Open", callback_data: "open", style: "primary" }]],
},
},
});
await sendPayload({
cfg,
to: "channel:CHAN1",
text: "",
payload: rendered!,
});
const options = expectSingleMattermostSend(
"channel:CHAN1",
"Deploy finished\n\n- Open\n- Docs: https://example.com/docs",
);
expect(options.buttons).toStrictEqual([
[
{
id: "open",
text: "Open",
callback_data: "open",
context: { callback_data: "open" },
style: "primary",
},
],
]);
});
it("keeps typed URL actions on the normal Mattermost text delivery path", async () => {
const renderPresentation = requireMattermostRenderPresentation();
const sendPayload = requireMattermostSendPayload();
const cfg = createMattermostTestConfig();
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [
{
label: "Review",
action: {
type: "url" as const,
url: "https://example.com/review",
},
},
{
label: "Open app",
action: {
type: "web-app" as const,
url: "https://example.com/app",
},
},
{
label: "Allow",
action: {
type: "approval" as const,
approvalId: "approval-1",
approvalKind: "exec" as const,
decision: "allow-once" as const,
},
value: "/approve approval-1 allow-once",
},
],
},
],
};
const payload = { presentation };
const rendered = await renderPresentation({
payload,
presentation,
ctx: {
cfg,
to: "channel:CHAN1",
text: "",
payload,
},
});
expect(rendered).toMatchObject({
text: "- Review: https://example.com/review\n- Open app: https://example.com/app\n- Allow",
});
expect(rendered?.channelData?.mattermost).toBeUndefined();
await sendPayload({
cfg,
to: "channel:CHAN1",
text: rendered?.text ?? "",
payload: rendered!,
});
const options = expectSingleMattermostSend(
"channel:CHAN1",
"- Review: https://example.com/review\n- Open app: https://example.com/app\n- Allow",
);
expect(options.buttons).toBeUndefined();
expect(JSON.stringify(options)).not.toContain("approval-1");
expect(JSON.stringify(options)).not.toContain("/approve");
});
it("skips hosted widget actions without a Mattermost URL", async () => {
const renderPresentation = requireMattermostRenderPresentation();
const cfg = createMattermostTestConfig();
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [
{
label: "Hosted widget",
action: {
type: "web-app" as const,
widgetId: "AAAAAAAAAAAAAAAAAAAAAA",
},
},
],
},
],
};
expect(
await renderPresentation({
payload: { presentation },
presentation,
ctx: {
cfg,
to: "channel:CHAN1",
text: "",
payload: { presentation },
},
}),
).toBeNull();
});
it("requires upload success for local media on presentation button payloads", async () => {
const renderPresentation = requireMattermostRenderPresentation();
const sendPayload = requireMattermostSendPayload();
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Open", value: "open" }],
},
],
};
const rendered = await renderPresentation({
payload: { presentation, mediaUrl: "report.png" },
presentation,
ctx: {
cfg,
to: "channel:CHAN1",
text: "",
payload: { presentation, mediaUrl: "report.png" },
},
});
await sendPayload({
cfg,
to: "channel:CHAN1",
text: "",
payload: rendered!,
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
workspaceDir: "/tmp/workspace",
},
});
const options = expectSingleMattermostSend("channel:CHAN1", "- Open");
expect(options.mediaUrl).toBe("report.png");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.mediaReadFile).toBe(mediaReadFile);
expect(options.workspaceDir).toBe("/tmp/workspace");
expect(options.requireMediaUpload).toBe(true);
});
it("keeps multi-media presentation payloads on the text/media fallback path", async () => {
const renderPresentation = requireMattermostRenderPresentation();
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Open", value: "open" }],
},
],
};
expect(
await renderPresentation({
payload: {
presentation,
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
},
presentation,
ctx: {
cfg: createMattermostTestConfig(),
to: "channel:CHAN1",
text: "",
payload: { presentation },
},
}),
).toBeNull();
});
it("chunks outbound text without requiring Mattermost runtime initialization", () => {
const chunker = requireMattermostChunker();
expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
});
it("forwards mediaLocalRoots on sendMedia", async () => {
const sendMedia = requireMattermostSendMedia();
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const params: MattermostSendMediaParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
mediaReadFile,
accountId: "default",
replyToId: "post-root",
};
await sendMedia(params);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.mediaUrl).toBe("/tmp/workspace/image.png");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.mediaReadFile).toBe(mediaReadFile);
expect(options.requireMediaUpload).toBe(true);
});
it("falls back to structured mediaAccess on sendMedia", async () => {
const sendMedia = requireMattermostSendMedia();
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
await sendMedia({
cfg,
to: "channel:CHAN1",
text: "hello",
mediaUrl: "image.png",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
workspaceDir: "/tmp/workspace",
},
});
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.mediaUrl).toBe("image.png");
expect(options.mediaLocalRoots).toStrictEqual(["/tmp/workspace"]);
expect(options.mediaReadFile).toBe(mediaReadFile);
expect(options.workspaceDir).toBe("/tmp/workspace");
expect(options.requireMediaUpload).toBe(true);
});
it("threads resolved cfg on sendText", async () => {
const sendText = requireMattermostSendText();
const cfg = {
channels: {
mattermost: {
botToken: "resolved-bot-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig;
const params: MattermostSendTextParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
accountId: "default",
};
await sendText(params);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.cfg).toBe(cfg);
expect(options.accountId).toBe("default");
});
it("uses threadId as fallback when replyToId is absent (sendText)", async () => {
const sendText = requireMattermostSendText();
const cfg = createMattermostTestConfig();
const params: MattermostSendTextParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
accountId: "default",
threadId: "post-root",
};
await sendText(params);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.accountId).toBe("default");
expect(options.replyToId).toBe("post-root");
});
it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => {
const sendMedia = requireMattermostSendMedia();
const cfg = createMattermostTestConfig();
const params: MattermostSendMediaParams = {
cfg,
to: "channel:CHAN1",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
threadId: "post-root",
};
await sendMedia(params);
const options = expectSingleMattermostSend("channel:CHAN1", "caption");
expect(options.accountId).toBe("default");
expect(options.replyToId).toBe("post-root");
expect(options.requireMediaUpload).toBeUndefined();
});
});
describe("config", () => {
it("formats allowFrom entries", () => {
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!;
const formatted = formatAllowFrom({
cfg: {} as OpenClawConfig,
allowFrom: [" @Alice ", " user:USER123 ", " mattermost:BOT999 "],
});
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
});
it("uses account responsePrefix overrides", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
responsePrefix: "[Channel]",
accounts: {
default: { responsePrefix: "[Account]" },
},
},
},
};
const prefixContext = createChannelMessageReplyPipeline({
cfg,
agentId: "main",
channel: "mattermost",
accountId: "default",
});
expect(prefixContext.responsePrefix).toBe("[Account]");
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */