feat(mattermost): add interactive buttons support (#19957)
Merged via squash. Prepared head SHA: 8a25e608729d0b9fd07bb0ee4219d199d9796dbe Co-authored-by: tonydehnke <36720180+tonydehnke@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm
This commit is contained in:
@@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
|
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
|
||||||
- LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
|
- LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
|
||||||
- LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
|
- LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
|
||||||
|
- Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.
|
||||||
|
|
||||||
## 2026.3.2
|
## 2026.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,151 @@ Config:
|
|||||||
- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true).
|
- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true).
|
||||||
- Per-account override: `channels.mattermost.accounts.<id>.actions.reactions`.
|
- Per-account override: `channels.mattermost.accounts.<id>.actions.reactions`.
|
||||||
|
|
||||||
|
## Interactive buttons (message tool)
|
||||||
|
|
||||||
|
Send messages with clickable buttons. When a user clicks a button, the agent receives the
|
||||||
|
selection and can respond.
|
||||||
|
|
||||||
|
Enable buttons by adding `inlineButtons` to the channel capabilities:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
mattermost: {
|
||||||
|
capabilities: ["inlineButtons"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `message action=send` with a `buttons` parameter. Buttons are a 2D array (rows of buttons):
|
||||||
|
|
||||||
|
```
|
||||||
|
message action=send channel=mattermost target=channel:<channelId> buttons=[[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Button fields:
|
||||||
|
|
||||||
|
- `text` (required): display label.
|
||||||
|
- `callback_data` (required): value sent back on click (used as the action ID).
|
||||||
|
- `style` (optional): `"default"`, `"primary"`, or `"danger"`.
|
||||||
|
|
||||||
|
When a user clicks a button:
|
||||||
|
|
||||||
|
1. All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user").
|
||||||
|
2. The agent receives the selection as an inbound message and responds.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Button callbacks use HMAC-SHA256 verification (automatic, no config needed).
|
||||||
|
- Mattermost strips callback data from its API responses (security feature), so all buttons
|
||||||
|
are removed on click — partial removal is not possible.
|
||||||
|
- Action IDs containing hyphens or underscores are sanitized automatically
|
||||||
|
(Mattermost routing limitation).
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to
|
||||||
|
enable the buttons tool description in the agent system prompt.
|
||||||
|
|
||||||
|
### Direct API integration (external scripts)
|
||||||
|
|
||||||
|
External scripts and webhooks can post buttons directly via the Mattermost REST API
|
||||||
|
instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from
|
||||||
|
the extension when possible; if posting raw JSON, follow these rules:
|
||||||
|
|
||||||
|
**Payload structure:**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channel_id: "<channelId>",
|
||||||
|
message: "Choose an option:",
|
||||||
|
props: {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "mybutton01", // alphanumeric only — see below
|
||||||
|
type: "button", // required, or clicks are silently ignored
|
||||||
|
name: "Approve", // display label
|
||||||
|
style: "primary", // optional: "default", "primary", "danger"
|
||||||
|
integration: {
|
||||||
|
url: "http://localhost:18789/mattermost/interactions/default",
|
||||||
|
context: {
|
||||||
|
action_id: "mybutton01", // must match button id (for name lookup)
|
||||||
|
action: "approve",
|
||||||
|
// ... any custom fields ...
|
||||||
|
_token: "<hmac>", // see HMAC section below
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical rules:**
|
||||||
|
|
||||||
|
1. Attachments go in `props.attachments`, not top-level `attachments` (silently ignored).
|
||||||
|
2. Every action needs `type: "button"` — without it, clicks are swallowed silently.
|
||||||
|
3. Every action needs an `id` field — Mattermost ignores actions without IDs.
|
||||||
|
4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break
|
||||||
|
Mattermost's server-side action routing (returns 404). Strip them before use.
|
||||||
|
5. `context.action_id` must match the button's `id` so the confirmation message shows the
|
||||||
|
button name (e.g., "Approve") instead of a raw ID.
|
||||||
|
6. `context.action_id` is required — the interaction handler returns 400 without it.
|
||||||
|
|
||||||
|
**HMAC token generation:**
|
||||||
|
|
||||||
|
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens
|
||||||
|
that match the gateway's verification logic:
|
||||||
|
|
||||||
|
1. Derive the secret from the bot token:
|
||||||
|
`HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)`
|
||||||
|
2. Build the context object with all fields **except** `_token`.
|
||||||
|
3. Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify`
|
||||||
|
with sorted keys, which produces compact output).
|
||||||
|
4. Sign: `HMAC-SHA256(key=secret, data=serializedContext)`
|
||||||
|
5. Add the resulting hex digest as `_token` in the context.
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac, hashlib, json
|
||||||
|
|
||||||
|
secret = hmac.new(
|
||||||
|
b"openclaw-mattermost-interactions",
|
||||||
|
bot_token.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
ctx = {"action_id": "mybutton01", "action": "approve"}
|
||||||
|
payload = json.dumps(ctx, sort_keys=True, separators=(",", ":"))
|
||||||
|
token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
context = {**ctx, "_token": token}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common HMAC pitfalls:
|
||||||
|
|
||||||
|
- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use
|
||||||
|
`separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`).
|
||||||
|
- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then
|
||||||
|
signs everything remaining. Signing a subset causes silent verification failure.
|
||||||
|
- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may
|
||||||
|
reorder context fields when storing the payload.
|
||||||
|
- Derive the secret from the bot token (deterministic), not random bytes. The secret
|
||||||
|
must be the same across the process that creates buttons and the gateway that verifies.
|
||||||
|
|
||||||
|
## Directory adapter
|
||||||
|
|
||||||
|
The Mattermost plugin includes a directory adapter that resolves channel and user names
|
||||||
|
via the Mattermost API. This enables `#channel-name` and `@username` targets in
|
||||||
|
`openclaw message send` and cron/webhook deliveries.
|
||||||
|
|
||||||
|
No configuration is needed — the adapter uses the bot token from the account config.
|
||||||
|
|
||||||
## Multi-account
|
## Multi-account
|
||||||
|
|
||||||
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
|
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
|
||||||
@@ -197,3 +342,10 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`:
|
|||||||
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
|
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
|
||||||
- Auth errors: check the bot token, base URL, and whether the account is enabled.
|
- Auth errors: check the bot token, base URL, and whether the account is enabled.
|
||||||
- Multi-account issues: env vars only apply to the `default` account.
|
- Multi-account issues: env vars only apply to the `default` account.
|
||||||
|
- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields.
|
||||||
|
- Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings.
|
||||||
|
- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only.
|
||||||
|
- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above.
|
||||||
|
- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload.
|
||||||
|
- Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value.
|
||||||
|
- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config.
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ describe("mattermostPlugin", () => {
|
|||||||
|
|
||||||
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
|
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
|
||||||
expect(actions).toContain("react");
|
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: "react" })).toBe(true);
|
||||||
|
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides react when mattermost is not configured", () => {
|
it("hides react when mattermost is not configured", () => {
|
||||||
@@ -133,7 +134,7 @@ describe("mattermostPlugin", () => {
|
|||||||
|
|
||||||
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
|
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
|
||||||
expect(actions).not.toContain("react");
|
expect(actions).not.toContain("react");
|
||||||
expect(actions).not.toContain("send");
|
expect(actions).toContain("send");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects per-account actions.reactions in listActions", () => {
|
it("respects per-account actions.reactions in listActions", () => {
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ import {
|
|||||||
type ResolvedMattermostAccount,
|
type ResolvedMattermostAccount,
|
||||||
} from "./mattermost/accounts.js";
|
} from "./mattermost/accounts.js";
|
||||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||||
|
import {
|
||||||
|
listMattermostDirectoryGroups,
|
||||||
|
listMattermostDirectoryPeers,
|
||||||
|
} from "./mattermost/directory.js";
|
||||||
|
import {
|
||||||
|
buildButtonAttachments,
|
||||||
|
resolveInteractionCallbackUrl,
|
||||||
|
setInteractionSecret,
|
||||||
|
} from "./mattermost/interactions.js";
|
||||||
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||||
import { probeMattermost } from "./mattermost/probe.js";
|
import { probeMattermost } from "./mattermost/probe.js";
|
||||||
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||||
@@ -32,62 +41,91 @@ import { getMattermostRuntime } from "./runtime.js";
|
|||||||
|
|
||||||
const mattermostMessageActions: ChannelMessageActionAdapter = {
|
const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
|
const enabledAccounts = listMattermostAccountIds(cfg)
|
||||||
const baseReactions = actionsConfig?.reactions;
|
|
||||||
const hasReactionCapableAccount = listMattermostAccountIds(cfg)
|
|
||||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled)
|
.filter((account) => account.enabled)
|
||||||
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
|
.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;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasReactionCapableAccount) {
|
const actions: ChannelMessageActionName[] = [];
|
||||||
return [];
|
|
||||||
|
// 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 }) => {
|
supportsAction: ({ action }) => {
|
||||||
return action === "react";
|
return action === "send" || action === "react";
|
||||||
|
},
|
||||||
|
supportsButtons: ({ cfg }) => {
|
||||||
|
const accounts = listMattermostAccountIds(cfg)
|
||||||
|
.map((id) => resolveMattermostAccount({ cfg, accountId: id }))
|
||||||
|
.filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim());
|
||||||
|
return accounts.length > 0;
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
if (action !== "react") {
|
if (action === "react") {
|
||||||
throw new Error(`Mattermost action ${action} not supported`);
|
// Check reactions gate: per-account config takes precedence over base config
|
||||||
}
|
const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
|
||||||
// Check reactions gate: per-account config takes precedence over base config
|
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||||
const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
|
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
|
||||||
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
|
const acctConfig = accounts?.[resolvedAccountId];
|
||||||
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
|
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
|
||||||
const acctConfig = accounts?.[resolvedAccountId];
|
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
|
||||||
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
|
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
|
||||||
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
|
if (!reactionsEnabled) {
|
||||||
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
|
throw new Error("Mattermost reactions are disabled in config");
|
||||||
if (!reactionsEnabled) {
|
}
|
||||||
throw new Error("Mattermost reactions are disabled in config");
|
|
||||||
}
|
|
||||||
|
|
||||||
const postIdRaw =
|
const postIdRaw =
|
||||||
typeof (params as any)?.messageId === "string"
|
typeof (params as any)?.messageId === "string"
|
||||||
? (params as any).messageId
|
? (params as any).messageId
|
||||||
: typeof (params as any)?.postId === "string"
|
: typeof (params as any)?.postId === "string"
|
||||||
? (params as any).postId
|
? (params as any).postId
|
||||||
: "";
|
: "";
|
||||||
const postId = postIdRaw.trim();
|
const postId = postIdRaw.trim();
|
||||||
if (!postId) {
|
if (!postId) {
|
||||||
throw new Error("Mattermost react requires messageId (post id)");
|
throw new Error("Mattermost react requires messageId (post id)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
|
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
|
||||||
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
|
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
|
||||||
if (!emojiName) {
|
if (!emojiName) {
|
||||||
throw new Error("Mattermost react requires emoji");
|
throw new Error("Mattermost react requires emoji");
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = (params as any)?.remove === true;
|
const remove = (params as any)?.remove === true;
|
||||||
if (remove) {
|
if (remove) {
|
||||||
const result = await removeMattermostReaction({
|
const result = await removeMattermostReaction({
|
||||||
|
cfg,
|
||||||
|
postId,
|
||||||
|
emojiName,
|
||||||
|
accountId: resolvedAccountId,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
|
||||||
|
],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await addMattermostReaction({
|
||||||
cfg,
|
cfg,
|
||||||
postId,
|
postId,
|
||||||
emojiName,
|
emojiName,
|
||||||
@@ -96,26 +134,92 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
|
||||||
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
|
|
||||||
],
|
|
||||||
details: {},
|
details: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await addMattermostReaction({
|
if (action !== "send") {
|
||||||
cfg,
|
throw new Error(`Unsupported Mattermost action: ${action}`);
|
||||||
postId,
|
|
||||||
emojiName,
|
|
||||||
accountId: resolvedAccountId,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send action with optional interactive buttons
|
||||||
|
const to =
|
||||||
|
typeof params.to === "string"
|
||||||
|
? params.to.trim()
|
||||||
|
: typeof params.target === "string"
|
||||||
|
? params.target.trim()
|
||||||
|
: "";
|
||||||
|
if (!to) {
|
||||||
|
throw new Error("Mattermost send requires a target (to).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof params.message === "string" ? params.message : "";
|
||||||
|
const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined;
|
||||||
|
const resolvedAccountId = accountId || undefined;
|
||||||
|
|
||||||
|
// Build props with button attachments if buttons are provided
|
||||||
|
let props: Record<string, unknown> | undefined;
|
||||||
|
if (params.buttons && Array.isArray(params.buttons)) {
|
||||||
|
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
|
||||||
|
if (account.botToken) setInteractionSecret(account.accountId, account.botToken);
|
||||||
|
const callbackUrl = resolveInteractionCallbackUrl(account.accountId, cfg);
|
||||||
|
|
||||||
|
// Flatten 2D array (rows of buttons) to 1D — core schema sends Array<Array<Button>>
|
||||||
|
// but Mattermost doesn't have row layout, so we flatten all rows into a single list.
|
||||||
|
// Also supports 1D arrays for backward compatibility.
|
||||||
|
const rawButtons = (params.buttons as Array<unknown>).flatMap((item) =>
|
||||||
|
Array.isArray(item) ? item : [item],
|
||||||
|
) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const buttons = rawButtons
|
||||||
|
.map((btn) => ({
|
||||||
|
id: String(btn.id ?? btn.callback_data ?? ""),
|
||||||
|
name: String(btn.text ?? btn.name ?? btn.label ?? ""),
|
||||||
|
style: (btn.style as "default" | "primary" | "danger") ?? "default",
|
||||||
|
context:
|
||||||
|
typeof btn.context === "object" && btn.context !== null
|
||||||
|
? (btn.context as Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
.filter((btn) => btn.id && btn.name);
|
||||||
|
|
||||||
|
const attachmentText =
|
||||||
|
typeof params.attachmentText === "string" ? params.attachmentText : undefined;
|
||||||
|
props = {
|
||||||
|
attachments: buildButtonAttachments({
|
||||||
|
callbackUrl,
|
||||||
|
accountId: account.accountId,
|
||||||
|
buttons,
|
||||||
|
text: attachmentText,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl =
|
||||||
|
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
|
||||||
|
|
||||||
|
const result = await sendMessageMattermost(to, message, {
|
||||||
|
accountId: resolvedAccountId,
|
||||||
|
replyToId,
|
||||||
|
props,
|
||||||
|
mediaUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
channel: "mattermost",
|
||||||
|
messageId: result.messageId,
|
||||||
|
channelId: result.channelId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
details: {},
|
details: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -249,6 +353,12 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||||
},
|
},
|
||||||
actions: mattermostMessageActions,
|
actions: mattermostMessageActions,
|
||||||
|
directory: {
|
||||||
|
listGroups: async (params) => listMattermostDirectoryGroups(params),
|
||||||
|
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
|
||||||
|
listPeers: async (params) => listMattermostDirectoryPeers(params),
|
||||||
|
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
|
||||||
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
commands: MattermostSlashCommandsSchema,
|
commands: MattermostSlashCommandsSchema,
|
||||||
|
interactions: z
|
||||||
|
.object({
|
||||||
|
callbackBaseUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,298 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createMattermostClient } from "./client.js";
|
import {
|
||||||
|
createMattermostClient,
|
||||||
|
createMattermostPost,
|
||||||
|
normalizeMattermostBaseUrl,
|
||||||
|
updateMattermostPost,
|
||||||
|
} from "./client.js";
|
||||||
|
|
||||||
describe("mattermost client", () => {
|
// ── Helper: mock fetch that captures requests ────────────────────────
|
||||||
it("request returns undefined on 204 responses", async () => {
|
|
||||||
|
function createMockFetch(response?: { status?: number; body?: unknown; contentType?: string }) {
|
||||||
|
const status = response?.status ?? 200;
|
||||||
|
const body = response?.body ?? {};
|
||||||
|
const contentType = response?.contentType ?? "application/json";
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
|
||||||
|
const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const urlStr = typeof url === "string" ? url : url.toString();
|
||||||
|
calls.push({ url: urlStr, init });
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": contentType },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { mockFetch: mockFetch as unknown as typeof fetch, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── normalizeMattermostBaseUrl ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("normalizeMattermostBaseUrl", () => {
|
||||||
|
it("strips trailing slashes", () => {
|
||||||
|
expect(normalizeMattermostBaseUrl("http://localhost:8065/")).toBe("http://localhost:8065");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips /api/v4 suffix", () => {
|
||||||
|
expect(normalizeMattermostBaseUrl("http://localhost:8065/api/v4")).toBe(
|
||||||
|
"http://localhost:8065",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty input", () => {
|
||||||
|
expect(normalizeMattermostBaseUrl("")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostBaseUrl(null)).toBeUndefined();
|
||||||
|
expect(normalizeMattermostBaseUrl(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves valid base URL", () => {
|
||||||
|
expect(normalizeMattermostBaseUrl("http://mm.example.com")).toBe("http://mm.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createMattermostClient ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createMattermostClient", () => {
|
||||||
|
it("creates a client with normalized baseUrl", () => {
|
||||||
|
const { mockFetch } = createMockFetch();
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065/",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
expect(client.baseUrl).toBe("http://localhost:8065");
|
||||||
|
expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty baseUrl", () => {
|
||||||
|
expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow(
|
||||||
|
"baseUrl is required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends Authorization header with Bearer token", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "my-secret-token",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
await client.request("/users/me");
|
||||||
|
const headers = new Headers(calls[0].init?.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer my-secret-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets Content-Type for string bodies", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) });
|
||||||
|
const headers = new Headers(calls[0].init?.headers);
|
||||||
|
expect(headers.get("Content-Type")).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok responses", async () => {
|
||||||
|
const { mockFetch } = createMockFetch({
|
||||||
|
status: 404,
|
||||||
|
body: { message: "Not Found" },
|
||||||
|
});
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined on 204 responses", async () => {
|
||||||
const fetchImpl = vi.fn(async () => {
|
const fetchImpl = vi.fn(async () => {
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = createMattermostClient({
|
const client = createMattermostClient({
|
||||||
baseUrl: "https://chat.example.com",
|
baseUrl: "https://chat.example.com",
|
||||||
botToken: "test-token",
|
botToken: "test-token",
|
||||||
fetchImpl: fetchImpl as any,
|
fetchImpl: fetchImpl as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await client.request<unknown>("/anything", { method: "DELETE" });
|
const result = await client.request<unknown>("/anything", { method: "DELETE" });
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── createMattermostPost ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createMattermostPost", () => {
|
||||||
|
it("sends channel_id and message", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "Hello world",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.channel_id).toBe("ch123");
|
||||||
|
expect(body.message).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes rootId when provided", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post2" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "Reply",
|
||||||
|
rootId: "root456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.root_id).toBe("root456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes fileIds when provided", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post3" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "With file",
|
||||||
|
fileIds: ["file1", "file2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.file_ids).toEqual(["file1", "file2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes props when provided (for interactive buttons)", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post4" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
text: "Choose:",
|
||||||
|
actions: [{ id: "btn1", type: "button", name: "Click" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "Pick an option",
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.props).toEqual(props);
|
||||||
|
expect(body.props.attachments[0].actions[0].type).toBe("button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits props when not provided", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "No props",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.props).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── updateMattermostPost ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("updateMattermostPost", () => {
|
||||||
|
it("sends PUT to /posts/{id}", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateMattermostPost(client, "post1", { message: "Updated" });
|
||||||
|
|
||||||
|
expect(calls[0].url).toContain("/posts/post1");
|
||||||
|
expect(calls[0].init?.method).toBe("PUT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes post id in the body", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateMattermostPost(client, "post1", { message: "Updated" });
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.id).toBe("post1");
|
||||||
|
expect(body.message).toBe("Updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes props for button completion updates", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateMattermostPost(client, "post1", {
|
||||||
|
message: "Original message",
|
||||||
|
props: {
|
||||||
|
attachments: [{ text: "✓ **do_now** selected by @tony" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.message).toBe("Original message");
|
||||||
|
expect(body.props.attachments[0].text).toContain("✓");
|
||||||
|
expect(body.props.attachments[0].text).toContain("do_now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits message when not provided", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateMattermostPost(client, "post1", {
|
||||||
|
props: { attachments: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init?.body as string);
|
||||||
|
expect(body.id).toBe("post1");
|
||||||
|
expect(body.message).toBeUndefined();
|
||||||
|
expect(body.props).toEqual({ attachments: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ export async function fetchMattermostChannel(
|
|||||||
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchMattermostChannelByName(
|
||||||
|
client: MattermostClient,
|
||||||
|
teamId: string,
|
||||||
|
channelName: string,
|
||||||
|
): Promise<MattermostChannel> {
|
||||||
|
return await client.request<MattermostChannel>(
|
||||||
|
`/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMattermostTyping(
|
export async function sendMattermostTyping(
|
||||||
client: MattermostClient,
|
client: MattermostClient,
|
||||||
params: { channelId: string; parentId?: string },
|
params: { channelId: string; parentId?: string },
|
||||||
@@ -172,9 +182,10 @@ export async function createMattermostPost(
|
|||||||
message: string;
|
message: string;
|
||||||
rootId?: string;
|
rootId?: string;
|
||||||
fileIds?: string[];
|
fileIds?: string[];
|
||||||
|
props?: Record<string, unknown>;
|
||||||
},
|
},
|
||||||
): Promise<MattermostPost> {
|
): Promise<MattermostPost> {
|
||||||
const payload: Record<string, string> = {
|
const payload: Record<string, unknown> = {
|
||||||
channel_id: params.channelId,
|
channel_id: params.channelId,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
};
|
};
|
||||||
@@ -182,7 +193,10 @@ export async function createMattermostPost(
|
|||||||
payload.root_id = params.rootId;
|
payload.root_id = params.rootId;
|
||||||
}
|
}
|
||||||
if (params.fileIds?.length) {
|
if (params.fileIds?.length) {
|
||||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
payload.file_ids = params.fileIds;
|
||||||
|
}
|
||||||
|
if (params.props) {
|
||||||
|
payload.props = params.props;
|
||||||
}
|
}
|
||||||
return await client.request<MattermostPost>("/posts", {
|
return await client.request<MattermostPost>("/posts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -203,6 +217,27 @@ export async function fetchMattermostUserTeams(
|
|||||||
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
|
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMattermostPost(
|
||||||
|
client: MattermostClient,
|
||||||
|
postId: string,
|
||||||
|
params: {
|
||||||
|
message?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<MattermostPost> {
|
||||||
|
const payload: Record<string, unknown> = { id: postId };
|
||||||
|
if (params.message !== undefined) {
|
||||||
|
payload.message = params.message;
|
||||||
|
}
|
||||||
|
if (params.props !== undefined) {
|
||||||
|
payload.props = params.props;
|
||||||
|
}
|
||||||
|
return await client.request<MattermostPost>(`/posts/${postId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadMattermostFile(
|
export async function uploadMattermostFile(
|
||||||
client: MattermostClient,
|
client: MattermostClient,
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
172
extensions/mattermost/src/mattermost/directory.ts
Normal file
172
extensions/mattermost/src/mattermost/directory.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import type {
|
||||||
|
ChannelDirectoryEntry,
|
||||||
|
OpenClawConfig,
|
||||||
|
RuntimeEnv,
|
||||||
|
} from "openclaw/plugin-sdk/mattermost";
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
335
extensions/mattermost/src/mattermost/interactions.test.ts
Normal file
335
extensions/mattermost/src/mattermost/interactions.test.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { type IncomingMessage } from "node:http";
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
buildButtonAttachments,
|
||||||
|
generateInteractionToken,
|
||||||
|
getInteractionCallbackUrl,
|
||||||
|
getInteractionSecret,
|
||||||
|
isLocalhostRequest,
|
||||||
|
resolveInteractionCallbackUrl,
|
||||||
|
setInteractionCallbackUrl,
|
||||||
|
setInteractionSecret,
|
||||||
|
verifyInteractionToken,
|
||||||
|
} from "./interactions.js";
|
||||||
|
|
||||||
|
// ── HMAC token management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("setInteractionSecret / getInteractionSecret", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setInteractionSecret("test-bot-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives a deterministic secret from the bot token", () => {
|
||||||
|
setInteractionSecret("token-a");
|
||||||
|
const secretA = getInteractionSecret();
|
||||||
|
setInteractionSecret("token-a");
|
||||||
|
const secretA2 = getInteractionSecret();
|
||||||
|
expect(secretA).toBe(secretA2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different secrets for different tokens", () => {
|
||||||
|
setInteractionSecret("token-a");
|
||||||
|
const secretA = getInteractionSecret();
|
||||||
|
setInteractionSecret("token-b");
|
||||||
|
const secretB = getInteractionSecret();
|
||||||
|
expect(secretA).not.toBe(secretB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a hex string", () => {
|
||||||
|
expect(getInteractionSecret()).toMatch(/^[0-9a-f]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Token generation / verification ──────────────────────────────────
|
||||||
|
|
||||||
|
describe("generateInteractionToken / verifyInteractionToken", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setInteractionSecret("test-bot-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a hex token", () => {
|
||||||
|
const token = generateInteractionToken({ action_id: "click" });
|
||||||
|
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a valid token", () => {
|
||||||
|
const context = { action_id: "do_now", item_id: "123" };
|
||||||
|
const token = generateInteractionToken(context);
|
||||||
|
expect(verifyInteractionToken(context, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tampered token", () => {
|
||||||
|
const context = { action_id: "do_now" };
|
||||||
|
const token = generateInteractionToken(context);
|
||||||
|
const tampered = token.replace(/.$/, token.endsWith("0") ? "1" : "0");
|
||||||
|
expect(verifyInteractionToken(context, tampered)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token generated with different context", () => {
|
||||||
|
const token = generateInteractionToken({ action_id: "a" });
|
||||||
|
expect(verifyInteractionToken({ action_id: "b" }, token)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tokens with wrong length", () => {
|
||||||
|
const context = { action_id: "test" };
|
||||||
|
expect(verifyInteractionToken(context, "short")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for the same context", () => {
|
||||||
|
const context = { action_id: "test", x: 1 };
|
||||||
|
const t1 = generateInteractionToken(context);
|
||||||
|
const t2 = generateInteractionToken(context);
|
||||||
|
expect(t1).toBe(t2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the same token regardless of key order", () => {
|
||||||
|
const contextA = { action_id: "do_now", tweet_id: "123", action: "do" };
|
||||||
|
const contextB = { action: "do", action_id: "do_now", tweet_id: "123" };
|
||||||
|
const contextC = { tweet_id: "123", action: "do", action_id: "do_now" };
|
||||||
|
const tokenA = generateInteractionToken(contextA);
|
||||||
|
const tokenB = generateInteractionToken(contextB);
|
||||||
|
const tokenC = generateInteractionToken(contextC);
|
||||||
|
expect(tokenA).toBe(tokenB);
|
||||||
|
expect(tokenB).toBe(tokenC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a token when Mattermost reorders context keys", () => {
|
||||||
|
// Simulate: token generated with keys in one order, verified with keys in another
|
||||||
|
// (Mattermost reorders context keys when storing/returning interactive message payloads)
|
||||||
|
const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" };
|
||||||
|
const token = generateInteractionToken(originalContext);
|
||||||
|
|
||||||
|
// Mattermost returns keys in alphabetical order (or any arbitrary order)
|
||||||
|
const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" };
|
||||||
|
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes tokens per account when account secrets differ", () => {
|
||||||
|
setInteractionSecret("acct-a", "bot-token-a");
|
||||||
|
setInteractionSecret("acct-b", "bot-token-b");
|
||||||
|
const context = { action_id: "do_now", item_id: "123" };
|
||||||
|
const tokenA = generateInteractionToken(context, "acct-a");
|
||||||
|
|
||||||
|
expect(verifyInteractionToken(context, tokenA, "acct-a")).toBe(true);
|
||||||
|
expect(verifyInteractionToken(context, tokenA, "acct-b")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Callback URL registry ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("callback URL registry", () => {
|
||||||
|
it("stores and retrieves callback URLs", () => {
|
||||||
|
setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1");
|
||||||
|
expect(getInteractionCallbackUrl("acct1")).toBe(
|
||||||
|
"http://localhost:18789/mattermost/interactions/acct1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unknown account", () => {
|
||||||
|
expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveInteractionCallbackUrl", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
setInteractionCallbackUrl("resolve-test", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers cached URL from registry", () => {
|
||||||
|
setInteractionCallbackUrl("cached", "http://cached:1234/path");
|
||||||
|
expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to computed URL from gateway port config", () => {
|
||||||
|
const url = resolveInteractionCallbackUrl("default", { gateway: { port: 9999 } });
|
||||||
|
expect(url).toBe("http://localhost:9999/mattermost/interactions/default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default port 18789 when no config provided", () => {
|
||||||
|
const url = resolveInteractionCallbackUrl("myaccount");
|
||||||
|
expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default port when gateway config has no port", () => {
|
||||||
|
const url = resolveInteractionCallbackUrl("acct", { gateway: {} });
|
||||||
|
expect(url).toBe("http://localhost:18789/mattermost/interactions/acct");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildButtonAttachments ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("buildButtonAttachments", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setInteractionSecret("test-bot-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an array with one attachment containing all buttons", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost:18789/mattermost/interactions/default",
|
||||||
|
buttons: [
|
||||||
|
{ id: "btn1", name: "Click Me" },
|
||||||
|
{ id: "btn2", name: "Skip", style: "danger" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].actions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets type to 'button' on every action", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost:18789/cb",
|
||||||
|
buttons: [{ id: "a", name: "A" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0].actions![0].type).toBe("button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes HMAC _token in integration context", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost:18789/cb",
|
||||||
|
buttons: [{ id: "test", name: "Test" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = result[0].actions![0];
|
||||||
|
expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes sanitized action_id in integration context", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost:18789/cb",
|
||||||
|
buttons: [{ id: "my_action", name: "Do It" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = result[0].actions![0];
|
||||||
|
// sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747)
|
||||||
|
expect(action.integration.context.action_id).toBe("myaction");
|
||||||
|
expect(action.id).toBe("myaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges custom context into integration context", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost:18789/cb",
|
||||||
|
buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = result[0].actions![0].integration.context;
|
||||||
|
expect(ctx.tweet_id).toBe("123");
|
||||||
|
expect(ctx.batch).toBe(true);
|
||||||
|
expect(ctx.action_id).toBe("btn");
|
||||||
|
expect(ctx._token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes callback URL to each button integration", () => {
|
||||||
|
const url = "http://localhost:18789/mattermost/interactions/default";
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: url,
|
||||||
|
buttons: [
|
||||||
|
{ id: "a", name: "A" },
|
||||||
|
{ id: "b", name: "B" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const action of result[0].actions!) {
|
||||||
|
expect(action.integration.url).toBe(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves button style", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost/cb",
|
||||||
|
buttons: [
|
||||||
|
{ id: "ok", name: "OK", style: "primary" },
|
||||||
|
{ id: "no", name: "No", style: "danger" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0].actions![0].style).toBe("primary");
|
||||||
|
expect(result[0].actions![1].style).toBe("danger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses provided text for the attachment", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost/cb",
|
||||||
|
buttons: [{ id: "x", name: "X" }],
|
||||||
|
text: "Choose an action:",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0].text).toBe("Choose an action:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to empty string text when not provided", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost/cb",
|
||||||
|
buttons: [{ id: "x", name: "X" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0].text).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates verifiable tokens", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost/cb",
|
||||||
|
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = result[0].actions![0].integration.context;
|
||||||
|
const token = ctx._token as string;
|
||||||
|
const { _token, ...contextWithoutToken } = ctx;
|
||||||
|
expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates tokens that verify even when Mattermost reorders context keys", () => {
|
||||||
|
const result = buildButtonAttachments({
|
||||||
|
callbackUrl: "http://localhost/cb",
|
||||||
|
buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = result[0].actions![0].integration.context;
|
||||||
|
const token = ctx._token as string;
|
||||||
|
|
||||||
|
// Simulate Mattermost returning context with keys in a different order
|
||||||
|
const reordered: Record<string, unknown> = {};
|
||||||
|
const keys = Object.keys(ctx).filter((k) => k !== "_token");
|
||||||
|
// Reverse the key order to simulate reordering
|
||||||
|
for (const key of keys.reverse()) {
|
||||||
|
reordered[key] = ctx[key];
|
||||||
|
}
|
||||||
|
expect(verifyInteractionToken(reordered, token)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── isLocalhostRequest ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("isLocalhostRequest", () => {
|
||||||
|
function fakeReq(remoteAddress?: string): IncomingMessage {
|
||||||
|
return {
|
||||||
|
socket: { remoteAddress },
|
||||||
|
} as unknown as IncomingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts 127.0.0.1", () => {
|
||||||
|
expect(isLocalhostRequest(fakeReq("127.0.0.1"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts ::1", () => {
|
||||||
|
expect(isLocalhostRequest(fakeReq("::1"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts ::ffff:127.0.0.1", () => {
|
||||||
|
expect(isLocalhostRequest(fakeReq("::ffff:127.0.0.1"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects external addresses", () => {
|
||||||
|
expect(isLocalhostRequest(fakeReq("10.0.0.1"))).toBe(false);
|
||||||
|
expect(isLocalhostRequest(fakeReq("192.168.1.1"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when socket has no remote address", () => {
|
||||||
|
expect(isLocalhostRequest(fakeReq(undefined))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when socket is missing", () => {
|
||||||
|
expect(isLocalhostRequest({} as IncomingMessage)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
429
extensions/mattermost/src/mattermost/interactions.ts
Normal file
429
extensions/mattermost/src/mattermost/interactions.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { getMattermostRuntime } from "../runtime.js";
|
||||||
|
import { updateMattermostPost, type MattermostClient } from "./client.js";
|
||||||
|
|
||||||
|
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||||
|
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mattermost interactive message callback payload.
|
||||||
|
* Sent by Mattermost when a user clicks an action button.
|
||||||
|
* See: https://developers.mattermost.com/integrate/plugins/interactive-messages/
|
||||||
|
*/
|
||||||
|
export type MattermostInteractionPayload = {
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
channel_id: string;
|
||||||
|
team_id?: string;
|
||||||
|
post_id: string;
|
||||||
|
trigger_id?: string;
|
||||||
|
type?: string;
|
||||||
|
data_source?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostInteractionResponse = {
|
||||||
|
update?: {
|
||||||
|
message: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
ephemeral_text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Callback URL registry ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const callbackUrls = new Map<string, string>();
|
||||||
|
|
||||||
|
export function setInteractionCallbackUrl(accountId: string, url: string): void {
|
||||||
|
callbackUrls.set(accountId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInteractionCallbackUrl(accountId: string): string | undefined {
|
||||||
|
return callbackUrls.get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the interaction callback URL for an account.
|
||||||
|
* Prefers the in-memory registered URL (set by the gateway monitor).
|
||||||
|
* Falls back to computing it from the gateway port in config (for CLI callers).
|
||||||
|
*/
|
||||||
|
export function resolveInteractionCallbackUrl(
|
||||||
|
accountId: string,
|
||||||
|
cfg?: { gateway?: { port?: number } },
|
||||||
|
): string {
|
||||||
|
const cached = callbackUrls.get(accountId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789;
|
||||||
|
return `http://localhost:${port}/mattermost/interactions/${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HMAC token management ──────────────────────────────────────────────
|
||||||
|
// Secret is derived from the bot token so it's stable across CLI and gateway processes.
|
||||||
|
|
||||||
|
const interactionSecrets = new Map<string, string>();
|
||||||
|
let defaultInteractionSecret: string | undefined;
|
||||||
|
|
||||||
|
function deriveInteractionSecret(botToken: string): string {
|
||||||
|
return createHmac("sha256", "openclaw-mattermost-interactions").update(botToken).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInteractionSecret(accountIdOrBotToken: string, botToken?: string): void {
|
||||||
|
if (typeof botToken === "string") {
|
||||||
|
interactionSecrets.set(accountIdOrBotToken, deriveInteractionSecret(botToken));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Backward-compatible fallback for call sites/tests that only pass botToken.
|
||||||
|
defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInteractionSecret(accountId?: string): string {
|
||||||
|
const scoped = accountId ? interactionSecrets.get(accountId) : undefined;
|
||||||
|
if (scoped) {
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
if (defaultInteractionSecret) {
|
||||||
|
return defaultInteractionSecret;
|
||||||
|
}
|
||||||
|
// Fallback for single-account runtimes that only registered scoped secrets.
|
||||||
|
if (interactionSecrets.size === 1) {
|
||||||
|
const first = interactionSecrets.values().next().value;
|
||||||
|
if (typeof first === "string") {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Interaction secret not initialized — call setInteractionSecret(accountId, botToken) first",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateInteractionToken(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
accountId?: string,
|
||||||
|
): string {
|
||||||
|
const secret = getInteractionSecret(accountId);
|
||||||
|
// Sort keys for stable serialization — Mattermost may reorder context keys
|
||||||
|
const payload = JSON.stringify(context, Object.keys(context).sort());
|
||||||
|
return createHmac("sha256", secret).update(payload).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyInteractionToken(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
token: string,
|
||||||
|
accountId?: string,
|
||||||
|
): boolean {
|
||||||
|
const expected = generateInteractionToken(context, accountId);
|
||||||
|
if (expected.length !== token.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(Buffer.from(expected), Buffer.from(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button builder helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MattermostButton = {
|
||||||
|
id: string;
|
||||||
|
type: "button" | "select";
|
||||||
|
name: string;
|
||||||
|
style?: "default" | "primary" | "danger";
|
||||||
|
integration: {
|
||||||
|
url: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostAttachment = {
|
||||||
|
text?: string;
|
||||||
|
actions?: MattermostButton[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Mattermost `props.attachments` with interactive buttons.
|
||||||
|
*
|
||||||
|
* Each button includes an HMAC token in its integration context so the
|
||||||
|
* callback handler can verify the request originated from a legitimate
|
||||||
|
* button click (Mattermost's recommended security pattern).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Sanitize a button ID so Mattermost's action router can match it.
|
||||||
|
* Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}`
|
||||||
|
* and IDs containing hyphens or underscores break the server-side routing.
|
||||||
|
* See: https://github.com/mattermost/mattermost/issues/25747
|
||||||
|
*/
|
||||||
|
function sanitizeActionId(id: string): string {
|
||||||
|
return id.replace(/[-_]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildButtonAttachments(params: {
|
||||||
|
callbackUrl: string;
|
||||||
|
accountId?: string;
|
||||||
|
buttons: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
style?: "default" | "primary" | "danger";
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
text?: string;
|
||||||
|
}): MattermostAttachment[] {
|
||||||
|
const actions: MattermostButton[] = params.buttons.map((btn) => {
|
||||||
|
const safeId = sanitizeActionId(btn.id);
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
action_id: safeId,
|
||||||
|
...btn.context,
|
||||||
|
};
|
||||||
|
const token = generateInteractionToken(context, params.accountId);
|
||||||
|
return {
|
||||||
|
id: safeId,
|
||||||
|
type: "button" as const,
|
||||||
|
name: btn.name,
|
||||||
|
style: btn.style,
|
||||||
|
integration: {
|
||||||
|
url: params.callbackUrl,
|
||||||
|
context: {
|
||||||
|
...context,
|
||||||
|
_token: token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: params.text ?? "",
|
||||||
|
actions,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Localhost validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
||||||
|
|
||||||
|
export function isLocalhostRequest(req: IncomingMessage): boolean {
|
||||||
|
const addr = req.socket?.remoteAddress;
|
||||||
|
if (!addr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return LOCALHOST_ADDRESSES.has(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body reader ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readInteractionBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Request body read timeout"));
|
||||||
|
}, INTERACTION_BODY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
|
||||||
|
req.destroy();
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Request body too large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createMattermostInteractionHandler(params: {
|
||||||
|
client: MattermostClient;
|
||||||
|
botUserId: string;
|
||||||
|
accountId: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
resolveSessionKey?: (channelId: string, userId: string) => Promise<string>;
|
||||||
|
dispatchButtonClick?: (opts: {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
actionId: string;
|
||||||
|
actionName: string;
|
||||||
|
postId: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||||
|
const { client, accountId, log } = params;
|
||||||
|
const core = getMattermostRuntime();
|
||||||
|
|
||||||
|
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
// Only accept POST
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request is from localhost
|
||||||
|
if (!isLocalhostRequest(req)) {
|
||||||
|
log?.(
|
||||||
|
`mattermost interaction: rejected non-localhost request from ${req.socket?.remoteAddress}`,
|
||||||
|
);
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Forbidden" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: MattermostInteractionPayload;
|
||||||
|
try {
|
||||||
|
const raw = await readInteractionBody(req);
|
||||||
|
payload = JSON.parse(raw) as MattermostInteractionPayload;
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: failed to parse body: ${String(err)}`);
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Invalid request body" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = payload.context;
|
||||||
|
if (!context) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Missing context" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HMAC token
|
||||||
|
const token = context._token;
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
log?.("mattermost interaction: missing _token in context");
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Missing token" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip _token before verification (it wasn't in the original context)
|
||||||
|
const { _token, ...contextWithoutToken } = context;
|
||||||
|
if (!verifyInteractionToken(contextWithoutToken, token, accountId)) {
|
||||||
|
log?.("mattermost interaction: invalid _token");
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Invalid token" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionId = context.action_id;
|
||||||
|
if (typeof actionId !== "string") {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Missing action_id in context" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.(
|
||||||
|
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||||
|
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch as system event so the agent can handle it.
|
||||||
|
// Wrapped in try/catch — the post update below must still run even if
|
||||||
|
// system event dispatch fails (e.g. missing sessionKey or channel lookup).
|
||||||
|
try {
|
||||||
|
const eventLabel =
|
||||||
|
`Mattermost button click: action="${actionId}" ` +
|
||||||
|
`by ${payload.user_name ?? payload.user_id} ` +
|
||||||
|
`in channel ${payload.channel_id}`;
|
||||||
|
|
||||||
|
const sessionKey = params.resolveSessionKey
|
||||||
|
? await params.resolveSessionKey(payload.channel_id, payload.user_id)
|
||||||
|
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
|
||||||
|
|
||||||
|
core.system.enqueueSystemEvent(eventLabel, {
|
||||||
|
sessionKey,
|
||||||
|
contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the original post to preserve its message and find the clicked button name.
|
||||||
|
const userName = payload.user_name ?? payload.user_id;
|
||||||
|
let originalMessage = "";
|
||||||
|
let clickedButtonName = actionId; // fallback to action ID if we can't find the name
|
||||||
|
try {
|
||||||
|
const originalPost = await client.request<{
|
||||||
|
message?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}>(`/posts/${payload.post_id}`);
|
||||||
|
originalMessage = originalPost?.message ?? "";
|
||||||
|
|
||||||
|
// Find the clicked button's display name from the original attachments
|
||||||
|
const postAttachments = Array.isArray(originalPost?.props?.attachments)
|
||||||
|
? (originalPost.props.attachments as Array<{
|
||||||
|
actions?: Array<{ id?: string; name?: string }>;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
for (const att of postAttachments) {
|
||||||
|
const match = att.actions?.find((a) => a.id === actionId);
|
||||||
|
if (match?.name) {
|
||||||
|
clickedButtonName = match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: failed to fetch post ${payload.post_id}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the post via API to replace buttons with a completion indicator.
|
||||||
|
try {
|
||||||
|
await updateMattermostPost(client, payload.post_id, {
|
||||||
|
message: originalMessage,
|
||||||
|
props: {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
text: `✓ **${clickedButtonName}** selected by @${userName}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with empty JSON — the post update is handled above
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end("{}");
|
||||||
|
|
||||||
|
// Dispatch a synthetic inbound message so the agent responds to the button click.
|
||||||
|
if (params.dispatchButtonClick) {
|
||||||
|
try {
|
||||||
|
await params.dispatchButtonClick({
|
||||||
|
channelId: payload.channel_id,
|
||||||
|
userId: payload.user_id,
|
||||||
|
userName,
|
||||||
|
actionId,
|
||||||
|
actionName: clickedButtonName,
|
||||||
|
postId: payload.post_id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
recordPendingHistoryEntryIfEnabled,
|
||||||
isDangerousNameMatchingEnabled,
|
isDangerousNameMatchingEnabled,
|
||||||
|
registerPluginHttpRoute,
|
||||||
resolveControlCommandGate,
|
resolveControlCommandGate,
|
||||||
readStoreAllowFromForDmPolicy,
|
readStoreAllowFromForDmPolicy,
|
||||||
resolveDmGroupAccessWithLists,
|
resolveDmGroupAccessWithLists,
|
||||||
@@ -42,6 +43,11 @@ import {
|
|||||||
type MattermostPost,
|
type MattermostPost,
|
||||||
type MattermostUser,
|
type MattermostUser,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
createMattermostInteractionHandler,
|
||||||
|
setInteractionCallbackUrl,
|
||||||
|
setInteractionSecret,
|
||||||
|
} from "./interactions.js";
|
||||||
import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js";
|
import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js";
|
||||||
import {
|
import {
|
||||||
createDedupeCache,
|
createDedupeCache,
|
||||||
@@ -318,12 +324,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
// a different port.
|
// a different port.
|
||||||
const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim();
|
const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim();
|
||||||
const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN;
|
const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN;
|
||||||
const gatewayPort =
|
const slashGatewayPort =
|
||||||
Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789);
|
Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789);
|
||||||
|
|
||||||
const callbackUrl = resolveCallbackUrl({
|
const slashCallbackUrl = resolveCallbackUrl({
|
||||||
config: slashConfig,
|
config: slashConfig,
|
||||||
gatewayPort,
|
gatewayPort: slashGatewayPort,
|
||||||
gatewayHost: cfg.gateway?.customBindHost ?? undefined,
|
gatewayHost: cfg.gateway?.customBindHost ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,7 +338,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mmHost = new URL(baseUrl).hostname;
|
const mmHost = new URL(baseUrl).hostname;
|
||||||
const callbackHost = new URL(callbackUrl).hostname;
|
const callbackHost = new URL(slashCallbackUrl).hostname;
|
||||||
|
|
||||||
// NOTE: We cannot infer network reachability from hostnames alone.
|
// NOTE: We cannot infer network reachability from hostnames alone.
|
||||||
// Mattermost might be accessed via a public domain while still running on the same
|
// Mattermost might be accessed via a public domain while still running on the same
|
||||||
@@ -340,7 +346,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
// So treat loopback callback URLs as an advisory warning only.
|
// So treat loopback callback URLs as an advisory warning only.
|
||||||
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
||||||
runtime.error?.(
|
runtime.error?.(
|
||||||
`mattermost: slash commands callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
`mattermost: slash commands callbackUrl resolved to ${slashCallbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -390,7 +396,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
client,
|
client,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
creatorUserId: botUserId,
|
creatorUserId: botUserId,
|
||||||
callbackUrl,
|
callbackUrl: slashCallbackUrl,
|
||||||
commands: dedupedCommands,
|
commands: dedupedCommands,
|
||||||
log: (msg) => runtime.log?.(msg),
|
log: (msg) => runtime.log?.(msg),
|
||||||
});
|
});
|
||||||
@@ -432,7 +438,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
|
|
||||||
runtime.log?.(
|
runtime.log?.(
|
||||||
`mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${callbackUrl})`,
|
`mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -440,6 +446,182 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Interactive buttons registration ──────────────────────────────────────
|
||||||
|
// Derive a stable HMAC secret from the bot token so CLI and gateway share it.
|
||||||
|
setInteractionSecret(account.accountId, botToken);
|
||||||
|
|
||||||
|
// Register HTTP callback endpoint for interactive button clicks.
|
||||||
|
// Mattermost POSTs to this URL when a user clicks a button action.
|
||||||
|
const gatewayPort = typeof cfg.gateway?.port === "number" ? cfg.gateway.port : 18789;
|
||||||
|
const interactionPath = `/mattermost/interactions/${account.accountId}`;
|
||||||
|
const callbackUrl = `http://localhost:${gatewayPort}${interactionPath}`;
|
||||||
|
setInteractionCallbackUrl(account.accountId, callbackUrl);
|
||||||
|
const unregisterInteractions = registerPluginHttpRoute({
|
||||||
|
path: interactionPath,
|
||||||
|
fallbackPath: "/mattermost/interactions/default",
|
||||||
|
auth: "plugin",
|
||||||
|
handler: createMattermostInteractionHandler({
|
||||||
|
client,
|
||||||
|
botUserId,
|
||||||
|
accountId: account.accountId,
|
||||||
|
callbackUrl,
|
||||||
|
resolveSessionKey: async (channelId: string, userId: string) => {
|
||||||
|
const channelInfo = await resolveChannelInfo(channelId);
|
||||||
|
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
||||||
|
const teamId = channelInfo?.team_id ?? undefined;
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
teamId,
|
||||||
|
peer: {
|
||||||
|
kind,
|
||||||
|
id: kind === "direct" ? userId : channelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return route.sessionKey;
|
||||||
|
},
|
||||||
|
dispatchButtonClick: async (opts) => {
|
||||||
|
const channelInfo = await resolveChannelInfo(opts.channelId);
|
||||||
|
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
||||||
|
const chatType = channelChatType(kind);
|
||||||
|
const teamId = channelInfo?.team_id ?? undefined;
|
||||||
|
const channelName = channelInfo?.name ?? undefined;
|
||||||
|
const channelDisplay = channelInfo?.display_name ?? channelName ?? opts.channelId;
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
teamId,
|
||||||
|
peer: {
|
||||||
|
kind,
|
||||||
|
id: kind === "direct" ? opts.userId : opts.channelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
|
||||||
|
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: bodyText,
|
||||||
|
BodyForAgent: bodyText,
|
||||||
|
RawBody: bodyText,
|
||||||
|
CommandBody: bodyText,
|
||||||
|
From:
|
||||||
|
kind === "direct"
|
||||||
|
? `mattermost:${opts.userId}`
|
||||||
|
: kind === "group"
|
||||||
|
? `mattermost:group:${opts.channelId}`
|
||||||
|
: `mattermost:channel:${opts.channelId}`,
|
||||||
|
To: to,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: chatType,
|
||||||
|
ConversationLabel: `mattermost:${opts.userName}`,
|
||||||
|
GroupSubject: kind !== "direct" ? channelDisplay : undefined,
|
||||||
|
GroupChannel: channelName ? `#${channelName}` : undefined,
|
||||||
|
GroupSpace: teamId,
|
||||||
|
SenderName: opts.userName,
|
||||||
|
SenderId: opts.userId,
|
||||||
|
Provider: "mattermost" as const,
|
||||||
|
Surface: "mattermost" as const,
|
||||||
|
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
|
||||||
|
WasMentioned: true,
|
||||||
|
CommandAuthorized: true,
|
||||||
|
OriginatingChannel: "mattermost" as const,
|
||||||
|
OriginatingTo: to,
|
||||||
|
});
|
||||||
|
|
||||||
|
const textLimit = core.channel.text.resolveTextChunkLimit(
|
||||||
|
cfg,
|
||||||
|
"mattermost",
|
||||||
|
account.accountId,
|
||||||
|
{ fallbackLimit: account.textChunkLimit ?? 4000 },
|
||||||
|
);
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||||
|
cfg,
|
||||||
|
agentId: route.agentId,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const typingCallbacks = createTypingCallbacks({
|
||||||
|
start: () => sendTypingIndicator(opts.channelId),
|
||||||
|
onStartError: (err) => {
|
||||||
|
logTypingFailure({
|
||||||
|
log: (message) => logger.debug?.(message),
|
||||||
|
channel: "mattermost",
|
||||||
|
target: opts.channelId,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
...prefixOptions,
|
||||||
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload: ReplyPayload) => {
|
||||||
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(
|
||||||
|
cfg,
|
||||||
|
"mattermost",
|
||||||
|
account.accountId,
|
||||||
|
);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
textLimit,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
|
if (!chunk) continue;
|
||||||
|
await sendMessageMattermost(to, chunk, {
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let first = true;
|
||||||
|
for (const mediaUrl of mediaUrls) {
|
||||||
|
const caption = first ? text : "";
|
||||||
|
first = false;
|
||||||
|
await sendMessageMattermost(to, caption, {
|
||||||
|
accountId: account.accountId,
|
||||||
|
mediaUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.log?.(`delivered button-click reply to ${to}`);
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks.onReplyStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
log: (msg) => runtime.log?.(msg),
|
||||||
|
}),
|
||||||
|
pluginId: "mattermost",
|
||||||
|
source: "mattermost-interactions",
|
||||||
|
accountId: account.accountId,
|
||||||
|
log: (msg: string) => runtime.log?.(msg),
|
||||||
|
});
|
||||||
|
|
||||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
@@ -493,6 +675,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
},
|
},
|
||||||
filePathHint: fileId,
|
filePathHint: fileId,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
|
// Allow fetching from the Mattermost server host (may be localhost or
|
||||||
|
// a private IP). Without this, SSRF guards block media downloads.
|
||||||
|
// Credit: #22594 (@webclerk)
|
||||||
|
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
|
||||||
});
|
});
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
const saved = await core.channel.media.saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
@@ -1296,17 +1482,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await runWithReconnect(connectOnce, {
|
try {
|
||||||
abortSignal: opts.abortSignal,
|
await runWithReconnect(connectOnce, {
|
||||||
jitterRatio: 0.2,
|
abortSignal: opts.abortSignal,
|
||||||
onError: (err) => {
|
jitterRatio: 0.2,
|
||||||
runtime.error?.(`mattermost connection failed: ${String(err)}`);
|
onError: (err) => {
|
||||||
opts.statusSink?.({ lastError: String(err), connected: false });
|
runtime.error?.(`mattermost connection failed: ${String(err)}`);
|
||||||
},
|
opts.statusSink?.({ lastError: String(err), connected: false });
|
||||||
onReconnect: (delayMs) => {
|
},
|
||||||
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
|
onReconnect: (delayMs) => {
|
||||||
},
|
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
unregisterInteractions?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (slashShutdownCleanup) {
|
if (slashShutdownCleanup) {
|
||||||
await slashShutdownCleanup;
|
await slashShutdownCleanup;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { sendMessageMattermost } from "./send.js";
|
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({})),
|
loadConfig: vi.fn(() => ({})),
|
||||||
@@ -12,7 +12,9 @@ const mockState = vi.hoisted(() => ({
|
|||||||
createMattermostClient: vi.fn(),
|
createMattermostClient: vi.fn(),
|
||||||
createMattermostDirectChannel: vi.fn(),
|
createMattermostDirectChannel: vi.fn(),
|
||||||
createMattermostPost: vi.fn(),
|
createMattermostPost: vi.fn(),
|
||||||
|
fetchMattermostChannelByName: vi.fn(),
|
||||||
fetchMattermostMe: vi.fn(),
|
fetchMattermostMe: vi.fn(),
|
||||||
|
fetchMattermostUserTeams: vi.fn(),
|
||||||
fetchMattermostUserByUsername: vi.fn(),
|
fetchMattermostUserByUsername: vi.fn(),
|
||||||
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
||||||
uploadMattermostFile: vi.fn(),
|
uploadMattermostFile: vi.fn(),
|
||||||
@@ -30,7 +32,9 @@ vi.mock("./client.js", () => ({
|
|||||||
createMattermostClient: mockState.createMattermostClient,
|
createMattermostClient: mockState.createMattermostClient,
|
||||||
createMattermostDirectChannel: mockState.createMattermostDirectChannel,
|
createMattermostDirectChannel: mockState.createMattermostDirectChannel,
|
||||||
createMattermostPost: mockState.createMattermostPost,
|
createMattermostPost: mockState.createMattermostPost,
|
||||||
|
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
|
||||||
fetchMattermostMe: mockState.fetchMattermostMe,
|
fetchMattermostMe: mockState.fetchMattermostMe,
|
||||||
|
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
|
||||||
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
||||||
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
||||||
uploadMattermostFile: mockState.uploadMattermostFile,
|
uploadMattermostFile: mockState.uploadMattermostFile,
|
||||||
@@ -71,11 +75,16 @@ describe("sendMessageMattermost", () => {
|
|||||||
mockState.createMattermostClient.mockReset();
|
mockState.createMattermostClient.mockReset();
|
||||||
mockState.createMattermostDirectChannel.mockReset();
|
mockState.createMattermostDirectChannel.mockReset();
|
||||||
mockState.createMattermostPost.mockReset();
|
mockState.createMattermostPost.mockReset();
|
||||||
|
mockState.fetchMattermostChannelByName.mockReset();
|
||||||
mockState.fetchMattermostMe.mockReset();
|
mockState.fetchMattermostMe.mockReset();
|
||||||
|
mockState.fetchMattermostUserTeams.mockReset();
|
||||||
mockState.fetchMattermostUserByUsername.mockReset();
|
mockState.fetchMattermostUserByUsername.mockReset();
|
||||||
mockState.uploadMattermostFile.mockReset();
|
mockState.uploadMattermostFile.mockReset();
|
||||||
mockState.createMattermostClient.mockReturnValue({});
|
mockState.createMattermostClient.mockReturnValue({});
|
||||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
||||||
|
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
|
||||||
|
mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]);
|
||||||
|
mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" });
|
||||||
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
|
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,3 +157,86 @@ describe("sendMessageMattermost", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseMattermostTarget", () => {
|
||||||
|
it("parses channel: prefix with valid ID as channel id", () => {
|
||||||
|
const target = parseMattermostTarget("channel:dthcxgoxhifn3pwh65cut3ud3w");
|
||||||
|
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses channel: prefix with non-ID as channel name", () => {
|
||||||
|
const target = parseMattermostTarget("channel:abc123");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "abc123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses user: prefix as user id", () => {
|
||||||
|
const target = parseMattermostTarget("user:usr456");
|
||||||
|
expect(target).toEqual({ kind: "user", id: "usr456" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mattermost: prefix as user id", () => {
|
||||||
|
const target = parseMattermostTarget("mattermost:usr789");
|
||||||
|
expect(target).toEqual({ kind: "user", id: "usr789" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses @ prefix as username", () => {
|
||||||
|
const target = parseMattermostTarget("@alice");
|
||||||
|
expect(target).toEqual({ kind: "user", username: "alice" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses # prefix as channel name", () => {
|
||||||
|
const target = parseMattermostTarget("#off-topic");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses # prefix with spaces", () => {
|
||||||
|
const target = parseMattermostTarget(" #general ");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "general" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats 26-char alphanumeric bare string as channel id", () => {
|
||||||
|
const target = parseMattermostTarget("dthcxgoxhifn3pwh65cut3ud3w");
|
||||||
|
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats non-ID bare string as channel name", () => {
|
||||||
|
const target = parseMattermostTarget("off-topic");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats channel: with non-ID value as channel name", () => {
|
||||||
|
const target = parseMattermostTarget("channel:off-topic");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty string", () => {
|
||||||
|
expect(() => parseMattermostTarget("")).toThrow("Recipient is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty # prefix", () => {
|
||||||
|
expect(() => parseMattermostTarget("#")).toThrow("Channel name is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty @ prefix", () => {
|
||||||
|
expect(() => parseMattermostTarget("@")).toThrow("Username is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses channel:#name as channel name", () => {
|
||||||
|
const target = parseMattermostTarget("channel:#off-topic");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses channel:#name with spaces", () => {
|
||||||
|
const target = parseMattermostTarget(" channel: #general ");
|
||||||
|
expect(target).toEqual({ kind: "channel-name", name: "general" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive for prefixes", () => {
|
||||||
|
expect(parseMattermostTarget("CHANNEL:dthcxgoxhifn3pwh65cut3ud3w")).toEqual({
|
||||||
|
kind: "channel",
|
||||||
|
id: "dthcxgoxhifn3pwh65cut3ud3w",
|
||||||
|
});
|
||||||
|
expect(parseMattermostTarget("User:XYZ")).toEqual({ kind: "user", id: "XYZ" });
|
||||||
|
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
createMattermostClient,
|
createMattermostClient,
|
||||||
createMattermostDirectChannel,
|
createMattermostDirectChannel,
|
||||||
createMattermostPost,
|
createMattermostPost,
|
||||||
|
fetchMattermostChannelByName,
|
||||||
fetchMattermostMe,
|
fetchMattermostMe,
|
||||||
fetchMattermostUserByUsername,
|
fetchMattermostUserByUsername,
|
||||||
|
fetchMattermostUserTeams,
|
||||||
normalizeMattermostBaseUrl,
|
normalizeMattermostBaseUrl,
|
||||||
uploadMattermostFile,
|
uploadMattermostFile,
|
||||||
type MattermostUser,
|
type MattermostUser,
|
||||||
@@ -20,6 +22,7 @@ export type MattermostSendOpts = {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MattermostSendResult = {
|
export type MattermostSendResult = {
|
||||||
@@ -29,10 +32,12 @@ export type MattermostSendResult = {
|
|||||||
|
|
||||||
type MattermostTarget =
|
type MattermostTarget =
|
||||||
| { kind: "channel"; id: string }
|
| { kind: "channel"; id: string }
|
||||||
|
| { kind: "channel-name"; name: string }
|
||||||
| { kind: "user"; id?: string; username?: string };
|
| { kind: "user"; id?: string; username?: string };
|
||||||
|
|
||||||
const botUserCache = new Map<string, MattermostUser>();
|
const botUserCache = new Map<string, MattermostUser>();
|
||||||
const userByNameCache = new Map<string, MattermostUser>();
|
const userByNameCache = new Map<string, MattermostUser>();
|
||||||
|
const channelByNameCache = new Map<string, string>();
|
||||||
|
|
||||||
const getCore = () => getMattermostRuntime();
|
const getCore = () => getMattermostRuntime();
|
||||||
|
|
||||||
@@ -50,7 +55,12 @@ function isHttpUrl(value: string): boolean {
|
|||||||
return /^https?:\/\//i.test(value);
|
return /^https?:\/\//i.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
|
||||||
|
function isMattermostId(value: string): boolean {
|
||||||
|
return /^[a-z0-9]{26}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMattermostTarget(raw: string): MattermostTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
throw new Error("Recipient is required for Mattermost sends");
|
throw new Error("Recipient is required for Mattermost sends");
|
||||||
@@ -61,6 +71,16 @@ function parseMattermostTarget(raw: string): MattermostTarget {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("Channel id is required for Mattermost sends");
|
throw new Error("Channel id is required for Mattermost sends");
|
||||||
}
|
}
|
||||||
|
if (id.startsWith("#")) {
|
||||||
|
const name = id.slice(1).trim();
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Channel name is required for Mattermost sends");
|
||||||
|
}
|
||||||
|
return { kind: "channel-name", name };
|
||||||
|
}
|
||||||
|
if (!isMattermostId(id)) {
|
||||||
|
return { kind: "channel-name", name: id };
|
||||||
|
}
|
||||||
return { kind: "channel", id };
|
return { kind: "channel", id };
|
||||||
}
|
}
|
||||||
if (lower.startsWith("user:")) {
|
if (lower.startsWith("user:")) {
|
||||||
@@ -84,6 +104,16 @@ function parseMattermostTarget(raw: string): MattermostTarget {
|
|||||||
}
|
}
|
||||||
return { kind: "user", username };
|
return { kind: "user", username };
|
||||||
}
|
}
|
||||||
|
if (trimmed.startsWith("#")) {
|
||||||
|
const name = trimmed.slice(1).trim();
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Channel name is required for Mattermost sends");
|
||||||
|
}
|
||||||
|
return { kind: "channel-name", name };
|
||||||
|
}
|
||||||
|
if (!isMattermostId(trimmed)) {
|
||||||
|
return { kind: "channel-name", name: trimmed };
|
||||||
|
}
|
||||||
return { kind: "channel", id: trimmed };
|
return { kind: "channel", id: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +146,34 @@ async function resolveUserIdByUsername(params: {
|
|||||||
return user.id;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveChannelIdByName(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { baseUrl, token, name } = params;
|
||||||
|
const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`;
|
||||||
|
const cached = channelByNameCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||||
|
const me = await fetchMattermostMe(client);
|
||||||
|
const teams = await fetchMattermostUserTeams(client, me.id);
|
||||||
|
for (const team of teams) {
|
||||||
|
try {
|
||||||
|
const channel = await fetchMattermostChannelByName(client, team.id, name);
|
||||||
|
if (channel?.id) {
|
||||||
|
channelByNameCache.set(key, channel.id);
|
||||||
|
return channel.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Channel not found in this team, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTargetChannelId(params: {
|
async function resolveTargetChannelId(params: {
|
||||||
target: MattermostTarget;
|
target: MattermostTarget;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -124,6 +182,13 @@ async function resolveTargetChannelId(params: {
|
|||||||
if (params.target.kind === "channel") {
|
if (params.target.kind === "channel") {
|
||||||
return params.target.id;
|
return params.target.id;
|
||||||
}
|
}
|
||||||
|
if (params.target.kind === "channel-name") {
|
||||||
|
return await resolveChannelIdByName({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
token: params.token,
|
||||||
|
name: params.target.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
const userId = params.target.id
|
const userId = params.target.id
|
||||||
? params.target.id
|
? params.target.id
|
||||||
: await resolveUserIdByUsername({
|
: await resolveUserIdByUsername({
|
||||||
@@ -221,6 +286,7 @@ export async function sendMessageMattermost(
|
|||||||
message,
|
message,
|
||||||
rootId: opts.replyToId,
|
rootId: opts.replyToId,
|
||||||
fileIds,
|
fileIds,
|
||||||
|
props: opts.props,
|
||||||
});
|
});
|
||||||
|
|
||||||
core.channel.activity.record({
|
core.channel.activity.record({
|
||||||
|
|||||||
96
extensions/mattermost/src/normalize.test.ts
Normal file
96
extensions/mattermost/src/normalize.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||||
|
|
||||||
|
describe("normalizeMattermostMessagingTarget", () => {
|
||||||
|
it("returns undefined for empty input", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes channel: prefix", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123");
|
||||||
|
expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes group: prefix to channel:", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes user: prefix", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes mattermost: prefix to user:", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps @username targets", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice");
|
||||||
|
expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for #channel (triggers directory lookup)", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for bare names (triggers directory lookup)", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty prefixed values", () => {
|
||||||
|
expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("@")).toBeUndefined();
|
||||||
|
expect(normalizeMattermostMessagingTarget("#")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("looksLikeMattermostTargetId", () => {
|
||||||
|
it("returns false for empty input", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("")).toBe(false);
|
||||||
|
expect(looksLikeMattermostTargetId(" ")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes prefixed targets", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("channel:abc")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("user:abc")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("group:abc")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes @username", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("@alice")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recognize #channel (should go to directory)", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false);
|
||||||
|
expect(looksLikeMattermostTargetId("#off-topic")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes 26-char alphanumeric Mattermost IDs", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true);
|
||||||
|
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes DM channel format (26__26)", () => {
|
||||||
|
expect(
|
||||||
|
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects short strings that are not Mattermost IDs", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("password")).toBe(false);
|
||||||
|
expect(looksLikeMattermostTargetId("hi")).toBe(false);
|
||||||
|
expect(looksLikeMattermostTargetId("bookmarks")).toBe(false);
|
||||||
|
expect(looksLikeMattermostTargetId("off-topic")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects strings longer than 26 chars that are not DM format", () => {
|
||||||
|
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,13 +25,16 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
|
|||||||
return id ? `@${id}` : undefined;
|
return id ? `@${id}` : undefined;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("#")) {
|
if (trimmed.startsWith("#")) {
|
||||||
const id = trimmed.slice(1).trim();
|
// Strip # prefix and fall through to directory lookup (same as bare names).
|
||||||
return id ? `channel:${id}` : undefined;
|
// The core's resolveMessagingTarget will use the directory adapter to
|
||||||
|
// resolve the channel name to its Mattermost ID.
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return `channel:${trimmed}`;
|
// Bare name without prefix — return undefined to allow directory lookup
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
export function looksLikeMattermostTargetId(raw: string, normalized?: string): boolean {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return false;
|
return false;
|
||||||
@@ -39,8 +42,9 @@ export function looksLikeMattermostTargetId(raw: string): boolean {
|
|||||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
|
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (/^[@#]/.test(trimmed)) {
|
if (trimmed.startsWith("@")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
// Mattermost IDs: 26-char alnum, or DM channels like "abc123__xyz789" (53 chars)
|
||||||
|
return /^[a-z0-9]{26}$/i.test(trimmed) || /^[a-z0-9]{26}__[a-z0-9]{26}$/i.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export type MattermostAccountConfig = {
|
|||||||
/** Explicit callback URL (e.g. behind reverse proxy). */
|
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
};
|
};
|
||||||
|
interactions?: {
|
||||||
|
/** External base URL used for Mattermost interaction callbacks. */
|
||||||
|
callbackBaseUrl?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MattermostConfig = {
|
export type MattermostConfig = {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type {
|
|||||||
ChannelMessageActionAdapter,
|
ChannelMessageActionAdapter,
|
||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
|
export type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
|
||||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||||
export { createTypingCallbacks } from "../channels/typing.js";
|
export { createTypingCallbacks } from "../channels/typing.js";
|
||||||
@@ -64,6 +65,7 @@ export {
|
|||||||
} from "../config/zod-schema.core.js";
|
} from "../config/zod-schema.core.js";
|
||||||
export { createDedupeCache } from "../infra/dedupe.js";
|
export { createDedupeCache } from "../infra/dedupe.js";
|
||||||
export { rawDataToString } from "../infra/ws.js";
|
export { rawDataToString } from "../infra/ws.js";
|
||||||
|
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user