feat(mattermost): add native slash command support#17302
feat(mattermost): add native slash command support#17302echo931 wants to merge 19 commits intoopenclaw:mainfrom
Conversation
Register custom slash commands via Mattermost REST API at startup,
handle callbacks via HTTP endpoint on the gateway, and clean up
commands on shutdown.
- New modules: slash-commands.ts (API + registration), slash-http.ts
(callback handler), slash-state.ts (shared state bridge)
- Config schema extended with commands.{native,nativeSkills,callbackPath,callbackUrl}
- Uses oc_ prefix for triggers (oc_status, oc_model, etc.) to avoid
conflicts with Mattermost built-in commands
- Opt-in via channels.mattermost.commands.native: true
- Capability nativeCommands: true exposed for command registry
Closes openclaw#16515
Address Codex review findings: 1. slash-http.ts: Token validation now rejects when commandTokens set is empty (e.g. registration failure). Previously an empty set meant any token was accepted — fail-open vulnerability. 2. slash-state.ts: Replaced global singleton with per-account state Map keyed by accountId. Multi-account deployments no longer overwrite each other's tokens, registered commands, or handlers. The HTTP route dispatcher matches inbound tokens to the correct account. 3. monitor.ts: Updated getSlashCommandState/deactivateSlashCommands calls to pass accountId.
Address Codex follow-up feedback: - Enforce DM/group allowlist + control-command gating for native slash commands (no unconditional CommandAuthorized=true). - Register callback HTTP routes for all configured callbackPath values (top-level + per-account overrides). - Track whether slash commands were created by this process; only delete managed commands on shutdown, leaving pre-existing commands intact.
- parseSlashCommandPayload JSON branch now validates required fields (token, team_id, channel_id, user_id, command) like the form-encoded branch, preventing runtime exceptions on malformed JSON payloads - normalizeCallbackPath() ensures leading '/' to prevent malformed URLs like 'http://host:portapi/...' when callbackPath lacks a leading slash - Applied in resolveSlashCommandConfig and resolveCallbackUrl Addresses Codex review round 3 (P2 items).
…RL reconciliation
- Reject slash command registration when callbackUrl resolves to
loopback but Mattermost baseUrl is remote; log actionable error
directing user to set commands.callbackUrl explicitly
- Honor commands.nativeSkills: when enabled, dynamically list skill
commands and register them with oc_ prefix alongside built-in commands
- Reconcile existing commands on callback URL change: attempt PUT update,
fallback to delete+recreate for stale commands with mismatched URLs
- Add updateMattermostCommand() for PUT /api/v4/commands/{id}
Addresses Codex review round 4 (P1 + P2 items).
…ackUrl pathname - normalizeAllowList/isSenderAllowed in slash-http.ts now matches the websocket monitor: strips mattermost:/user:/@ prefixes and supports the '*' wildcard, so configs that work for WS also work for slash cmds - registerSlashCommandRoute extracts pathname from explicit callbackUrl and registers it alongside callbackPath, so callbacks hit a registered route even when callbackUrl uses a non-default pathname Addresses Codex review round 5 (P1 + P2).
…rMap for accurate command name resolution - Export listSkillCommandsForAgents and SkillCommandSpec from plugin-sdk/index.ts (removes deep relative import in monitor.ts) - Add originalName field to MattermostCommandSpec for preserving pre-prefix names - Build trigger→originalName map in monitor.ts, thread through slash-state → slash-http - resolveCommandText() now uses triggerMap for accurate name lookup (oc_report → /oc_report correctly, not /report)
…nt race Snapshot registered commands, then deactivate state immediately on abort. Prevents race where new monitor activates fresh state that gets wiped by the delayed .then() of the old monitor's cleanup promise.
| const addMmCommands = (raw: unknown) => { | ||
| const commands = raw as Record<string, unknown> | undefined; | ||
| mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath)); | ||
| tryAddCallbackUrlPath(commands?.callbackUrl); | ||
| }; | ||
|
|
||
| addMmCommands(mmRaw?.commands); | ||
| const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>; | ||
| for (const accountId of Object.keys(accountsRaw)) { | ||
| const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined; | ||
| addMmCommands(accountCfg?.commands); | ||
| } |
There was a problem hiding this comment.
Default callback path always added to auth bypass set
addMmCommands is called unconditionally at line 391 with mmRaw?.commands. When mattermost has no commands config (the default, since slash commands are opt-in), commands is undefined, so normalizeCallbackPath(undefined?.callbackPath) returns the default path "/api/channels/mattermost/command". This means /api/channels/mattermost/command is always exempt from gateway auth — even when mattermost isn't configured or slash commands are disabled.
The immediate security risk is low (requests to that path fall through to the plugin handler which validates its own command token, and if no handler is registered they get a 404). But as a defense-in-depth measure, the bypass should only apply when slash commands are actually configured. Consider guarding with a check like:
| const addMmCommands = (raw: unknown) => { | |
| const commands = raw as Record<string, unknown> | undefined; | |
| mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath)); | |
| tryAddCallbackUrlPath(commands?.callbackUrl); | |
| }; | |
| addMmCommands(mmRaw?.commands); | |
| const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>; | |
| for (const accountId of Object.keys(accountsRaw)) { | |
| const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined; | |
| addMmCommands(accountCfg?.commands); | |
| } | |
| const addMmCommands = (raw: unknown) => { | |
| if (raw == null || typeof raw !== "object") return; | |
| const commands = raw as Record<string, unknown>; | |
| mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath)); | |
| tryAddCallbackUrlPath(commands?.callbackUrl); | |
| }; | |
| addMmCommands(mmRaw?.commands); | |
| const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>; | |
| for (const accountId of Object.keys(accountsRaw)) { | |
| const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined; | |
| addMmCommands(accountCfg?.commands); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-http.ts
Line: 385:396
Comment:
**Default callback path always added to auth bypass set**
`addMmCommands` is called unconditionally at line 391 with `mmRaw?.commands`. When mattermost has no `commands` config (the default, since slash commands are opt-in), `commands` is `undefined`, so `normalizeCallbackPath(undefined?.callbackPath)` returns the default path `"/api/channels/mattermost/command"`. This means `/api/channels/mattermost/command` is always exempt from gateway auth — even when mattermost isn't configured or slash commands are disabled.
The immediate security risk is low (requests to that path fall through to the plugin handler which validates its own command token, and if no handler is registered they get a 404). But as a defense-in-depth measure, the bypass should only apply when slash commands are actually configured. Consider guarding with a check like:
```suggestion
const addMmCommands = (raw: unknown) => {
if (raw == null || typeof raw !== "object") return;
const commands = raw as Record<string, unknown>;
mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath));
tryAddCallbackUrlPath(commands?.callbackUrl);
};
addMmCommands(mmRaw?.commands);
const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>;
for (const accountId of Object.keys(accountsRaw)) {
const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined;
addMmCommands(accountCfg?.commands);
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1c9f43f45
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| id: token | ||
| shell: bash | ||
| run: | | ||
| echo "token=${{ steps.app-token.outputs.token || github.token }}" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
Handle 403 when falling back to GITHUB_TOKEN
This backfill job now falls back to github.token when GH_APP_PRIVATE_KEY is absent, but the contributor-label script in the same job still treats non-404 membership lookup failures as fatal. In that fallback mode, teams.getMembershipForUserInOrg commonly returns 403 for GITHUB_TOKEN, so workflow_dispatch backfills can abort on the first PR instead of completing (unlike the other updated jobs, which already downgrade 403 to a warning).
Useful? React with 👍 / 👎.
| } catch (err) { | ||
| runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`); | ||
| } |
There was a problem hiding this comment.
Clean up partial slash setup when team registration fails
The outer registration block logs and exits on the first thrown error, but by this point earlier teams may already have had commands created. In that partial-success scenario (e.g., one team lacks command permissions), activateSlashCommands is never reached, so callbacks return the “not initialized” 503 response while newly created commands remain stranded because shutdown cleanup reads from slash state that was never activated.
Useful? React with 👍 / 👎.
|
Applied fixes for Greptile+Codex feedback; tests/lint clean.
Commit: 1716ff2 @codex review |
|
Codex Review: Didn't find any major issues. Hooray! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
bfc1ccb to
f92900f
Compare
|
Thanks for the maintainer sweep — updated PR description to align with
Validation status right now:
|
|
Follow-up: full local validation is now complete ✅
PR is aligned with |
|
Closing this stale branch because it is heavily behind main and no longer up-to-date for review.\n\nI will reopen a fresh PR from current main for this feature/bug once rebased/reimplemented cleanly. |
What
Adds opt-in native Mattermost slash command support to OpenClaw, including command registration, callback handling, and account-aware routing.
Why
Mattermost users need first-class slash commands (e.g.
/oc_status) without relying on external bridges. This keeps UX consistent with other channels while preserving fail-closed auth/token checks.Scope
Single theme PR: Mattermost native slash-command support + related hardening needed to safely ship it (auth bypass precision, command registration cleanup, callback URL handling, and labeler fallback robustness required for CI/backfill in this workflow).
Validation
pnpm buildpnpm checkpnpm test(full suite in progress during maintainer sweep; no failures observed so far in this run)AI assistance transparency
This PR was heavily assisted by LLMs.
Closes #16515.
Previous context from fork: 0mg-cc#10
Greptile Summary
This PR adds opt-in native Mattermost slash command support, enabling OpenClaw to register custom slash commands (e.g.,
/oc_status,/oc_model) via the Mattermost REST API and handle incoming command callbacks through an HTTP endpoint on the gateway.Last reviewed commit: f1c9f43