Skip to content

Commit 136ca87

Browse files
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
1 parent 9741e91 commit 136ca87

17 files changed

Lines changed: 2063 additions & 90 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
120120
- 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.
121121
- 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.
122122
- 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.
123+
- 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.
123124

124125
## 2026.3.2
125126

docs/channels/mattermost.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,151 @@ Config:
175175
- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true).
176176
- Per-account override: `channels.mattermost.accounts.<id>.actions.reactions`.
177177

178+
## Interactive buttons (message tool)
179+
180+
Send messages with clickable buttons. When a user clicks a button, the agent receives the
181+
selection and can respond.
182+
183+
Enable buttons by adding `inlineButtons` to the channel capabilities:
184+
185+
```json5
186+
{
187+
channels: {
188+
mattermost: {
189+
capabilities: ["inlineButtons"],
190+
},
191+
},
192+
}
193+
```
194+
195+
Use `message action=send` with a `buttons` parameter. Buttons are a 2D array (rows of buttons):
196+
197+
```
198+
message action=send channel=mattermost target=channel:<channelId> buttons=[[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]]
199+
```
200+
201+
Button fields:
202+
203+
- `text` (required): display label.
204+
- `callback_data` (required): value sent back on click (used as the action ID).
205+
- `style` (optional): `"default"`, `"primary"`, or `"danger"`.
206+
207+
When a user clicks a button:
208+
209+
1. All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user").
210+
2. The agent receives the selection as an inbound message and responds.
211+
212+
Notes:
213+
214+
- Button callbacks use HMAC-SHA256 verification (automatic, no config needed).
215+
- Mattermost strips callback data from its API responses (security feature), so all buttons
216+
are removed on click — partial removal is not possible.
217+
- Action IDs containing hyphens or underscores are sanitized automatically
218+
(Mattermost routing limitation).
219+
220+
Config:
221+
222+
- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to
223+
enable the buttons tool description in the agent system prompt.
224+
225+
### Direct API integration (external scripts)
226+
227+
External scripts and webhooks can post buttons directly via the Mattermost REST API
228+
instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from
229+
the extension when possible; if posting raw JSON, follow these rules:
230+
231+
**Payload structure:**
232+
233+
```json5
234+
{
235+
channel_id: "<channelId>",
236+
message: "Choose an option:",
237+
props: {
238+
attachments: [
239+
{
240+
actions: [
241+
{
242+
id: "mybutton01", // alphanumeric only — see below
243+
type: "button", // required, or clicks are silently ignored
244+
name: "Approve", // display label
245+
style: "primary", // optional: "default", "primary", "danger"
246+
integration: {
247+
url: "http://localhost:18789/mattermost/interactions/default",
248+
context: {
249+
action_id: "mybutton01", // must match button id (for name lookup)
250+
action: "approve",
251+
// ... any custom fields ...
252+
_token: "<hmac>", // see HMAC section below
253+
},
254+
},
255+
},
256+
],
257+
},
258+
],
259+
},
260+
}
261+
```
262+
263+
**Critical rules:**
264+
265+
1. Attachments go in `props.attachments`, not top-level `attachments` (silently ignored).
266+
2. Every action needs `type: "button"` — without it, clicks are swallowed silently.
267+
3. Every action needs an `id` field — Mattermost ignores actions without IDs.
268+
4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break
269+
Mattermost's server-side action routing (returns 404). Strip them before use.
270+
5. `context.action_id` must match the button's `id` so the confirmation message shows the
271+
button name (e.g., "Approve") instead of a raw ID.
272+
6. `context.action_id` is required — the interaction handler returns 400 without it.
273+
274+
**HMAC token generation:**
275+
276+
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens
277+
that match the gateway's verification logic:
278+
279+
1. Derive the secret from the bot token:
280+
`HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)`
281+
2. Build the context object with all fields **except** `_token`.
282+
3. Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify`
283+
with sorted keys, which produces compact output).
284+
4. Sign: `HMAC-SHA256(key=secret, data=serializedContext)`
285+
5. Add the resulting hex digest as `_token` in the context.
286+
287+
Python example:
288+
289+
```python
290+
import hmac, hashlib, json
291+
292+
secret = hmac.new(
293+
b"openclaw-mattermost-interactions",
294+
bot_token.encode(), hashlib.sha256
295+
).hexdigest()
296+
297+
ctx = {"action_id": "mybutton01", "action": "approve"}
298+
payload = json.dumps(ctx, sort_keys=True, separators=(",", ":"))
299+
token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
300+
301+
context = {**ctx, "_token": token}
302+
```
303+
304+
Common HMAC pitfalls:
305+
306+
- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use
307+
`separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`).
308+
- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then
309+
signs everything remaining. Signing a subset causes silent verification failure.
310+
- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may
311+
reorder context fields when storing the payload.
312+
- Derive the secret from the bot token (deterministic), not random bytes. The secret
313+
must be the same across the process that creates buttons and the gateway that verifies.
314+
315+
## Directory adapter
316+
317+
The Mattermost plugin includes a directory adapter that resolves channel and user names
318+
via the Mattermost API. This enables `#channel-name` and `@username` targets in
319+
`openclaw message send` and cron/webhook deliveries.
320+
321+
No configuration is needed — the adapter uses the bot token from the account config.
322+
178323
## Multi-account
179324

180325
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
@@ -197,3 +342,10 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`:
197342
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
198343
- Auth errors: check the bot token, base URL, and whether the account is enabled.
199344
- Multi-account issues: env vars only apply to the `default` account.
345+
- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields.
346+
- 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.
347+
- 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.
348+
- 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.
349+
- 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.
350+
- 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.
351+
- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config.

extensions/mattermost/src/channel.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@ describe("mattermostPlugin", () => {
102102

103103
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
104104
expect(actions).toContain("react");
105-
expect(actions).not.toContain("send");
105+
expect(actions).toContain("send");
106106
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
107+
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
107108
});
108109

109110
it("hides react when mattermost is not configured", () => {
@@ -133,7 +134,7 @@ describe("mattermostPlugin", () => {
133134

134135
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
135136
expect(actions).not.toContain("react");
136-
expect(actions).not.toContain("send");
137+
expect(actions).toContain("send");
137138
});
138139

139140
it("respects per-account actions.reactions in listActions", () => {

0 commit comments

Comments
 (0)