Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
13
extensions/mattermost/README.md
Normal file
13
extensions/mattermost/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Mattermost OpenClaw channel
|
||||
|
||||
Official OpenClaw channel plugin for Mattermost.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/mattermost
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/channels/mattermost.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/mattermost`.
|
||||
3
extensions/mattermost/api.ts
Normal file
3
extensions/mattermost/api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
|
||||
// channel plugin (and its runtime state) into tests or other shared surfaces.
|
||||
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
|
||||
4
extensions/mattermost/channel-plugin-api.ts
Normal file
4
extensions/mattermost/channel-plugin-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag the broader Mattermost helper surfaces into lightweight plugin loads.
|
||||
export { mattermostPlugin } from "./channel-plugin-runtime.js";
|
||||
export { mattermostSetupPlugin } from "./src/channel.setup.js";
|
||||
4
extensions/mattermost/channel-plugin-runtime.ts
Normal file
4
extensions/mattermost/channel-plugin-runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Private runtime-bearing plugin export for the bundled Mattermost entry.
|
||||
// Keep the actual channel plugin value off the lighter channel-plugin-api seam
|
||||
// so bootstrap can lazy-load it without tripping bundle init cycles.
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
2
extensions/mattermost/doctor-contract-api.ts
Normal file
2
extensions/mattermost/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
2
extensions/mattermost/gateway-auth-api.ts
Normal file
2
extensions/mattermost/gateway-auth-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export { resolveMattermostGatewayAuthBypassPaths as resolveGatewayAuthBypassPaths } from "./src/gateway-auth-bypass.js";
|
||||
38
extensions/mattermost/index.ts
Normal file
38
extensions/mattermost/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Mattermost plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
defineBundledChannelEntry,
|
||||
loadBundledEntryExportSync,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
function registerSlashCommandRoute(api: OpenClawPluginApi): void {
|
||||
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
|
||||
specifier: "./slash-route-api.js",
|
||||
exportName: "registerSlashCommandRoute",
|
||||
});
|
||||
register(api);
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "mattermost",
|
||||
name: "Mattermost",
|
||||
description: "Mattermost channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "mattermostPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMattermostRuntime",
|
||||
},
|
||||
registerFull(api) {
|
||||
// Actual slash-command registration happens after the monitor connects and
|
||||
// knows the team id; the route itself can be wired here.
|
||||
registerSlashCommandRoute(api);
|
||||
},
|
||||
});
|
||||
54
extensions/mattermost/npm-shrinkwrap.json
generated
Normal file
54
extensions/mattermost/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
extensions/mattermost/openclaw.plugin.json
Normal file
16
extensions/mattermost/openclaw.plugin.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "mattermost",
|
||||
"icon": "https://cdn.simpleicons.org/mattermost",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["mattermost"],
|
||||
"channelEnvVars": {
|
||||
"mattermost": ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
59
extensions/mattermost/package.json
Normal file
59
extensions/mattermost/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "mattermost",
|
||||
"label": "Mattermost",
|
||||
"selectionLabel": "Mattermost (plugin)",
|
||||
"docsPath": "/channels/mattermost",
|
||||
"docsLabel": "mattermost",
|
||||
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
"order": 65
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/mattermost",
|
||||
"npmSpec": "@openclaw/mattermost",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9",
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
2
extensions/mattermost/policy-api.ts
Normal file
2
extensions/mattermost/policy-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
|
||||
84
extensions/mattermost/runtime-api.ts
Normal file
84
extensions/mattermost/runtime-api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Private runtime barrel for the bundled Mattermost extension.
|
||||
// Keep this barrel thin and generic-only.
|
||||
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelPlugin,
|
||||
ChatType,
|
||||
HistoryEntry,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createDedupeCache,
|
||||
parseStrictPositiveInteger,
|
||||
resolveClientIp,
|
||||
isTrustedProxyAddress,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-status";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export {
|
||||
listSkillCommandsForAgents,
|
||||
resolveControlCommandGate,
|
||||
resolveStoredModelOverride,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
// Legacy map-helper exports stay for older plugin consumers. New message-turn
|
||||
// code should use createChannelHistoryWindow.
|
||||
export {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
export { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
|
||||
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
resolveChannelMediaMaxBytes,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
export { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
export { setMattermostRuntime } from "./src/runtime.js";
|
||||
6
extensions/mattermost/secret-contract-api.ts
Normal file
6
extensions/mattermost/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
14
extensions/mattermost/setup-entry.ts
Normal file
14
extensions/mattermost/setup-entry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Mattermost plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "mattermostSetupPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
2
extensions/mattermost/slash-route-api.ts
Normal file
2
extensions/mattermost/slash-route-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export { registerSlashCommandRoute } from "./src/mattermost/slash-state.js";
|
||||
29
extensions/mattermost/src/approval-auth.test.ts
Normal file
29
extensions/mattermost/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Mattermost tests cover approval auth plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("mattermostApprovalAuth", () => {
|
||||
it("authorizes stable Mattermost user ids and ignores usernames", () => {
|
||||
expect(
|
||||
mattermostApprovalAuth.authorizeActorAction({
|
||||
cfg: {
|
||||
channels: { mattermost: { allowFrom: ["user:abcdefghijklmnopqrstuvwxyz"] } },
|
||||
},
|
||||
senderId: "abcdefghijklmnopqrstuvwxyz",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
mattermostApprovalAuth.authorizeActorAction({
|
||||
cfg: {
|
||||
channels: { mattermost: { allowFrom: ["@owner"] } },
|
||||
},
|
||||
senderId: "attacker-user-id",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
31
extensions/mattermost/src/approval-auth.ts
Normal file
31
extensions/mattermost/src/approval-auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Mattermost plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
|
||||
const MATTERMOST_USER_ID_RE = /^[a-z0-9]{26}$/;
|
||||
|
||||
function normalizeMattermostApproverId(value: string | number): string | undefined {
|
||||
const normalized = String(value)
|
||||
.trim()
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim();
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
return MATTERMOST_USER_ID_RE.test(lowered) ? lowered : undefined;
|
||||
}
|
||||
|
||||
export const mattermostApprovalAuth = createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Mattermost",
|
||||
resolveApprovers: ({ cfg, accountId }) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId }).config;
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.allowFrom,
|
||||
normalizeApprover: normalizeMattermostApproverId,
|
||||
});
|
||||
},
|
||||
normalizeSenderId: (value) => normalizeMattermostApproverId(value),
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Mattermost tests cover channel actions setup status.contract plugin behavior.
|
||||
import {
|
||||
installChannelActionsContractSuite,
|
||||
installChannelSetupContractSuite,
|
||||
installChannelStatusContractSuite,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect } from "vitest";
|
||||
import { mattermostPlugin, mattermostSetupPlugin } from "../channel-plugin-api.js";
|
||||
|
||||
describe("mattermost actions contract", () => {
|
||||
installChannelActionsContractSuite({
|
||||
plugin: mattermostPlugin,
|
||||
unsupportedAction: "poll",
|
||||
cases: [
|
||||
{
|
||||
name: "configured account exposes send and react",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send", "react"],
|
||||
expectedCapabilities: ["presentation"],
|
||||
},
|
||||
{
|
||||
name: "reactions can be disabled while send stays available",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send"],
|
||||
expectedCapabilities: ["presentation"],
|
||||
},
|
||||
{
|
||||
name: "missing bot credentials disables the actions surface",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [],
|
||||
expectedCapabilities: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("mattermost setup contract", () => {
|
||||
installChannelSetupContractSuite({
|
||||
plugin: mattermostSetupPlugin,
|
||||
cases: [
|
||||
{
|
||||
name: "default account stores token and normalized base URL",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
botToken: "test-token",
|
||||
httpUrl: "https://chat.example.com/",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
assertPatchedConfig: (cfg) => {
|
||||
const mattermostConfig = cfg.channels?.mattermost;
|
||||
if (!mattermostConfig) {
|
||||
throw new Error("expected Mattermost config patch");
|
||||
}
|
||||
expect(mattermostConfig.enabled).toBe(true);
|
||||
expect(mattermostConfig.botToken).toBe("test-token");
|
||||
expect(mattermostConfig.baseUrl).toBe("https://chat.example.com");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing credentials are rejected",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
httpUrl: "",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("mattermost status contract", () => {
|
||||
installChannelStatusContractSuite({
|
||||
plugin: mattermostPlugin,
|
||||
cases: [
|
||||
{
|
||||
name: "configured account preserves connectivity details in the snapshot",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {
|
||||
accountId: "default",
|
||||
connected: true,
|
||||
lastConnectedAt: 1234,
|
||||
},
|
||||
probe: { ok: true },
|
||||
assertSnapshot: (snapshot) => {
|
||||
expect(snapshot.accountId).toBe("default");
|
||||
expect(snapshot.enabled).toBe(true);
|
||||
expect(snapshot.configured).toBe(true);
|
||||
expect(snapshot.connected).toBe(true);
|
||||
expect(snapshot.baseUrl).toBe("https://chat.example.com");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
5
extensions/mattermost/src/channel-api.ts
Normal file
5
extensions/mattermost/src/channel-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
80
extensions/mattermost/src/channel-config-shared.ts
Normal file
80
extensions/mattermost/src/channel-config-shared.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Mattermost helper module supports channel config shared behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveMattermostGatewayAuthBypassPaths } from "./gateway-auth-bypass.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
|
||||
export const mattermostMeta = {
|
||||
id: "mattermost",
|
||||
label: "Mattermost",
|
||||
selectionLabel: "Mattermost (plugin)",
|
||||
detailLabel: "Mattermost Bot",
|
||||
docsPath: "/channels/mattermost",
|
||||
docsLabel: "mattermost",
|
||||
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
order: 65,
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(
|
||||
entry
|
||||
.trim()
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
function formatMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
return username ? `@${normalizeLowercaseStringOrEmpty(username)}` : "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, ""));
|
||||
}
|
||||
|
||||
export { resolveMattermostGatewayAuthBypassPaths };
|
||||
|
||||
export const mattermostConfigAdapter = createScopedChannelConfigAdapter<ResolvedMattermostAccount>({
|
||||
sectionKey: "mattermost",
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveMattermostAccount),
|
||||
defaultAccountId: resolveDefaultMattermostAccountId,
|
||||
clearBaseFields: ["botToken", "baseUrl", "name"],
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: formatMattermostAllowEntry,
|
||||
}),
|
||||
});
|
||||
|
||||
export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean {
|
||||
return Boolean(account.botToken && account.baseUrl);
|
||||
}
|
||||
|
||||
export function describeMattermostAccount(account: ResolvedMattermostAccount) {
|
||||
return describeAccountSnapshot({
|
||||
account,
|
||||
configured: isMattermostConfigured(account),
|
||||
extra: {
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
256
extensions/mattermost/src/channel.message-adapter.test.ts
Normal file
256
extensions/mattermost/src/channel.message-adapter.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Mattermost tests cover channel.message adapter plugin behavior.
|
||||
import {
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageMattermostMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./mattermost/send.js", () => ({
|
||||
sendMessageMattermost: sendMessageMattermostMock,
|
||||
}));
|
||||
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
|
||||
type MattermostMessageAdapter = NonNullable<typeof mattermostPlugin.message>;
|
||||
type MattermostMessageSender = NonNullable<MattermostMessageAdapter["send"]>;
|
||||
|
||||
function requireMattermostMessageAdapter(): MattermostMessageAdapter {
|
||||
const adapter = mattermostPlugin.message;
|
||||
if (!adapter) {
|
||||
throw new Error("Expected mattermost plugin to expose a channel message adapter");
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function requireTextSender(
|
||||
adapter: MattermostMessageAdapter,
|
||||
): NonNullable<MattermostMessageSender["text"]> {
|
||||
const text = adapter.send?.text;
|
||||
if (!text) {
|
||||
throw new Error("Expected mattermost message adapter text sender");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireMediaSender(
|
||||
adapter: MattermostMessageAdapter,
|
||||
): NonNullable<MattermostMessageSender["media"]> {
|
||||
const media = adapter.send?.media;
|
||||
if (!media) {
|
||||
throw new Error("Expected mattermost message adapter media sender");
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function requirePayloadSender(
|
||||
adapter: MattermostMessageAdapter,
|
||||
): NonNullable<MattermostMessageSender["payload"]> {
|
||||
const payload = adapter.send?.payload;
|
||||
if (!payload) {
|
||||
throw new Error("Expected mattermost message adapter payload sender");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
describe("mattermost channel message adapter", () => {
|
||||
beforeAll(async () => {
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
messageId: "warmup-post",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
await requireTextSender(requireMattermostMessageAdapter())({
|
||||
cfg: {},
|
||||
to: "channel:warmup",
|
||||
text: "warmup",
|
||||
accountId: "default",
|
||||
});
|
||||
sendMessageMattermostMock.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendMessageMattermostMock.mockReset();
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
messageId: "post-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("declares durable-final capabilities covered by outbound proof tests", async () => {
|
||||
const adapter = requireMattermostMessageAdapter();
|
||||
const sendPayload = requirePayloadSender(adapter);
|
||||
|
||||
const provePayload = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
sendMessageMattermostMock.mockResolvedValueOnce({
|
||||
messageId: "post-1",
|
||||
channelId: "channel-1",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "post-1",
|
||||
platformMessageIds: ["post-1"],
|
||||
parts: [{ platformMessageId: "post-1", kind: "card", index: 0 }],
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const result = await sendPayload({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "card",
|
||||
accountId: "default",
|
||||
payload: {
|
||||
text: "card",
|
||||
channelData: {
|
||||
mattermost: {
|
||||
presentationButtons: [[{ text: "Open", callback_data: "open" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "card", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaUrl: undefined,
|
||||
mediaLocalRoots: undefined,
|
||||
mediaReadFile: undefined,
|
||||
replyToId: undefined,
|
||||
buttons: [[{ text: "Open", callback_data: "open" }]],
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("card");
|
||||
};
|
||||
|
||||
await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "mattermostMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
payload: provePayload,
|
||||
text: () => undefined,
|
||||
media: () => undefined,
|
||||
replyTo: () => undefined,
|
||||
thread: () => undefined,
|
||||
messageSendingHooks: () => {
|
||||
expect(requireTextSender(adapter)).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends text through Mattermost", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("sends media through Mattermost", async () => {
|
||||
const sendMedia = requireMediaSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendMedia({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.parts[0]?.kind).toBe("media");
|
||||
});
|
||||
|
||||
it("maps thread ids to Mattermost reply targets", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "threaded",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "thread-1",
|
||||
});
|
||||
expect(result.receipt.threadId).toBe("thread-1");
|
||||
});
|
||||
|
||||
it("prefers explicit Mattermost reply ids over thread ids", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "reply",
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
});
|
||||
expect(result.receipt.replyToId).toBe("post-parent-1");
|
||||
});
|
||||
|
||||
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
|
||||
const adapter = requireMattermostMessageAdapter();
|
||||
const sendText = requireTextSender(adapter);
|
||||
|
||||
await verifyChannelMessageLiveCapabilityAdapterProofs({
|
||||
adapterName: "mattermostMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
draftPreview: () => {
|
||||
expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true);
|
||||
},
|
||||
previewFinalization: () => {
|
||||
expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true);
|
||||
},
|
||||
progressUpdates: () => {
|
||||
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await verifyChannelMessageLiveFinalizerProofs({
|
||||
adapterName: "mattermostMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
finalEdit: () => {
|
||||
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
|
||||
},
|
||||
normalFallback: () => {
|
||||
expect(sendText).toBeTypeOf("function");
|
||||
},
|
||||
discardPending: () => {
|
||||
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
10
extensions/mattermost/src/channel.runtime.ts
Normal file
10
extensions/mattermost/src/channel.runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Mattermost plugin module implements channel behavior.
|
||||
export {
|
||||
listMattermostDirectoryGroups,
|
||||
listMattermostDirectoryPeers,
|
||||
} from "./mattermost/directory.js";
|
||||
export { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
export { probeMattermost } from "./mattermost/probe.js";
|
||||
export { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||
export { sendMessageMattermost } from "./mattermost/send.js";
|
||||
export { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||
39
extensions/mattermost/src/channel.setup.ts
Normal file
39
extensions/mattermost/src/channel.setup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Mattermost plugin module implements channel.setup behavior.
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import {
|
||||
describeMattermostAccount,
|
||||
isMattermostConfigured,
|
||||
mattermostConfigAdapter,
|
||||
mattermostMeta,
|
||||
resolveMattermostGatewayAuthBypassPaths,
|
||||
} from "./channel-config-shared.js";
|
||||
import { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
import type { ResolvedMattermostAccount } from "./mattermost/accounts.js";
|
||||
import { mattermostSetupAdapter } from "./setup-core.js";
|
||||
import { mattermostSetupWizard } from "./setup-surface.js";
|
||||
|
||||
export const mattermostSetupPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
...mattermostMeta,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
configSchema: MattermostChannelConfigSchema,
|
||||
config: {
|
||||
...mattermostConfigAdapter,
|
||||
isConfigured: isMattermostConfigured,
|
||||
describeAccount: describeMattermostAccount,
|
||||
},
|
||||
gateway: {
|
||||
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
|
||||
},
|
||||
setup: mattermostSetupAdapter,
|
||||
setupWizard: mattermostSetupWizard,
|
||||
};
|
||||
1423
extensions/mattermost/src/channel.test.ts
Normal file
1423
extensions/mattermost/src/channel.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
941
extensions/mattermost/src/channel.ts
Normal file
941
extensions/mattermost/src/channel.ts
Normal file
@@ -0,0 +1,941 @@
|
||||
// Mattermost plugin module implements channel behavior.
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
attachChannelToResult,
|
||||
createAttachedChannelResultAdapter,
|
||||
type ChannelOutboundAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
type MessagePresentation,
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
resolveMessagePresentationControlValue,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolvePayloadMediaUrls, sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { mattermostApprovalAuth } from "./approval-auth.js";
|
||||
import {
|
||||
chunkTextForOutbound,
|
||||
createAccountStatusSink,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import {
|
||||
describeMattermostAccount,
|
||||
isMattermostConfigured,
|
||||
mattermostConfigAdapter,
|
||||
mattermostMeta as meta,
|
||||
normalizeMattermostAllowEntry as normalizeAllowEntry,
|
||||
resolveMattermostGatewayAuthBypassPaths,
|
||||
} from "./channel-config-shared.js";
|
||||
import { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
import { mattermostDoctor } from "./doctor.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
resolveMattermostReplyToMode,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
|
||||
import { mattermostSetupAdapter } from "./setup-core.js";
|
||||
import { mattermostSetupWizard } from "./setup-surface.js";
|
||||
import type { MattermostConfig } from "./types.js";
|
||||
|
||||
const loadMattermostChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
|
||||
function buildMattermostPresentationButtons(presentation: MessagePresentation) {
|
||||
return presentation.blocks
|
||||
.filter((block) => block.type === "buttons")
|
||||
.map((block) =>
|
||||
block.buttons.flatMap((button) => {
|
||||
if (button.action) {
|
||||
return [];
|
||||
}
|
||||
const value = resolveMessagePresentationControlValue(button);
|
||||
return value
|
||||
? [
|
||||
{
|
||||
id: value,
|
||||
text: button.label,
|
||||
callback_data: value,
|
||||
context: {
|
||||
callback_data: value,
|
||||
},
|
||||
style: button.style,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.filter((row) => row.length > 0);
|
||||
}
|
||||
|
||||
const MATTERMOST_PRESENTATION_CAPABILITIES = {
|
||||
supported: true,
|
||||
buttons: true,
|
||||
selects: false,
|
||||
context: true,
|
||||
divider: false,
|
||||
limits: {
|
||||
text: {
|
||||
markdownDialect: "markdown",
|
||||
},
|
||||
},
|
||||
} satisfies ChannelOutboundAdapter["presentationCapabilities"];
|
||||
|
||||
function hasMattermostPresentationButtons(presentation: MessagePresentation): boolean {
|
||||
return buildMattermostPresentationButtons(presentation).some((row) => row.length > 0);
|
||||
}
|
||||
|
||||
function readMattermostPresentationButtons(payload: {
|
||||
channelData?: Record<string, unknown>;
|
||||
}): Array<unknown> | undefined {
|
||||
const buttons = (payload.channelData?.mattermost as { presentationButtons?: unknown } | undefined)
|
||||
?.presentationButtons;
|
||||
return Array.isArray(buttons) ? buttons : undefined;
|
||||
}
|
||||
|
||||
type MattermostDirectoryListParams = Parameters<
|
||||
NonNullable<NonNullable<ChannelPlugin["directory"]>["listGroups"]>
|
||||
>[0];
|
||||
|
||||
const mattermostSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedMattermostAccount>({
|
||||
channelKey: "mattermost",
|
||||
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||
resolveDmAllowFrom: (account) => account.config.allowFrom,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
surface: "Mattermost channels",
|
||||
openScope: "any member",
|
||||
groupPolicyPath: "channels.mattermost.groupPolicy",
|
||||
groupAllowFromPath: "channels.mattermost.groupAllowFrom",
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeDmEntry: (raw) => normalizeAllowEntry(raw),
|
||||
});
|
||||
|
||||
function describeMattermostMessageTool({
|
||||
cfg,
|
||||
accountId,
|
||||
}: Parameters<
|
||||
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
|
||||
>[0]): ChannelMessageToolDiscovery {
|
||||
const enabledAccounts = (
|
||||
accountId
|
||||
? [resolveMattermostAccount({ cfg, accountId })]
|
||||
: listMattermostAccountIds(cfg).map((listedAccountId) =>
|
||||
resolveMattermostAccount({ cfg, accountId: listedAccountId }),
|
||||
)
|
||||
)
|
||||
.filter((account) => account.enabled)
|
||||
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
|
||||
|
||||
const actions: ChannelMessageActionName[] = [];
|
||||
|
||||
if (enabledAccounts.length > 0) {
|
||||
actions.push("send");
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
if (hasReactionCapableAccount) {
|
||||
actions.push("react");
|
||||
}
|
||||
|
||||
return {
|
||||
actions,
|
||||
capabilities: enabledAccounts.length > 0 ? ["presentation"] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function hasConfiguredMattermostDirectoryAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
}: Pick<MattermostDirectoryListParams, "cfg" | "accountId">): boolean {
|
||||
const accounts = accountId
|
||||
? [resolveMattermostAccount({ cfg, accountId })]
|
||||
: listMattermostAccountIds(cfg).map((listedAccountId) =>
|
||||
resolveMattermostAccount({ cfg, accountId: listedAccountId }),
|
||||
);
|
||||
return accounts.some((account) =>
|
||||
Boolean(account.enabled && account.botToken?.trim() && account.baseUrl?.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function extractMattermostToolSend(args: Record<string, unknown>): ChannelToolSend | null {
|
||||
if (normalizeOptionalString(args.action) !== "send") {
|
||||
return null;
|
||||
}
|
||||
const to = normalizeOptionalString(args.to) ?? normalizeOptionalString(args.target);
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const threadId =
|
||||
normalizeOptionalString(args.threadId) ??
|
||||
normalizeOptionalString(args.replyToId) ??
|
||||
normalizeOptionalString(args.replyTo);
|
||||
const threadSuppressed = args.topLevel === true || args.threadId === null;
|
||||
return {
|
||||
to,
|
||||
accountId: normalizeOptionalString(args.accountId),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(!threadId && !threadSuppressed ? { threadImplicit: true } : {}),
|
||||
...(threadSuppressed ? { threadSuppressed: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractMattermostToolSendResult(
|
||||
result: unknown,
|
||||
send: ChannelToolSend,
|
||||
): ChannelToolSend | null {
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
const details = (result as { details?: unknown }).details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
const toolSend = (details as { toolSend?: unknown }).toolSend;
|
||||
if (!toolSend || typeof toolSend !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = toolSend as Record<string, unknown>;
|
||||
const to = normalizeOptionalString(record.to);
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const threadId = normalizeOptionalString(record.threadId);
|
||||
const originalTarget = normalizeOptionalString(send.to);
|
||||
const preserveOriginalTarget =
|
||||
originalTarget?.startsWith("user:") === true || originalTarget?.startsWith("@") === true;
|
||||
return {
|
||||
to: preserveOriginalTarget ? originalTarget : to,
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMattermostAutoThreadId(params: {
|
||||
to: string;
|
||||
replyToId?: string | null;
|
||||
toolContext?: {
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
}): string | undefined {
|
||||
const replyToId = normalizeOptionalString(params.replyToId);
|
||||
const context = params.toolContext;
|
||||
const currentThreadId = normalizeOptionalString(context?.currentThreadTs);
|
||||
const currentMessageId =
|
||||
typeof context?.currentMessageId === "number"
|
||||
? String(context.currentMessageId)
|
||||
: normalizeOptionalString(context?.currentMessageId);
|
||||
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
|
||||
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
|
||||
if (replyToId === currentMessageId) {
|
||||
return currentThreadId;
|
||||
}
|
||||
if (!replyToId) {
|
||||
const replyToMode = context?.replyToMode;
|
||||
const canInheritThread =
|
||||
replyToMode === "all" ||
|
||||
(replyToMode === "first" && context?.hasRepliedRef?.value !== true);
|
||||
return canInheritThread ? currentThreadId : undefined;
|
||||
}
|
||||
}
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
function normalizeMattermostThreadTarget(raw: string | undefined): string | undefined {
|
||||
const normalized = raw ? normalizeMattermostMessagingTarget(raw) : undefined;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
return trimmed && /^[a-z0-9]{26}$/i.test(trimmed) ? `channel:${trimmed}` : undefined;
|
||||
}
|
||||
|
||||
function matchesMattermostToolContextTarget(params: {
|
||||
target: string;
|
||||
toolContext: ChannelThreadingToolContext;
|
||||
}): boolean {
|
||||
const target = normalizeMattermostThreadTarget(params.target);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return [params.toolContext.currentChannelId, params.toolContext.currentMessagingTarget].some(
|
||||
(currentTarget) => normalizeMattermostThreadTarget(currentTarget) === target,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
|
||||
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
function buildMattermostThreadingToolContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): ChannelThreadingToolContext {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
|
||||
});
|
||||
const chatType =
|
||||
params.context.ChatType === "direct" ||
|
||||
params.context.ChatType === "group" ||
|
||||
params.context.ChatType === "channel"
|
||||
? params.context.ChatType
|
||||
: "channel";
|
||||
const configuredReplyToMode = resolveMattermostReplyToMode(account, chatType);
|
||||
const currentThreadTs =
|
||||
normalizeMattermostThreadId(params.context.MessageThreadId) ??
|
||||
normalizeMattermostThreadId(params.context.TransportThreadId) ??
|
||||
normalizeOptionalString(params.context.ReplyToId);
|
||||
const currentMessageId = normalizeMattermostThreadId(params.context.CurrentMessageId);
|
||||
const hasExistingThread =
|
||||
Boolean(currentThreadTs) && (!currentMessageId || currentThreadTs !== currentMessageId);
|
||||
const currentChannelId = params.context.To
|
||||
? normalizeMattermostMessagingTarget(params.context.To)
|
||||
: undefined;
|
||||
return {
|
||||
currentChannelId,
|
||||
currentThreadTs,
|
||||
currentMessageId: params.context.CurrentMessageId,
|
||||
replyToMode: hasExistingThread ? "all" : configuredReplyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sameChannelThreadRequired: Boolean(currentThreadTs),
|
||||
};
|
||||
}
|
||||
|
||||
async function listMattermostDirectoryGroups(params: MattermostDirectoryListParams) {
|
||||
if (!hasConfiguredMattermostDirectoryAccount(params)) {
|
||||
return [];
|
||||
}
|
||||
return (await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params);
|
||||
}
|
||||
|
||||
async function listMattermostDirectoryPeers(params: MattermostDirectoryListParams) {
|
||||
if (!hasConfiguredMattermostDirectoryAccount(params)) {
|
||||
return [];
|
||||
}
|
||||
return (await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params);
|
||||
}
|
||||
|
||||
const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: describeMattermostMessageTool,
|
||||
extractToolSend: ({ args }) => extractMattermostToolSend(args),
|
||||
extractToolSendResult: ({ result, send }) => extractMattermostToolSendResult(result, send),
|
||||
supportsAction: ({ action }) => {
|
||||
return action === "send" || action === "react";
|
||||
},
|
||||
handleAction: async ({
|
||||
action,
|
||||
params,
|
||||
cfg,
|
||||
accountId,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
}) => {
|
||||
if (action === "react") {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
|
||||
const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined;
|
||||
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
|
||||
const reactionsEnabled =
|
||||
account.config.actions?.reactions ?? mattermostConfig?.actions?.reactions ?? true;
|
||||
if (!reactionsEnabled) {
|
||||
throw new Error("Mattermost reactions are disabled in config");
|
||||
}
|
||||
|
||||
const { postId, emojiName, remove } = parseMattermostReactActionParams(params);
|
||||
if (remove) {
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).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 (
|
||||
await loadMattermostChannelRuntime()
|
||||
).addMattermostReaction({
|
||||
cfg,
|
||||
postId,
|
||||
emojiName,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (action !== "send") {
|
||||
throw new Error(`Unsupported Mattermost action: ${action}`);
|
||||
}
|
||||
|
||||
// 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 presentation = normalizeMessagePresentation(params.presentation);
|
||||
const message = presentation
|
||||
? renderMessagePresentationFallbackText({
|
||||
text: typeof params.message === "string" ? params.message : "",
|
||||
presentation,
|
||||
})
|
||||
: typeof params.message === "string"
|
||||
? params.message
|
||||
: "";
|
||||
// Mattermost post root_id is the thread root. A generic replyTo can name
|
||||
// the current child post, so prefer threadId unless the caller supplied the
|
||||
// Mattermost-specific replyToId root directly.
|
||||
const replyToId =
|
||||
normalizeOptionalString(params.replyToId) ??
|
||||
normalizeOptionalString(params.threadId) ??
|
||||
normalizeOptionalString(params.replyTo);
|
||||
const resolvedAccountId = accountId || undefined;
|
||||
|
||||
const attachmentMedia = collectMattermostAttachmentMedia(params);
|
||||
if (attachmentMedia.hasUnsupportedAttachmentPayload) {
|
||||
throw new Error(
|
||||
"Mattermost send attachments require media, mediaUrl, path, filePath, fileUrl, mediaUrls, or attachments[] with one of those fields; buffer/base64 payloads are not supported.",
|
||||
);
|
||||
}
|
||||
if (attachmentMedia.mediaUrls.length > 1) {
|
||||
throw new Error(
|
||||
"Mattermost send supports one attachment per message; split multiple mediaUrls or attachments[] entries into separate sends.",
|
||||
);
|
||||
}
|
||||
const buttons = presentation ? buildMattermostPresentationButtons(presentation) : [];
|
||||
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, message, {
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
replyToId,
|
||||
buttons: buttons.length > 0 ? buttons : undefined,
|
||||
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
|
||||
mediaUrl: attachmentMedia.mediaUrls[0],
|
||||
mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots,
|
||||
mediaReadFile: mediaReadFile ?? mediaAccess?.readFile,
|
||||
...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}),
|
||||
requireMediaUpload: requiresMattermostMediaUpload(attachmentMedia.mediaUrls[0])
|
||||
? true
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
channel: "mattermost",
|
||||
messageId: result.messageId,
|
||||
channelId: result.channelId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
toolSend: {
|
||||
to: `channel:${result.channelId}`,
|
||||
...(replyToId ? { threadId: replyToId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function parseMattermostReactActionParams(params: Record<string, unknown>): {
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
remove: boolean;
|
||||
} {
|
||||
const postId =
|
||||
normalizeOptionalString(params.messageId) ?? normalizeOptionalString(params.postId);
|
||||
if (!postId) {
|
||||
throw new Error("Mattermost react requires messageId (post id)");
|
||||
}
|
||||
|
||||
const emojiName = normalizeOptionalString(params.emoji)?.replace(/^:+|:+$/g, "");
|
||||
if (!emojiName) {
|
||||
throw new Error("Mattermost react requires emoji");
|
||||
}
|
||||
|
||||
return {
|
||||
postId,
|
||||
emojiName,
|
||||
remove: params.remove === true,
|
||||
};
|
||||
}
|
||||
|
||||
function collectNonBlankStrings(values: Array<string | undefined>): string[] {
|
||||
const collected: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed && !seen.has(trimmed)) {
|
||||
seen.add(trimmed);
|
||||
collected.push(trimmed);
|
||||
}
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
function toSnakeCaseKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function readMattermostParam(params: Record<string, unknown>, key: string): unknown {
|
||||
if (Object.hasOwn(params, key)) {
|
||||
return params[key];
|
||||
}
|
||||
const snakeKey = toSnakeCaseKey(key);
|
||||
return snakeKey === key || !Object.hasOwn(params, snakeKey) ? undefined : params[snakeKey];
|
||||
}
|
||||
|
||||
function readMattermostStringParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const raw = readMattermostParam(params, key);
|
||||
return typeof raw === "string" ? normalizeOptionalString(raw) : undefined;
|
||||
}
|
||||
|
||||
function readMattermostStringArrayParam(params: Record<string, unknown>, key: string): string[] {
|
||||
const raw = readMattermostParam(params, key);
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.flatMap((entry) => {
|
||||
const normalized = normalizeOptionalString(entry);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const normalized = normalizeOptionalString(raw);
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function requiresMattermostMediaUpload(mediaUrl: string | undefined): boolean {
|
||||
const normalized = normalizeOptionalString(mediaUrl);
|
||||
return Boolean(normalized && !/^https?:\/\//i.test(normalized));
|
||||
}
|
||||
|
||||
function collectMattermostAttachmentMedia(params: Record<string, unknown>): {
|
||||
mediaUrls: string[];
|
||||
hasUnsupportedAttachmentPayload: boolean;
|
||||
} {
|
||||
const mediaUrlCandidates: Array<string | undefined> = [
|
||||
readMattermostStringParam(params, "media"),
|
||||
readMattermostStringParam(params, "mediaUrl"),
|
||||
readMattermostStringParam(params, "path"),
|
||||
readMattermostStringParam(params, "filePath"),
|
||||
readMattermostStringParam(params, "fileUrl"),
|
||||
];
|
||||
mediaUrlCandidates.push(...readMattermostStringArrayParam(params, "mediaUrls"));
|
||||
|
||||
let hasUnsupportedAttachmentPayload =
|
||||
typeof params.buffer === "string" || typeof params.base64 === "string";
|
||||
if (Array.isArray(params.attachments)) {
|
||||
for (const attachment of params.attachments) {
|
||||
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
|
||||
continue;
|
||||
}
|
||||
const record = attachment as Record<string, unknown>;
|
||||
mediaUrlCandidates.push(
|
||||
readMattermostStringParam(record, "media"),
|
||||
readMattermostStringParam(record, "mediaUrl"),
|
||||
readMattermostStringParam(record, "path"),
|
||||
readMattermostStringParam(record, "filePath"),
|
||||
readMattermostStringParam(record, "fileUrl"),
|
||||
readMattermostStringParam(record, "url"),
|
||||
);
|
||||
hasUnsupportedAttachmentPayload ||= typeof record.buffer === "string";
|
||||
hasUnsupportedAttachmentPayload ||= typeof record.base64 === "string";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mediaUrls: collectNonBlankStrings(mediaUrlCandidates),
|
||||
hasUnsupportedAttachmentPayload,
|
||||
};
|
||||
}
|
||||
|
||||
const mattermostOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
deliveryCapabilities: {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
media: true,
|
||||
payload: true,
|
||||
replyTo: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
presentationCapabilities: MATTERMOST_PRESENTATION_CAPABILITIES,
|
||||
renderPresentation: ({ payload, presentation }) => {
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 1) {
|
||||
return null;
|
||||
}
|
||||
const buttons = buildMattermostPresentationButtons(presentation);
|
||||
if (!hasMattermostPresentationButtons(presentation)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
mattermost: {
|
||||
...(payload.channelData?.mattermost as Record<string, unknown> | undefined),
|
||||
presentationButtons: buttons,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
sendPayload: async (ctx) => {
|
||||
const buttons = readMattermostPresentationButtons(ctx.payload);
|
||||
if (buttons?.length) {
|
||||
const mediaUrl = resolvePayloadMediaUrls({
|
||||
...ctx.payload,
|
||||
mediaUrl: ctx.payload.mediaUrl ?? ctx.mediaUrl,
|
||||
})
|
||||
.map((url) => url.trim())
|
||||
.find(Boolean);
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(ctx.to, ctx.payload.text ?? ctx.text, {
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots,
|
||||
mediaReadFile: ctx.mediaReadFile ?? ctx.mediaAccess?.readFile,
|
||||
...(ctx.mediaAccess?.workspaceDir ? { workspaceDir: ctx.mediaAccess.workspaceDir } : {}),
|
||||
requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined,
|
||||
replyToId: ctx.replyToId ?? (ctx.threadId != null ? String(ctx.threadId) : undefined),
|
||||
buttons,
|
||||
});
|
||||
return attachChannelToResult("mattermost", result);
|
||||
}
|
||||
return await sendTextMediaPayload({ channel: "mattermost", ctx, adapter: mattermostOutbound });
|
||||
},
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "mattermost",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
}),
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}) =>
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots,
|
||||
mediaReadFile: mediaReadFile ?? mediaAccess?.readFile,
|
||||
...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}),
|
||||
requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const mattermostMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "mattermost",
|
||||
outbound: mattermostOutbound,
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
progressUpdates: true,
|
||||
},
|
||||
finalizer: {
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
normalFallback: true,
|
||||
discardPending: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
setup: mattermostSetupAdapter,
|
||||
setupWizard: mattermostSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
configSchema: MattermostChannelConfigSchema,
|
||||
config: {
|
||||
...mattermostConfigAdapter,
|
||||
isConfigured: isMattermostConfigured,
|
||||
describeAccount: describeMattermostAccount,
|
||||
},
|
||||
approvalCapability: mattermostApprovalAuth,
|
||||
doctor: mattermostDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
actions: mattermostMessageActions,
|
||||
message: mattermostMessageAdapter,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listGroups: listMattermostDirectoryGroups,
|
||||
listGroupsLive: listMattermostDirectoryGroups,
|
||||
listPeers: listMattermostDirectoryPeers,
|
||||
listPeersLive: listMattermostDirectoryPeers,
|
||||
}),
|
||||
messaging: {
|
||||
targetPrefixes: ["mattermost"],
|
||||
defaultMarkdownTableMode: "off",
|
||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
|
||||
const parent = parentConversationId?.trim();
|
||||
const child = conversationId.trim();
|
||||
return parent && parent !== child
|
||||
? { to: `channel:${parent}`, threadId: child }
|
||||
: { to: normalizeMattermostMessagingTarget(`channel:${child}`) };
|
||||
},
|
||||
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
resolveTarget: async ({ cfg, accountId, input }) => {
|
||||
const resolved = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: resolved.to,
|
||||
kind: resolved.kind,
|
||||
source: "directory",
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedMattermostAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
|
||||
connected: false,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnect: null,
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||
connected: snapshot.connected ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
return { ok: false, error: "bot token or baseUrl missing" };
|
||||
}
|
||||
return await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).probeMattermost(baseUrl, token, timeoutMs, isPrivateNetworkOptInEnabled(account.config));
|
||||
},
|
||||
resolveAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
extra: {
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
statusSink({
|
||||
baseUrl: account.baseUrl,
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||
return (await loadMattermostChannelRuntime()).monitorMattermostProvider({
|
||||
botToken: account.botToken ?? undefined,
|
||||
baseUrl: account.baseUrl ?? undefined,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "mattermostUserId",
|
||||
message: "OpenClaw: your access has been approved.",
|
||||
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
||||
notify: createLoggedPairingApprovalNotifier(
|
||||
({ id }) => `[mattermost] User ${id} approved for pairing`,
|
||||
),
|
||||
},
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: (params) => buildMattermostThreadingToolContext(params),
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultMattermostAccountId(cfg),
|
||||
}),
|
||||
resolveReplyToMode: (account, chatType) =>
|
||||
resolveMattermostReplyToMode(
|
||||
account,
|
||||
chatType === "direct" || chatType === "group" || chatType === "channel"
|
||||
? chatType
|
||||
: "channel",
|
||||
),
|
||||
},
|
||||
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
|
||||
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
|
||||
matchesToolContextTarget: ({ target, toolContext }) =>
|
||||
matchesMattermostToolContextTarget({ target, toolContext }),
|
||||
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
|
||||
const ambientThreadId = threadId != null ? String(threadId) : undefined;
|
||||
const resolvedThreadId =
|
||||
replyDelivery?.chatType === "direct"
|
||||
? undefined
|
||||
: replyDelivery
|
||||
? replyToIsExplicit
|
||||
? (replyToId ?? ambientThreadId)
|
||||
: (ambientThreadId ?? replyToId ?? undefined)
|
||||
: (ambientThreadId ?? replyToId);
|
||||
return {
|
||||
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
|
||||
threadId: resolvedThreadId ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
security: mattermostSecurityAdapter,
|
||||
outbound: mattermostOutbound,
|
||||
});
|
||||
179
extensions/mattermost/src/config-schema-core.ts
Normal file
179
extensions/mattermost/src/config-schema-core.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Mattermost helper module supports config schema core behavior.
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const MattermostGroupSchema = z
|
||||
.object({
|
||||
/** Whether mentions are required to trigger the bot in this group. */
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function requireMattermostOpenAllowFrom(params: {
|
||||
policy?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
ctx: z.RefinementCtx;
|
||||
}) {
|
||||
requireOpenAllowFrom({
|
||||
policy: params.policy,
|
||||
allowFrom: params.allowFrom,
|
||||
ctx: params.ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
}
|
||||
|
||||
const DmChannelRetrySchema = z
|
||||
.object({
|
||||
/** Maximum number of retry attempts for DM channel creation (default: 3) */
|
||||
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||
/** Initial delay in milliseconds before first retry (default: 1000) */
|
||||
initialDelayMs: z.number().int().min(100).max(60000).optional(),
|
||||
/** Maximum delay in milliseconds between retries (default: 10000) */
|
||||
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
|
||||
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
|
||||
timeoutMs: z.number().int().min(5000).max(120000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
|
||||
return data.initialDelayMs <= data.maxDelayMs;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "initialDelayMs must be less than or equal to maxDelayMs",
|
||||
path: ["initialDelayMs"],
|
||||
},
|
||||
)
|
||||
.optional();
|
||||
|
||||
const MattermostSlashCommandsSchema = z
|
||||
.object({
|
||||
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||
native: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: z.string().optional(),
|
||||
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||
callbackUrl: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]);
|
||||
const MattermostStreamingProgressSchema = z
|
||||
.object({
|
||||
label: z.union([z.string(), z.literal(false)]).optional(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
maxLines: z.number().int().positive().optional(),
|
||||
maxLineChars: z.number().int().positive().optional(),
|
||||
toolProgress: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const MattermostStreamingPreviewSchema = z
|
||||
.object({
|
||||
toolProgress: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const MattermostStreamingBlockSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
coalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const MattermostStreamingSchema = z.union([
|
||||
MattermostStreamingModeSchema,
|
||||
z.boolean(),
|
||||
z
|
||||
.object({
|
||||
mode: MattermostStreamingModeSchema.optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
preview: MattermostStreamingPreviewSchema.optional(),
|
||||
progress: MattermostStreamingProgressSchema.optional(),
|
||||
block: MattermostStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: buildSecretInputSchema().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||
oncharPrefixes: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
streaming: MattermostStreamingSchema.optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
replyToMode: z.enum(["off", "first", "all", "batched"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
commands: MattermostSlashCommandsSchema,
|
||||
interactions: z
|
||||
.object({
|
||||
callbackBaseUrl: z.string().optional(),
|
||||
allowedSourceIps: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */
|
||||
groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(),
|
||||
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
network: MattermostNetworkSchema,
|
||||
/** Retry configuration for DM channel creation */
|
||||
dmChannelRetry: DmChannelRetrySchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireMattermostOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
});
|
||||
});
|
||||
|
||||
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireMattermostOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
});
|
||||
});
|
||||
117
extensions/mattermost/src/config-schema.test.ts
Normal file
117
extensions/mattermost/src/config-schema.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// Mattermost tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MattermostConfigSchema } from "./config-schema-core.js";
|
||||
|
||||
describe("MattermostConfigSchema", () => {
|
||||
it("accepts SecretRef botToken at top-level", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef botToken on account", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
accounts: {
|
||||
main: {
|
||||
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN_MAIN" },
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts replyToMode", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
replyToMode: "all",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts documented streaming modes and progress config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Shelling",
|
||||
maxLines: 4,
|
||||
toolProgress: false,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
quiet: {
|
||||
streaming: "off",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts groups with requireMention", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"channel-123": { requireMention: false },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts groups on account", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
accounts: {
|
||||
main: {
|
||||
baseUrl: "https://chat.example.com",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown properties inside groups entry", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
groups: {
|
||||
"*": { requireMention: true, unknownProp: "bad" },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported direct-message reply threading config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dm: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported per-chat-type reply threading config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
replyToModeByChatType: {
|
||||
direct: "all",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
8
extensions/mattermost/src/config-surface.ts
Normal file
8
extensions/mattermost/src/config-surface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Mattermost helper module supports config surface behavior.
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { MattermostConfigSchema } from "./config-schema-core.js";
|
||||
import { mattermostChannelConfigUiHints } from "./config-ui-hints.js";
|
||||
|
||||
export const MattermostChannelConfigSchema = buildChannelConfigSchema(MattermostConfigSchema, {
|
||||
uiHints: mattermostChannelConfigUiHints,
|
||||
});
|
||||
61
extensions/mattermost/src/config-ui-hints.ts
Normal file
61
extensions/mattermost/src/config-ui-hints.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Mattermost helper module supports config ui hints behavior.
|
||||
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
|
||||
|
||||
export const mattermostChannelConfigUiHints = {
|
||||
"": {
|
||||
label: "Mattermost",
|
||||
help: "Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming.",
|
||||
},
|
||||
dmPolicy: {
|
||||
label: "Mattermost DM Policy",
|
||||
help: 'Direct message access control ("pairing" recommended). "open" requires channels.mattermost.allowFrom=["*"].',
|
||||
},
|
||||
streaming: {
|
||||
label: "Mattermost Streaming Mode",
|
||||
help: 'Unified Mattermost stream preview mode: "off" | "partial" | "block" | "progress". "progress" keeps a single editable progress draft until final delivery.',
|
||||
},
|
||||
"streaming.mode": {
|
||||
label: "Mattermost Streaming Mode",
|
||||
help: 'Canonical Mattermost preview mode: "off" | "partial" | "block" | "progress".',
|
||||
},
|
||||
"streaming.progress.label": {
|
||||
label: "Mattermost Progress Label",
|
||||
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
|
||||
},
|
||||
"streaming.progress.labels": {
|
||||
label: "Mattermost Progress Label Pool",
|
||||
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
|
||||
},
|
||||
"streaming.progress.maxLines": {
|
||||
label: "Mattermost Progress Max Lines",
|
||||
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
|
||||
},
|
||||
"streaming.progress.maxLineChars": {
|
||||
label: "Mattermost Progress Max Line Chars",
|
||||
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
|
||||
},
|
||||
"streaming.progress.toolProgress": {
|
||||
label: "Mattermost Progress Tool Lines",
|
||||
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
|
||||
},
|
||||
"streaming.progress.commandText": {
|
||||
label: "Mattermost Progress Command Text",
|
||||
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
|
||||
},
|
||||
"streaming.preview.toolProgress": {
|
||||
label: "Mattermost Draft Tool Progress",
|
||||
help: "Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active.",
|
||||
},
|
||||
"streaming.preview.commandText": {
|
||||
label: "Mattermost Draft Command Text",
|
||||
help: 'Command/exec detail in preview tool-progress lines: "raw" preserves released behavior; "status" shows only the tool label.',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Mattermost Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Mattermost Block Streaming Coalesce",
|
||||
help: "Merge streamed Mattermost block replies before final delivery.",
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
10
extensions/mattermost/src/doctor-contract.ts
Normal file
10
extensions/mattermost/src/doctor-contract.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Mattermost plugin module implements doctor contract behavior.
|
||||
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const contract = createLegacyPrivateNetworkDoctorContract({
|
||||
channelKey: "mattermost",
|
||||
});
|
||||
|
||||
export const legacyConfigRules = contract.legacyConfigRules;
|
||||
|
||||
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
||||
51
extensions/mattermost/src/doctor.test.ts
Normal file
51
extensions/mattermost/src/doctor.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Mattermost tests cover doctor plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostDoctor } from "./doctor.js";
|
||||
|
||||
function getMattermostCompatibilityNormalizer(): NonNullable<
|
||||
typeof mattermostDoctor.normalizeCompatibilityConfig
|
||||
> {
|
||||
const normalize = mattermostDoctor.normalizeCompatibilityConfig;
|
||||
if (!normalize) {
|
||||
throw new Error("Expected mattermost doctor to expose normalizeCompatibilityConfig");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
describe("mattermost doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = getMattermostCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const mattermostConfig = result.config.channels?.mattermost;
|
||||
if (!mattermostConfig) {
|
||||
throw new Error("expected normalized Mattermost config");
|
||||
}
|
||||
expect(mattermostConfig.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
const workAccount = mattermostConfig.accounts?.work as
|
||||
| { network?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (!workAccount) {
|
||||
throw new Error("expected Mattermost work account config");
|
||||
}
|
||||
expect(workAccount.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
49
extensions/mattermost/src/doctor.ts
Normal file
49
extensions/mattermost/src/doctor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Mattermost plugin module implements doctor behavior.
|
||||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
legacyConfigRules as MATTERMOST_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig as normalizeMattermostCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
|
||||
function isMattermostMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim();
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
|
||||
if (/^[a-z0-9]{26}$/.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const collectMattermostMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "mattermost",
|
||||
detector: isMattermostMutableAllowEntry,
|
||||
collectLists: (scope) => [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const mattermostDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeMattermostCompatibilityConfig,
|
||||
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
|
||||
};
|
||||
39
extensions/mattermost/src/gateway-auth-bypass.test.ts
Normal file
39
extensions/mattermost/src/gateway-auth-bypass.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Mattermost tests cover gateway auth bypass plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectMattermostSlashCallbackPaths,
|
||||
resolveMattermostGatewayAuthBypassPaths,
|
||||
} from "./gateway-auth-bypass.js";
|
||||
|
||||
describe("Mattermost gateway auth bypass paths", () => {
|
||||
it("normalizes slash callback paths and callback URL paths", () => {
|
||||
expect(
|
||||
collectMattermostSlashCallbackPaths({
|
||||
callbackPath: "api/channels/mattermost/command",
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/custom",
|
||||
}),
|
||||
).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/custom"]);
|
||||
});
|
||||
|
||||
it("keeps only Mattermost channel callback paths", () => {
|
||||
expect(
|
||||
resolveMattermostGatewayAuthBypassPaths({
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
callbackUrl: "https://gateway.example.com/api/channels/nostr/default/profile",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/work"]);
|
||||
});
|
||||
});
|
||||
84
extensions/mattermost/src/gateway-auth-bypass.ts
Normal file
84
extensions/mattermost/src/gateway-auth-bypass.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Mattermost plugin module implements gateway auth bypass behavior.
|
||||
const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
type MattermostSlashCommandConfigInput = {
|
||||
callbackPath?: unknown;
|
||||
callbackUrl?: unknown;
|
||||
};
|
||||
|
||||
type MattermostAccountConfigInput = {
|
||||
commands?: MattermostSlashCommandConfigInput;
|
||||
};
|
||||
|
||||
type MattermostConfigInput = MattermostAccountConfigInput & {
|
||||
accounts?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeCallbackPath(value: unknown): string {
|
||||
const trimmed = readTrimmedString(value);
|
||||
if (!trimmed) {
|
||||
return DEFAULT_SLASH_CALLBACK_PATH;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function readMattermostCommands(value: unknown): MattermostSlashCommandConfigInput | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as MattermostSlashCommandConfigInput)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isMattermostBypassPath(path: string): boolean {
|
||||
return path === DEFAULT_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/");
|
||||
}
|
||||
|
||||
export function collectMattermostSlashCallbackPaths(
|
||||
raw?: MattermostSlashCommandConfigInput,
|
||||
): string[] {
|
||||
const paths = new Set<string>([normalizeCallbackPath(raw?.callbackPath)]);
|
||||
const callbackUrl = readTrimmedString(raw?.callbackUrl);
|
||||
if (callbackUrl) {
|
||||
try {
|
||||
const pathname = new URL(callbackUrl).pathname;
|
||||
if (pathname) {
|
||||
paths.add(pathname);
|
||||
}
|
||||
} catch {
|
||||
// Keep the normalized callback path when the configured URL is invalid.
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
export function resolveMattermostGatewayAuthBypassPaths(cfg: {
|
||||
channels?: Record<string, unknown>;
|
||||
}): string[] {
|
||||
const base =
|
||||
cfg.channels?.mattermost && typeof cfg.channels.mattermost === "object"
|
||||
? (cfg.channels.mattermost as MattermostConfigInput)
|
||||
: undefined;
|
||||
const callbackPaths = new Set(
|
||||
collectMattermostSlashCallbackPaths(readMattermostCommands(base?.commands)).filter(
|
||||
isMattermostBypassPath,
|
||||
),
|
||||
);
|
||||
const accounts = base?.accounts ?? {};
|
||||
for (const account of Object.values(accounts)) {
|
||||
const accountConfig =
|
||||
account && typeof account === "object" && !Array.isArray(account)
|
||||
? (account as MattermostAccountConfigInput)
|
||||
: undefined;
|
||||
for (const path of collectMattermostSlashCallbackPaths(
|
||||
readMattermostCommands(accountConfig?.commands),
|
||||
)) {
|
||||
if (isMattermostBypassPath(path)) {
|
||||
callbackPaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...callbackPaths];
|
||||
}
|
||||
47
extensions/mattermost/src/group-mentions.test.ts
Normal file
47
extensions/mattermost/src/group-mentions.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Mattermost tests cover group mentions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
|
||||
describe("resolveMattermostGroupRequireMention", () => {
|
||||
it("defaults to requiring mention when no override is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
|
||||
expect(requireMention).toBe(true);
|
||||
});
|
||||
|
||||
it("respects chatmode-derived account override", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onmessage",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers an explicit runtime override when provided", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "oncall",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
requireMentionOverride: false,
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
});
|
||||
24
extensions/mattermost/src/group-mentions.ts
Normal file
24
extensions/mattermost/src/group-mentions.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Mattermost plugin module implements group mentions behavior.
|
||||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
import type { ChannelGroupContext } from "./runtime-api.js";
|
||||
|
||||
export function resolveMattermostGroupRequireMention(
|
||||
params: ChannelGroupContext & { requireMentionOverride?: boolean },
|
||||
): boolean | undefined {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const requireMentionOverride =
|
||||
typeof params.requireMentionOverride === "boolean"
|
||||
? params.requireMentionOverride
|
||||
: account.requireMention;
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "mattermost",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
requireMentionOverride,
|
||||
});
|
||||
}
|
||||
207
extensions/mattermost/src/mattermost/accounts.test.ts
Normal file
207
extensions/mattermost/src/mattermost/accounts.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
// Mattermost tests cover accounts plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
resolveMattermostReplyToMode,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDefaultMattermostAccountId", () => {
|
||||
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
|
||||
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("alerts");
|
||||
});
|
||||
|
||||
it("normalizes channels.mattermost.defaultAccount before lookup", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "Ops Team",
|
||||
accounts: {
|
||||
"ops-team": { botToken: "tok-ops", baseUrl: "https://chat.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("ops-team");
|
||||
});
|
||||
|
||||
it("falls back when channels.mattermost.defaultAccount is missing", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
|
||||
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the implicit default account when named accounts are added to top-level credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "tok-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
botToken: "tok-work",
|
||||
baseUrl: "https://work.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("inherits top-level access policy for named accounts before doctor migration", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
accounts: {
|
||||
tony: {
|
||||
botToken: "tok-tony",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
|
||||
|
||||
expect(account.config.dmPolicy).toBe("open");
|
||||
expect(account.config.groupPolicy).toBe("open");
|
||||
expect(account.config.allowFrom).toEqual(["*"]);
|
||||
expect(account.config.groupAllowFrom).toEqual(["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyToMode", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
alerts: {
|
||||
botToken: "tok-alerts",
|
||||
baseUrl: "https://alerts.example.com",
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg });
|
||||
expect(account.accountId).toBe("alerts");
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses the configured mode for channel and group messages", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
|
||||
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
|
||||
});
|
||||
|
||||
it("keeps direct messages off even when replyToMode is enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to off when replyToMode is unset", () => {
|
||||
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves shared commands config when an account overrides one commands field", () => {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
commands: {
|
||||
callbackPath: "/hooks/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(account.config.commands).toEqual({
|
||||
native: true,
|
||||
callbackPath: "/hooks/work",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves documented streaming mode from account config", () => {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
streaming: "partial",
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(account.streamingMode).toBe("off");
|
||||
});
|
||||
});
|
||||
157
extensions/mattermost/src/mattermost/accounts.ts
Normal file
157
extensions/mattermost/src/mattermost/accounts.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Mattermost plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
hasConfiguredAccountValue,
|
||||
} from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
resolveChannelStreamingBlockCoalesce,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
resolveChannelPreviewStreamMode,
|
||||
type StreamingMode,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
||||
import type {
|
||||
MattermostAccountConfig,
|
||||
MattermostChatMode,
|
||||
MattermostChatTypeKey,
|
||||
MattermostReplyToMode,
|
||||
} from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
type MattermostTokenSource = "env" | "config" | "none";
|
||||
type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: MattermostAccountConfig["chunkMode"];
|
||||
streamingMode: StreamingMode;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
|
||||
hasImplicitDefaultAccount: (cfg) => {
|
||||
const mattermost = cfg.channels?.mattermost;
|
||||
return Boolean(
|
||||
mattermost?.baseUrl?.trim() &&
|
||||
(hasConfiguredAccountValue(mattermost.botToken) || process.env.MATTERMOST_BOT_TOKEN?.trim()),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return mattermostAccountHelpers.listAccountIds(cfg);
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
||||
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
return resolveMergedAccountConfig<MattermostAccountConfig>({
|
||||
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
|
||||
accounts: cfg.channels?.mattermost?.accounts as
|
||||
| Record<string, Partial<MattermostAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
nestedObjectKeys: ["commands"],
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") {
|
||||
return true;
|
||||
}
|
||||
if (config.chatmode === "onmessage") {
|
||||
return false;
|
||||
}
|
||||
if (config.chatmode === "onchar") {
|
||||
return true;
|
||||
}
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
allowUnresolvedSecretRef?: boolean;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
|
||||
const configToken = params.allowUnresolvedSecretRef
|
||||
? normalizeSecretInputString(merged.botToken)
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: merged.botToken,
|
||||
path: `channels.mattermost.accounts.${accountId}.botToken`,
|
||||
});
|
||||
const configUrl = merged.baseUrl?.trim();
|
||||
const botToken = configToken || envToken;
|
||||
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
|
||||
const requireMention = resolveMattermostRequireMention(merged);
|
||||
|
||||
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
|
||||
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
botToken,
|
||||
baseUrl,
|
||||
botTokenSource,
|
||||
baseUrlSource,
|
||||
config: merged,
|
||||
chatmode: merged.chatmode,
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
|
||||
streamingMode: resolveChannelPreviewStreamMode(merged, "partial"),
|
||||
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
|
||||
blockStreamingCoalesce:
|
||||
resolveChannelStreamingBlockCoalesce(merged) ?? merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective replyToMode for a given chat type.
|
||||
* Mattermost auto-threading only applies to channel and group messages.
|
||||
*/
|
||||
export function resolveMattermostReplyToMode(
|
||||
account: ResolvedMattermostAccount,
|
||||
kind: MattermostChatTypeKey,
|
||||
): MattermostReplyToMode {
|
||||
if (kind === "direct") {
|
||||
return "off";
|
||||
}
|
||||
return account.config.replyToMode ?? "off";
|
||||
}
|
||||
491
extensions/mattermost/src/mattermost/client.retry.test.ts
Normal file
491
extensions/mattermost/src/mattermost/client.retry.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
// Mattermost tests cover client.retry plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannelWithRetry,
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs,
|
||||
} from "./client.js";
|
||||
|
||||
describe("resolveMattermostReplyDeliveryBarrierTimeoutMs", () => {
|
||||
it("uses the default barrier for non-DM deliveries", () => {
|
||||
expect(
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: false,
|
||||
queuedCounts: { tool: 1, block: 1, final: 1 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the default barrier when no deliveries were queued", () => {
|
||||
expect(
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: true,
|
||||
queuedCounts: { tool: 0, block: 0, final: 0 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("covers the default retry envelope plus scheduling slack", () => {
|
||||
expect(
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: true,
|
||||
queuedCounts: { tool: 0, block: 0, final: 1 },
|
||||
}),
|
||||
).toBe(210_000);
|
||||
});
|
||||
|
||||
it("covers one maximum retry envelope per queued delivery", () => {
|
||||
expect(
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: true,
|
||||
dmRetryOptions: {
|
||||
maxRetries: 10,
|
||||
maxDelayMs: 60_000,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
queuedCounts: { tool: 1, block: 0, final: 1 },
|
||||
}),
|
||||
).toBe(3_960_000);
|
||||
});
|
||||
|
||||
it("includes the configured inter-block delay budget", () => {
|
||||
expect(
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: true,
|
||||
queuedCounts: { tool: 0, block: 2, final: 0 },
|
||||
humanDelayBudgetMs: 180_000,
|
||||
}),
|
||||
).toBe(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMattermostDirectChannelWithRetry", () => {
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockClient() {
|
||||
return createMattermostClient({
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
botToken: "test-token",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchFailedError(params: { message: string; code?: string }): TypeError {
|
||||
const cause = Object.assign(new Error(params.message), {
|
||||
code: params.code,
|
||||
});
|
||||
return Object.assign(new TypeError("fetch failed"), { cause });
|
||||
}
|
||||
|
||||
async function resolveRetryRun<T>(run: Promise<T>): Promise<T> {
|
||||
await vi.runAllTimersAsync();
|
||||
return await run;
|
||||
}
|
||||
|
||||
function suppressUnhandled<T>(run: Promise<T>): Promise<T> {
|
||||
run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return Response.json(body, { status });
|
||||
}
|
||||
|
||||
it("succeeds on first attempt without retries", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-123" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on 429 rate limit error and succeeds", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ message: "Too many requests" }, 429))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-456" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-456");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
const retryCall = onRetry.mock.calls[0];
|
||||
expect(retryCall?.[0]).toBe(1);
|
||||
expect(retryCall?.[1]).toBeGreaterThanOrEqual(10);
|
||||
expect(retryCall?.[1]).toBeLessThanOrEqual(20);
|
||||
expect(retryCall?.[2]).toBeInstanceOf(Error);
|
||||
expect((retryCall?.[2] as Error | undefined)?.message).toContain("Too many requests");
|
||||
});
|
||||
|
||||
it("retries on port 443 connection errors (not misclassified as 4xx)", async () => {
|
||||
// This tests that port numbers like :443 don't trigger false 4xx classification
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("connect ECONNRESET 104.18.32.10:443"))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-port" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result.id).toBe("dm-channel-port");
|
||||
});
|
||||
|
||||
it("does not retry on 400 even if error message contains '429' text", async () => {
|
||||
// This tests that "429" in error detail doesn't trigger false rate-limit retry
|
||||
// e.g., "Invalid user ID: 4294967295" should NOT be retried
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: "Invalid user ID: 4294967295" }, 400));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("Mattermost API 400");
|
||||
|
||||
// Should not retry - only called once (400 is a client error, even though message contains "429")
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries on 5xx server errors", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ message: "Service unavailable" }, 503))
|
||||
.mockResolvedValueOnce(jsonResponse({ message: "Bad gateway" }, 502))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-789" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-789");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on network errors", async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Network error: connection refused"))
|
||||
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-abc" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-abc");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on fetch failed errors when the cause carries a transient code", async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(
|
||||
createFetchFailedError({
|
||||
message: "connect ECONNREFUSED 127.0.0.1:81",
|
||||
code: "ECONNREFUSED",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-fetch-failed" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-fetch-failed");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on 4xx client errors (except 429)", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: "Bad request" }, 400));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry on 404 not found", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: "User not found" }, 404));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("404");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws after exhausting all retries", async () => {
|
||||
mockFetch.mockImplementation(async () => jsonResponse({ message: "Service unavailable" }, 503));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 2,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("Mattermost API 503");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
});
|
||||
|
||||
it("respects custom timeout option and aborts fetch", async () => {
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let abortListenerCalled = false;
|
||||
|
||||
mockFetch.mockImplementationOnce((url, init) => {
|
||||
abortSignal = init?.signal ?? undefined;
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
abortListenerCalled = true;
|
||||
});
|
||||
}
|
||||
// Return a promise that rejects when aborted, otherwise never resolves
|
||||
return new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
reject(new Error("AbortError"));
|
||||
} else {
|
||||
setTimeout(checkAbort, 10);
|
||||
}
|
||||
};
|
||||
setTimeout(checkAbort, 10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 50,
|
||||
maxRetries: 0,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("AbortError");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(abortSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(abortSignal?.aborted).toBe(true);
|
||||
expect(abortListenerCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("caps oversized request timeouts before scheduling aborts", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-capped" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("uses exponential backoff with jitter between retries", async () => {
|
||||
const delays: number[] = [];
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-delay" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(delays).toHaveLength(2);
|
||||
// First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms
|
||||
expect(delays[0]).toBeGreaterThanOrEqual(100);
|
||||
expect(delays[0]).toBeLessThanOrEqual(200);
|
||||
// Second retry: exponentialDelay = 200ms, jitter = 0-200ms, total = 200-400ms
|
||||
expect(delays[1]).toBeGreaterThanOrEqual(200);
|
||||
expect(delays[1]).toBeLessThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("respects maxDelayMs cap", async () => {
|
||||
const delays: number[] = [];
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-max" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 2500,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(delays).toHaveLength(4);
|
||||
// All delays should be capped at maxDelayMs
|
||||
delays.forEach((delay) => {
|
||||
expect(delay).toBeLessThanOrEqual(2500);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry on 4xx errors even if message contains retryable keywords", async () => {
|
||||
// This tests the fix for false positives where a 400 error with "timeout" in the message
|
||||
// would incorrectly be retried
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ message: "Request timeout: connection timed out" }, 400),
|
||||
);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
|
||||
// Should not retry - only called once
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry on 403 Forbidden even with 'abort' in message", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: "Request aborted: forbidden" }, 403));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("403");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes AbortSignal to fetch for timeout support", async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
mockFetch.mockImplementationOnce((url, init) => {
|
||||
capturedSignal = init?.signal ?? undefined;
|
||||
return Promise.resolve(jsonResponse({ id: "dm-channel-signal" }, 201));
|
||||
});
|
||||
|
||||
const client = createMockClient();
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("retries on 5xx even if error message contains 4xx substring", async () => {
|
||||
// This tests the fix for the ordering bug: 503 with "upstream 404" should be retried
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503: upstream returned 404 Not Found"))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-5xx-with-404" }, 201));
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should retry and succeed on second attempt
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result.id).toBe("dm-channel-5xx-with-404");
|
||||
});
|
||||
});
|
||||
566
extensions/mattermost/src/mattermost/client.test.ts
Normal file
566
extensions/mattermost/src/mattermost/client.test.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
// Mattermost tests cover client plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostPost,
|
||||
normalizeMattermostBaseUrl,
|
||||
readMattermostError,
|
||||
updateMattermostPost,
|
||||
} from "./client.js";
|
||||
|
||||
// ── Helper: mock fetch that captures requests ────────────────────────
|
||||
|
||||
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 = requestUrl(url);
|
||||
calls.push({ url: urlStr, init });
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
});
|
||||
|
||||
return { mockFetch: mockFetch as typeof fetch, calls };
|
||||
}
|
||||
|
||||
function requestUrl(url: string | URL | Request): string {
|
||||
if (typeof url === "string") {
|
||||
return url;
|
||||
}
|
||||
if (url instanceof URL) {
|
||||
return url.toString();
|
||||
}
|
||||
return url.url;
|
||||
}
|
||||
|
||||
function parseRequestJson(init: RequestInit | undefined): Record<string, unknown> {
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("expected JSON request body");
|
||||
}
|
||||
const parsed: unknown = JSON.parse(init.body);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("expected JSON object request body");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function streamingMattermostResponse(body: unknown): {
|
||||
response: Response;
|
||||
arrayBuffer: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(body));
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoded);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const arrayBuffer = vi.fn(async () => {
|
||||
throw new Error("guarded Mattermost responses must stay streaming");
|
||||
});
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
body: stream,
|
||||
arrayBuffer,
|
||||
} as unknown as Response,
|
||||
arrayBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestClient(response?: { status?: number; body?: unknown; contentType?: string }) {
|
||||
const { mockFetch, calls } = createMockFetch(response);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065",
|
||||
botToken: "tok",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
async function updatePostAndCapture(
|
||||
update: Parameters<typeof updateMattermostPost>[2],
|
||||
response?: { status?: number; body?: unknown; contentType?: string },
|
||||
) {
|
||||
const { client, calls } = createTestClient(response ?? { body: { id: "post1" } });
|
||||
await updateMattermostPost(client, "post1", update);
|
||||
return {
|
||||
calls,
|
||||
body: parseRequestJson(calls[0].init),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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");
|
||||
});
|
||||
});
|
||||
|
||||
// ── readMattermostError ───────────────────────────────────────────────
|
||||
|
||||
describe("readMattermostError", () => {
|
||||
it("bounds null-body JSON errors without response.json/text", async () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(readMattermostError(response)).resolves.toBe("");
|
||||
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses bounded JSON error messages from response bodies", async () => {
|
||||
const response = new Response(JSON.stringify({ message: "invalid token", id: "app.error" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(readMattermostError(response)).resolves.toBe("invalid token");
|
||||
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMattermostClient ───────────────────────────────────────────
|
||||
|
||||
describe("createMattermostClient", () => {
|
||||
it("keeps guarded Mattermost responses streaming until callers consume them", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const { response, arrayBuffer } = streamingMattermostResponse({ id: "u1" });
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
await expect(client.request("/users/me")).resolves.toEqual({ id: "u1" });
|
||||
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads guarded null-body Mattermost errors without response.json/text", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const response = new Response(null, {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
await expect(client.request("/users/me")).rejects.toThrow(
|
||||
"Mattermost API 503 Service Unavailable: unknown error",
|
||||
);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds and cancels guarded Mattermost error bodies", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const tracked = cancelTrackedResponse(`${"upstream unavailable ".repeat(512)}tail`, {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await client.request("/users/me");
|
||||
} catch (error) {
|
||||
caught = error as Error;
|
||||
}
|
||||
|
||||
expect(caught?.message).toContain("Mattermost API 503 Service Unavailable");
|
||||
expect(caught?.message).toContain("upstream unavailable");
|
||||
expect(caught?.message).not.toContain("tail");
|
||||
expect(caught?.message.length).toBeLessThan(8_300);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds and cancels oversized guarded Mattermost success JSON bodies", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
let canceled = false;
|
||||
let pulled = 0;
|
||||
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of '{'
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulled += 1;
|
||||
// Flood far past the 16 MiB JSON cap; an unbounded reader would buffer
|
||||
// the whole stream before parsing.
|
||||
controller.enqueue(oversizeChunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await client.request("/users/me");
|
||||
} catch (error) {
|
||||
caught = error as Error;
|
||||
}
|
||||
|
||||
expect(caught?.message).toContain("JSON response exceeds 16777216 bytes");
|
||||
// The reader is cancelled at the cap instead of draining the flood: ~8
|
||||
// chunks of 2 MiB reach the 16 MiB ceiling, never the unbounded tail.
|
||||
expect(canceled).toBe(true);
|
||||
expect(pulled).toBeLessThanOrEqual(12);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects oversized guarded Mattermost success text bodies instead of truncating", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const tracked = cancelTrackedResponse(`${"plain success ".repeat(7000)}tail`, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
await expect(client.request("/users/me")).rejects.toThrow(
|
||||
"Mattermost API /users/me: text response exceeds 65536 bytes",
|
||||
);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases guarded Mattermost responses when upstream body reads fail", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
throw new Error("upstream body failed");
|
||||
},
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
});
|
||||
|
||||
await expect(client.request("/users/me")).rejects.toThrow("upstream body failed");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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<typeof fetch>(async () => {
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
botToken: "test-token",
|
||||
fetchImpl,
|
||||
});
|
||||
const result = await client.request<unknown>("/anything", { method: "DELETE" });
|
||||
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 = parseRequestJson(calls[0].init);
|
||||
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 = parseRequestJson(calls[0].init);
|
||||
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 = parseRequestJson(calls[0].init);
|
||||
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 = parseRequestJson(calls[0].init);
|
||||
expect(body).toEqual({
|
||||
channel_id: "ch123",
|
||||
message: "Pick an option",
|
||||
props,
|
||||
});
|
||||
});
|
||||
|
||||
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 = parseRequestJson(calls[0].init);
|
||||
expect(body.props).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateMattermostPost ─────────────────────────────────────────────
|
||||
|
||||
describe("updateMattermostPost", () => {
|
||||
it("sends PUT to /posts/{id}", async () => {
|
||||
const { calls } = await updatePostAndCapture({ message: "Updated" });
|
||||
|
||||
const firstCall = calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("expected Mattermost update post request");
|
||||
}
|
||||
expect(firstCall.url).toContain("/posts/post1");
|
||||
if (!firstCall.init) {
|
||||
throw new Error("expected Mattermost update post request init");
|
||||
}
|
||||
expect(firstCall.init.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("includes post id in the body", async () => {
|
||||
const { body } = await updatePostAndCapture({ message: "Updated" });
|
||||
expect(body.id).toBe("post1");
|
||||
expect(body.message).toBe("Updated");
|
||||
});
|
||||
|
||||
it("includes props for button completion updates", async () => {
|
||||
const { body } = await updatePostAndCapture({
|
||||
message: "Original message",
|
||||
props: {
|
||||
attachments: [{ text: "✓ **do_now** selected by @tony" }],
|
||||
},
|
||||
});
|
||||
expect(body).toEqual({
|
||||
id: "post1",
|
||||
message: "Original message",
|
||||
props: {
|
||||
attachments: [{ text: "✓ **do_now** selected by @tony" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits message when not provided", async () => {
|
||||
const { body } = await updatePostAndCapture({
|
||||
props: { attachments: [] },
|
||||
});
|
||||
expect(body.id).toBe("post1");
|
||||
expect(body.message).toBeUndefined();
|
||||
expect(body.props).toEqual({ attachments: [] });
|
||||
});
|
||||
});
|
||||
700
extensions/mattermost/src/mattermost/client.ts
Normal file
700
extensions/mattermost/src/mattermost/client.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
// Mattermost plugin module implements client behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
const MATTERMOST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
// Mattermost REST control-plane JSON (posts, users, channels, file-upload
|
||||
// results) stays well under a megabyte; cap successful JSON the same way the
|
||||
// shared provider path is capped so an untrusted/self-hosted homeserver cannot
|
||||
// stream an unbounded body into the runtime before parsing.
|
||||
// Non-JSON success bodies are a rare fallback (the API is JSON-first); keep a
|
||||
// generous text budget but still bound it instead of buffering the whole stream.
|
||||
const MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES = 64 * 1024;
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type MattermostClient = {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
/** Guarded fetch implementation; use in place of raw fetch for outbound requests. */
|
||||
fetchImpl: MattermostFetch;
|
||||
};
|
||||
|
||||
export type MattermostUser = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
nickname?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
update_at?: number;
|
||||
};
|
||||
|
||||
export type MattermostChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
type?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
||||
export const MattermostPostSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
user_id: z.string().nullable().optional(),
|
||||
channel_id: z.string().nullable().optional(),
|
||||
message: z.string().nullable().optional(),
|
||||
file_ids: z.array(z.string()).nullable().optional(),
|
||||
type: z.string().nullable().optional(),
|
||||
root_id: z.string().nullable().optional(),
|
||||
create_at: z.number().nullable().optional(),
|
||||
props: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type MattermostPost = z.infer<typeof MattermostPostSchema>;
|
||||
|
||||
export type MattermostFileInfo = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||
}
|
||||
|
||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
throw new Error("Mattermost baseUrl is required");
|
||||
}
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalized}/api/v4${suffix}`;
|
||||
}
|
||||
|
||||
async function readMattermostSuccessText(res: Response, path: string): Promise<string> {
|
||||
const bytes = await readResponseWithLimit(res, MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Mattermost API ${path}: text response exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const text = await readResponseTextLimited(res, MATTERMOST_ERROR_BODY_LIMIT_BYTES);
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = JSON.parse(text) as { message?: string } | undefined;
|
||||
if (data?.message) {
|
||||
return data.message;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
|
||||
let released = false;
|
||||
const releaseOnce = async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
await release();
|
||||
};
|
||||
|
||||
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
|
||||
void releaseOnce();
|
||||
return new Response(null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
await releaseOnce();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
await releaseOnce();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
await reader.cancel(reason).catch(() => undefined);
|
||||
await releaseOnce();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: MattermostFetch;
|
||||
/** Allow requests to private/internal IPs (self-hosted/LAN deployments). */
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): MattermostClient {
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error("Mattermost baseUrl is required");
|
||||
}
|
||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||
const token = params.botToken.trim();
|
||||
// When no custom fetchImpl is provided (production path), use an SSRF-guarded wrapper
|
||||
// that validates the target URL before making the request (DNS rebinding protection etc.).
|
||||
// A custom fetchImpl is accepted for testing and special cases.
|
||||
const externalFetchImpl = params.fetchImpl;
|
||||
|
||||
const guardedFetchImpl: MattermostFetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init,
|
||||
auditContext: "mattermost-api",
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork),
|
||||
});
|
||||
return responseWithRelease(response, release);
|
||||
};
|
||||
|
||||
const fetchImpl = externalFetchImpl ?? guardedFetchImpl;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return await readProviderJsonResponse<T>(res, `Mattermost API ${path}`);
|
||||
}
|
||||
return (await readMattermostSuccessText(res, path)) as T;
|
||||
};
|
||||
|
||||
return { baseUrl, apiBaseUrl, token, request, fetchImpl };
|
||||
}
|
||||
|
||||
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchMattermostUser(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostUserByUsername(
|
||||
client: MattermostClient,
|
||||
username: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostChannel(
|
||||
client: MattermostClient,
|
||||
channelId: string,
|
||||
): Promise<MattermostChannel> {
|
||||
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(
|
||||
client: MattermostClient,
|
||||
params: { channelId: string; parentId?: string },
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
};
|
||||
const parentId = params.parentId?.trim();
|
||||
if (parentId) {
|
||||
payload.parent_id = parentId;
|
||||
}
|
||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostDirectChannel(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>("/channels/direct", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateDmChannelRetryOptions = {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries?: number;
|
||||
/** Initial delay in milliseconds (default: 1000) */
|
||||
initialDelayMs?: number;
|
||||
/** Maximum delay in milliseconds (default: 10000) */
|
||||
maxDelayMs?: number;
|
||||
/** Timeout for each individual request in milliseconds (default: 30000) */
|
||||
timeoutMs?: number;
|
||||
/** Optional logger for retry events */
|
||||
onRetry?: (attempt: number, delayMs: number, error: Error) => void;
|
||||
};
|
||||
|
||||
const DM_REPLY_DELIVERY_BARRIER_SLACK_MS = 60_000;
|
||||
|
||||
/** Covers DM creation retries without extending channel-delivery stalls. */
|
||||
export function resolveMattermostReplyDeliveryBarrierTimeoutMs(params: {
|
||||
isDirect: boolean;
|
||||
dmRetryOptions?: CreateDmChannelRetryOptions;
|
||||
queuedCounts: Readonly<Record<"tool" | "block" | "final", number>>;
|
||||
humanDelayBudgetMs?: number;
|
||||
}): number | undefined {
|
||||
if (!params.isDirect) {
|
||||
return undefined;
|
||||
}
|
||||
const deliveryCount = Object.values(params.queuedCounts).reduce((sum, count) => sum + count, 0);
|
||||
if (deliveryCount === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const maxRetries = params.dmRetryOptions?.maxRetries ?? 3;
|
||||
const maxDelayMs = params.dmRetryOptions?.maxDelayMs ?? 10_000;
|
||||
const timeoutMs = params.dmRetryOptions?.timeoutMs ?? 30_000;
|
||||
const perDeliveryTimeoutMs =
|
||||
(maxRetries + 1) * timeoutMs + maxRetries * maxDelayMs + DM_REPLY_DELIVERY_BARRIER_SLACK_MS;
|
||||
const totalTimeoutMs =
|
||||
perDeliveryTimeoutMs * deliveryCount + Math.max(0, params.humanDelayBudgetMs ?? 0);
|
||||
return resolveTimerTimeoutMs(
|
||||
Number.isFinite(totalTimeoutMs) ? totalTimeoutMs : Number.MAX_SAFE_INTEGER,
|
||||
perDeliveryTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"ESOCKETTIMEDOUT",
|
||||
"ECONNABORTED",
|
||||
"ENOTFOUND",
|
||||
"EAI_AGAIN",
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"EPIPE",
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_DNS_RESOLVE_FAILED",
|
||||
"UND_ERR_CONNECT",
|
||||
"UND_ERR_SOCKET",
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
]);
|
||||
|
||||
const RETRYABLE_NETWORK_ERROR_NAMES = new Set([
|
||||
"AbortError",
|
||||
"TimeoutError",
|
||||
"ConnectTimeoutError",
|
||||
"HeadersTimeoutError",
|
||||
"BodyTimeoutError",
|
||||
]);
|
||||
|
||||
const RETRYABLE_NETWORK_MESSAGE_SNIPPETS = [
|
||||
"network error",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"abort",
|
||||
"connection refused",
|
||||
"econnreset",
|
||||
"econnrefused",
|
||||
"etimedout",
|
||||
"enotfound",
|
||||
"socket hang up",
|
||||
"getaddrinfo",
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a Mattermost DM channel with exponential backoff retry logic.
|
||||
* Retries on transient errors (429, 5xx, network errors) but not on
|
||||
* client errors (4xx except 429) or permanent failures.
|
||||
*/
|
||||
export async function createMattermostDirectChannelWithRetry(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
options: CreateDmChannelRetryOptions = {},
|
||||
): Promise<MattermostChannel> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelayMs = 1000,
|
||||
maxDelayMs = 10000,
|
||||
timeoutMs: rawTimeoutMs = 30000,
|
||||
onRetry,
|
||||
} = options;
|
||||
const timeoutMs = resolveTimerTimeoutMs(rawTimeoutMs, 30000);
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Use AbortController for per-request timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const result = await createMattermostDirectChannel(client, userIds, controller.signal);
|
||||
return result;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// Don't retry on the last attempt
|
||||
if (attempt >= maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if (!isRetryableError(lastError)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Calculate exponential backoff delay with full-jitter
|
||||
// Jitter is proportional to the exponential delay, not a fixed 1000ms
|
||||
// This ensures backoff behaves correctly for small delay configurations
|
||||
const exponentialDelay = initialDelayMs * 2 ** attempt;
|
||||
const jitter = Math.random() * exponentialDelay;
|
||||
const delayMs = Math.min(exponentialDelay + jitter, maxDelayMs);
|
||||
|
||||
if (onRetry) {
|
||||
onRetry(attempt + 1, delayMs, lastError);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Failed to create DM channel after retries");
|
||||
}
|
||||
|
||||
function isRetryableError(error: Error): boolean {
|
||||
const candidates = collectErrorCandidates(error);
|
||||
const messages = candidates
|
||||
.map((candidate) => normalizeLowercaseStringOrEmpty(readErrorMessage(candidate)))
|
||||
.filter((message): message is string => Boolean(message));
|
||||
|
||||
// Retry on 5xx server errors FIRST (before checking 4xx)
|
||||
// Use "mattermost api" prefix to avoid matching port numbers (e.g., :443) or IP octets
|
||||
// This prevents misclassification when a 5xx error detail contains a 4xx substring
|
||||
// e.g., "Mattermost API 503: upstream returned 404"
|
||||
if (messages.some((message) => /mattermost api 5\d{2}\b/.test(message))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for explicit 429 rate limiting FIRST (before generic "429" text match)
|
||||
// This avoids retrying when error detail contains "429" but it's not the status code
|
||||
if (
|
||||
messages.some(
|
||||
(message) => /mattermost api 429\b/.test(message) || message.includes("too many requests"),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for explicit 4xx status codes - these are client errors and should NOT be retried
|
||||
// (except 429 which is handled above)
|
||||
// Use "mattermost api" prefix to avoid matching port numbers like :443
|
||||
for (const message of messages) {
|
||||
const clientErrorMatch = message.match(/mattermost api (4\d{2})\b/);
|
||||
if (!clientErrorMatch) {
|
||||
continue;
|
||||
}
|
||||
const statusCode = Number.parseInt(clientErrorMatch[1], 10);
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry on network/transient errors only if no explicit Mattermost API status code is present
|
||||
// This avoids false positives like:
|
||||
// - "400 Bad Request: connection timed out" (has status code)
|
||||
// - "connect ECONNRESET 104.18.32.10:443" (has port number, not status)
|
||||
const hasMattermostApiStatusCode = messages.some((message) =>
|
||||
/mattermost api \d{3}\b/.test(message),
|
||||
);
|
||||
if (hasMattermostApiStatusCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const codes: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const code = readErrorCode(candidate);
|
||||
if (code) {
|
||||
codes.push(code);
|
||||
}
|
||||
}
|
||||
if (codes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const names: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const name = readErrorName(candidate);
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
if (names.some((name) => RETRYABLE_NETWORK_ERROR_NAMES.has(name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return messages.some((message) =>
|
||||
RETRYABLE_NETWORK_MESSAGE_SNIPPETS.some((pattern) => message.includes(pattern)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectErrorCandidates(error: unknown): unknown[] {
|
||||
const queue: unknown[] = [error];
|
||||
let queueIndex = 0;
|
||||
const seen = new Set<unknown>();
|
||||
const candidates: unknown[] = [];
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex];
|
||||
queueIndex += 1;
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
candidates.push(current);
|
||||
|
||||
if (typeof current !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nested = current as {
|
||||
cause?: unknown;
|
||||
reason?: unknown;
|
||||
errors?: unknown;
|
||||
};
|
||||
queue.push(nested.cause, nested.reason);
|
||||
if (Array.isArray(nested.errors)) {
|
||||
queue.push(...nested.errors);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function readErrorMessage(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === "string" && message.trim() ? message : undefined;
|
||||
}
|
||||
|
||||
function readErrorName(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const name = (error as { name?: unknown }).name;
|
||||
return typeof name === "string" && name.trim() ? name : undefined;
|
||||
}
|
||||
|
||||
function readErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const { code, errno } = error as {
|
||||
code?: unknown;
|
||||
errno?: unknown;
|
||||
};
|
||||
const raw = typeof code === "string" && code.trim() ? code : errno;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(raw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
rootId?: string;
|
||||
fileIds?: string[];
|
||||
props?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MattermostPost> {
|
||||
const payload: Record<string, unknown> = {
|
||||
channel_id: params.channelId,
|
||||
message: params.message,
|
||||
};
|
||||
if (params.rootId) {
|
||||
payload.root_id = params.rootId;
|
||||
}
|
||||
if (params.fileIds?.length) {
|
||||
payload.file_ids = params.fileIds;
|
||||
}
|
||||
if (params.props) {
|
||||
payload.props = params.props;
|
||||
}
|
||||
return await client.request<MattermostPost>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export type MattermostTeam = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchMattermostUserTeams(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostTeam[]> {
|
||||
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 deleteMattermostPost(
|
||||
client: MattermostClient,
|
||||
postId: string,
|
||||
): Promise<void> {
|
||||
await client.request<void>(`/posts/${postId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<MattermostFileInfo> {
|
||||
const form = new FormData();
|
||||
const fileName = normalizeOptionalString(params.fileName) ?? "upload";
|
||||
const bytes = Uint8Array.from(params.buffer);
|
||||
const blob = params.contentType
|
||||
? new Blob([bytes], { type: params.contentType })
|
||||
: new Blob([bytes]);
|
||||
form.append("files", blob, fileName);
|
||||
form.append("channel_id", params.channelId);
|
||||
|
||||
const res = await client.fetchImpl(`${client.apiBaseUrl}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
||||
}
|
||||
const data = await readProviderJsonResponse<{ file_infos?: MattermostFileInfo[] }>(
|
||||
res,
|
||||
"Mattermost API /files",
|
||||
);
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
253
extensions/mattermost/src/mattermost/directory.test.ts
Normal file
253
extensions/mattermost/src/mattermost/directory.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// Mattermost tests cover directory plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
listMattermostAccountIdsMock,
|
||||
resolveMattermostAccountMock,
|
||||
createMattermostClientMock,
|
||||
fetchMattermostMeMock,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
listMattermostAccountIdsMock: vi.fn(),
|
||||
resolveMattermostAccountMock: vi.fn(),
|
||||
createMattermostClientMock: vi.fn(),
|
||||
fetchMattermostMeMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", () => {
|
||||
return {
|
||||
listMattermostAccountIds: listMattermostAccountIdsMock,
|
||||
resolveMattermostAccount: resolveMattermostAccountMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => {
|
||||
return {
|
||||
createMattermostClient: createMattermostClientMock,
|
||||
fetchMattermostMe: fetchMattermostMeMock,
|
||||
};
|
||||
});
|
||||
|
||||
let listMattermostDirectoryGroups: typeof import("./directory.js").listMattermostDirectoryGroups;
|
||||
let listMattermostDirectoryPeers: typeof import("./directory.js").listMattermostDirectoryPeers;
|
||||
|
||||
describe("mattermost directory", () => {
|
||||
beforeAll(async () => {
|
||||
({ listMattermostDirectoryGroups, listMattermostDirectoryPeers } =
|
||||
await import("./directory.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("deduplicates channels across enabled accounts and skips failing accounts", async () => {
|
||||
const clientA = {
|
||||
token: "token-a",
|
||||
request: vi.fn().mockResolvedValueOnce([
|
||||
{ id: "chan-1", type: "O", name: "alerts", display_name: "Alerts" },
|
||||
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
|
||||
{ id: "chan-3", type: "D", name: "dm", display_name: "Direct" },
|
||||
]),
|
||||
};
|
||||
const clientB = {
|
||||
token: "token-b",
|
||||
request: vi.fn().mockRejectedValue(new Error("expired token")),
|
||||
};
|
||||
const clientC = {
|
||||
token: "token-c",
|
||||
request: vi.fn().mockResolvedValueOnce([
|
||||
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
|
||||
{ id: "chan-4", type: "O", name: "infra", display_name: "Infra" },
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default", "alerts", "infra"]);
|
||||
resolveMattermostAccountMock.mockImplementation(({ accountId }) => {
|
||||
if (accountId === "disabled") {
|
||||
return { enabled: false };
|
||||
}
|
||||
return { enabled: true, botToken: `token-${accountId}`, baseUrl: "https://chat.example.com" };
|
||||
});
|
||||
createMattermostClientMock
|
||||
.mockReturnValueOnce(clientA)
|
||||
.mockReturnValueOnce(clientB)
|
||||
.mockReturnValueOnce(clientC);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryGroups({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
query: " op ",
|
||||
}),
|
||||
).resolves.toEqual([{ kind: "group", id: "channel:chan-2", name: "ops", handle: "Ops" }]);
|
||||
});
|
||||
|
||||
it("uses the first healthy client for peers and filters self and blanks", async () => {
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce([{ user_id: "me-1" }, { user_id: "user-1" }, { user_id: "user-2" }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
first_name: "Alice",
|
||||
last_name: "Ng",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
username: "bob",
|
||||
nickname: "Bobby",
|
||||
},
|
||||
{
|
||||
id: "me-1",
|
||||
username: "self",
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" },
|
||||
{ kind: "user", id: "user:user-2", name: "bob", handle: "Bobby" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("paginates team members before resolving peer directory users in batches", async () => {
|
||||
const firstPageMembers = Array.from({ length: 200 }, (_, index) => ({
|
||||
user_id: `user-${index + 1}`,
|
||||
}));
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce(firstPageMembers)
|
||||
.mockResolvedValueOnce([{ user_id: "user-201" }, { user_id: "user-202" }])
|
||||
.mockResolvedValueOnce([{ id: "user-1", username: "alice" }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "user-201", username: "zara" },
|
||||
{ id: "user-202", username: "yuki" },
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ kind: "user", id: "user:user-1", name: "alice", handle: undefined },
|
||||
{ kind: "user", id: "user:user-201", name: "zara", handle: undefined },
|
||||
{ kind: "user", id: "user:user-202", name: "yuki", handle: undefined },
|
||||
]);
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "/teams/team-1/members?page=0&per_page=200");
|
||||
expect(client.request).toHaveBeenNthCalledWith(3, "/teams/team-1/members?page=1&per_page=200");
|
||||
expect(client.request).toHaveBeenNthCalledWith(4, "/users/ids", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(firstPageMembers.map((member) => member.user_id)),
|
||||
});
|
||||
expect(client.request).toHaveBeenNthCalledWith(5, "/users/ids", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(["user-201", "user-202"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("applies peer limits after resolving users", async () => {
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce([{ user_id: "missing-user" }, { user_id: "user-2" }])
|
||||
.mockResolvedValueOnce([{ id: "user-2", username: "bob" }]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
limit: 1,
|
||||
}),
|
||||
).resolves.toEqual([{ kind: "user", id: "user:user-2", name: "bob", handle: undefined }]);
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "/teams/team-1/members?page=0&per_page=200");
|
||||
expect(client.request).toHaveBeenNthCalledWith(3, "/users/ids", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(["missing-user", "user-2"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses user search when a query is present and applies limits", async () => {
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "user-1", username: "alice", first_name: "Alice", last_name: "Ng" },
|
||||
{ id: "user-2", username: "alex", nickname: "Lex" },
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
query: " ali ",
|
||||
limit: 1,
|
||||
}),
|
||||
).resolves.toEqual([{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" }]);
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "/users/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ term: "ali", team_id: "team-1" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
198
extensions/mattermost/src/mattermost/directory.ts
Normal file
198
extensions/mattermost/src/mattermost/directory.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Mattermost plugin module implements directory behavior.
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostMe,
|
||||
type MattermostChannel,
|
||||
type MattermostClient,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "./runtime-api.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,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = normalizeLowercaseStringOrEmpty(params.query);
|
||||
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 = normalizeLowercaseStringOrEmpty(ch.name);
|
||||
const display = normalizeLowercaseStringOrEmpty(ch.display_name);
|
||||
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.
|
||||
*
|
||||
* Uses paginated member listing with per_page=200, the Mattermost API maximum.
|
||||
*/
|
||||
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 = normalizeLowercaseStringOrEmpty(params.query);
|
||||
|
||||
let users: MattermostUser[];
|
||||
if (q) {
|
||||
users = await client.request<MattermostUser[]>("/users/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ term: q, team_id: teamId }),
|
||||
});
|
||||
} else {
|
||||
const pageSize = 200;
|
||||
const userIds: string[] = [];
|
||||
for (let page = 0; ; page += 1) {
|
||||
const pageMembers = await client.request<ReadonlyArray<{ user_id: string }>>(
|
||||
`/teams/${teamId}/members?page=${page}&per_page=${pageSize}`,
|
||||
);
|
||||
for (const member of pageMembers) {
|
||||
if (member.user_id !== me.id) {
|
||||
userIds.push(member.user_id);
|
||||
}
|
||||
}
|
||||
if (pageMembers.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userIds.length) {
|
||||
return [];
|
||||
}
|
||||
users = [];
|
||||
for (let index = 0; index < userIds.length; index += pageSize) {
|
||||
const userIdBatch = userIds.slice(index, index + pageSize);
|
||||
users.push(
|
||||
...(await client.request<MattermostUser[]>("/users/ids", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIdBatch),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
281
extensions/mattermost/src/mattermost/draft-stream.test.ts
Normal file
281
extensions/mattermost/src/mattermost/draft-stream.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// Mattermost tests cover draft stream plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import { createMattermostDraftStream } from "./draft-stream.js";
|
||||
|
||||
type RequestRecord = {
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
};
|
||||
|
||||
function createMockClient(): {
|
||||
client: MattermostClient;
|
||||
calls: RequestRecord[];
|
||||
requestMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const calls: RequestRecord[] = [];
|
||||
let nextId = 1;
|
||||
const requestImpl: MattermostClient["request"] = async <T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> => {
|
||||
calls.push({ path, init });
|
||||
if (path === "/posts") {
|
||||
return { id: `post-${nextId++}` } as T;
|
||||
}
|
||||
if (path.startsWith("/posts/")) {
|
||||
return { id: "patched" } as T;
|
||||
}
|
||||
return {} as T;
|
||||
};
|
||||
const requestMock = vi.fn(requestImpl);
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "token",
|
||||
request: requestMock as MattermostClient["request"],
|
||||
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
|
||||
};
|
||||
return { client, calls, requestMock };
|
||||
}
|
||||
|
||||
function parseRequestJson(init: RequestInit | undefined): Record<string, unknown> {
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("expected JSON request body");
|
||||
}
|
||||
const parsed: unknown = JSON.parse(init.body);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("expected JSON object request body");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("createMattermostDraftStream", () => {
|
||||
it("creates a preview post and updates it on later changes", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
rootId: "root-1",
|
||||
throttleMs: 0,
|
||||
});
|
||||
|
||||
stream.update("Running `read`…");
|
||||
await stream.flush();
|
||||
stream.update("Running `read`…");
|
||||
await stream.flush();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.path).toBe("/posts");
|
||||
|
||||
expect(parseRequestJson(calls[0]?.init)).toEqual({
|
||||
channel_id: "channel-1",
|
||||
root_id: "root-1",
|
||||
message: "Running `read`…",
|
||||
});
|
||||
expect(stream.postId()).toBe("post-1");
|
||||
});
|
||||
|
||||
it("does not resend identical updates", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
throttleMs: 0,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears the preview post when no final reply is delivered", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
rootId: "root-1",
|
||||
throttleMs: 0,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
await stream.clear();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.path).toBe("/posts/post-1");
|
||||
expect(calls[1]?.init?.method).toBe("DELETE");
|
||||
expect(stream.postId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discardPending keeps the preview post but ignores later updates", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
rootId: "root-1",
|
||||
throttleMs: 0,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
await stream.discardPending();
|
||||
stream.update("Late update");
|
||||
await stream.flush();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.path).toBe("/posts");
|
||||
expect(stream.postId()).toBe("post-1");
|
||||
});
|
||||
|
||||
it("seal keeps the preview post and cancels pending final overwrites", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
rootId: "root-1",
|
||||
throttleMs: 0,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
stream.update("Stale final draft");
|
||||
await stream.seal();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.path).toBe("/posts");
|
||||
expect(stream.postId()).toBe("post-1");
|
||||
});
|
||||
|
||||
it("stop flushes the last pending update and ignores later ones", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
rootId: "root-1",
|
||||
throttleMs: 1000,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
stream.update("Stale partial");
|
||||
await stream.stop();
|
||||
stream.update("Late partial");
|
||||
await stream.flush();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.path).toBe("/posts");
|
||||
expect(calls[1]?.path).toBe("/posts/post-1");
|
||||
expect(parseRequestJson(calls[1]?.init)).toEqual({
|
||||
id: "post-1",
|
||||
message: "Stale partial",
|
||||
});
|
||||
});
|
||||
|
||||
it("warns and stops when preview creation fails", async () => {
|
||||
const warn = vi.fn();
|
||||
const requestImpl: MattermostClient["request"] = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const requestMock = vi.fn(requestImpl);
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "token",
|
||||
request: requestMock as MattermostClient["request"],
|
||||
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
|
||||
};
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
throttleMs: 0,
|
||||
warn,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
stream.update("Still working...");
|
||||
await stream.flush();
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(requestMock).toHaveBeenCalledTimes(1);
|
||||
expect(stream.postId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates on a code-point boundary so a straddling emoji is dropped whole", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
// maxChars=12 => cut point is maxChars-3=9. The emoji 😀 occupies UTF-16
|
||||
// indices 8-9, so a raw slice(0,9) would keep the lone high surrogate at
|
||||
// index 8 and drop its low surrogate at index 9, leaking a dangling half.
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
throttleMs: 0,
|
||||
maxChars: 12,
|
||||
});
|
||||
|
||||
const input = `${"a".repeat(8)}\u{1F600}${"b".repeat(5)}`;
|
||||
stream.update(input);
|
||||
await stream.flush();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const message = parseRequestJson(calls[0]?.init).message;
|
||||
expect(typeof message).toBe("string");
|
||||
const sent = message as string;
|
||||
// The straddling emoji must be dropped whole, leaving no dangling surrogate half.
|
||||
expect(/[\uD800-\uDFFF]/u.test(sent)).toBe(false);
|
||||
expect(sent.length).toBeLessThanOrEqual(12);
|
||||
expect(sent).toBe("aaaaaaaa...");
|
||||
});
|
||||
|
||||
it("does not resend after an update failure followed by stop", async () => {
|
||||
const warn = vi.fn();
|
||||
const calls: RequestRecord[] = [];
|
||||
let failNextPatch = true;
|
||||
const requestImpl: MattermostClient["request"] = async <T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> => {
|
||||
calls.push({ path, init });
|
||||
if (path === "/posts") {
|
||||
return { id: "post-1" } as T;
|
||||
}
|
||||
if (path === "/posts/post-1") {
|
||||
if (failNextPatch) {
|
||||
failNextPatch = false;
|
||||
throw new Error("patch failed");
|
||||
}
|
||||
return { id: "patched" } as T;
|
||||
}
|
||||
return {} as T;
|
||||
};
|
||||
const requestMock = vi.fn(requestImpl);
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "token",
|
||||
request: requestMock as MattermostClient["request"],
|
||||
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
|
||||
};
|
||||
const stream = createMattermostDraftStream({
|
||||
client,
|
||||
channelId: "channel-1",
|
||||
throttleMs: 1000,
|
||||
warn,
|
||||
});
|
||||
|
||||
stream.update("Working...");
|
||||
await stream.flush();
|
||||
stream.update("Will fail");
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith("mattermost stream preview failed: patch failed");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.path).toBe("/posts");
|
||||
expect(calls[1]?.path).toBe("/posts/post-1");
|
||||
});
|
||||
});
|
||||
132
extensions/mattermost/src/mattermost/draft-stream.ts
Normal file
132
extensions/mattermost/src/mattermost/draft-stream.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Mattermost plugin module implements draft stream behavior.
|
||||
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
createMattermostPost,
|
||||
deleteMattermostPost,
|
||||
updateMattermostPost,
|
||||
type MattermostClient,
|
||||
} from "./client.js";
|
||||
|
||||
const MATTERMOST_STREAM_MAX_CHARS = 4000;
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
|
||||
type MattermostDraftStream = {
|
||||
update: (text: string) => void;
|
||||
flush: () => Promise<void>;
|
||||
postId: () => string | undefined;
|
||||
clear: () => Promise<void>;
|
||||
discardPending: () => Promise<void>;
|
||||
seal: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
forceNewMessage: () => void;
|
||||
};
|
||||
|
||||
function normalizeMattermostDraftText(text: string, maxChars: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${sliceUtf16Safe(trimmed, 0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function createMattermostDraftStream(params: {
|
||||
client: MattermostClient;
|
||||
channelId: string;
|
||||
rootId?: string;
|
||||
maxChars?: number;
|
||||
throttleMs?: number;
|
||||
renderText?: (text: string) => string;
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}): MattermostDraftStream {
|
||||
const maxChars = Math.min(
|
||||
params.maxChars ?? MATTERMOST_STREAM_MAX_CHARS,
|
||||
MATTERMOST_STREAM_MAX_CHARS,
|
||||
);
|
||||
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
|
||||
const streamState = { stopped: false, final: false };
|
||||
let streamPostId: string | undefined;
|
||||
let lastSentText = "";
|
||||
|
||||
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
|
||||
if (streamState.stopped && !streamState.final) {
|
||||
return false;
|
||||
}
|
||||
const rendered = params.renderText?.(text) ?? text;
|
||||
const normalized = normalizeMattermostDraftText(rendered, maxChars);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized === lastSentText) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (streamPostId) {
|
||||
await updateMattermostPost(params.client, streamPostId, {
|
||||
message: normalized,
|
||||
});
|
||||
} else {
|
||||
const sent = await createMattermostPost(params.client, {
|
||||
channelId: params.channelId,
|
||||
message: normalized,
|
||||
rootId: params.rootId,
|
||||
});
|
||||
const postId = sent.id?.trim();
|
||||
if (!postId) {
|
||||
streamState.stopped = true;
|
||||
params.warn?.("mattermost stream preview stopped (missing post id from create)");
|
||||
return false;
|
||||
}
|
||||
streamPostId = postId;
|
||||
}
|
||||
lastSentText = normalized;
|
||||
return true;
|
||||
} catch (err) {
|
||||
streamState.stopped = true;
|
||||
params.warn?.(
|
||||
`mattermost stream preview failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const { loop, update, stop, clear, discardPending, seal } = createFinalizableDraftLifecycle({
|
||||
throttleMs,
|
||||
state: streamState,
|
||||
sendOrEditStreamMessage,
|
||||
readMessageId: () => streamPostId,
|
||||
clearMessageId: () => {
|
||||
streamPostId = undefined;
|
||||
},
|
||||
isValidMessageId: (value): value is string => typeof value === "string" && value.length > 0,
|
||||
deleteMessage: async (postId) => {
|
||||
await deleteMattermostPost(params.client, postId);
|
||||
},
|
||||
warn: params.warn,
|
||||
warnPrefix: "mattermost stream preview cleanup failed",
|
||||
});
|
||||
|
||||
const forceNewMessage = () => {
|
||||
streamPostId = undefined;
|
||||
lastSentText = "";
|
||||
loop.resetPending();
|
||||
loop.resetThrottleWindow();
|
||||
};
|
||||
|
||||
params.log?.(`mattermost stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
|
||||
|
||||
return {
|
||||
update,
|
||||
flush: loop.flush,
|
||||
postId: () => streamPostId,
|
||||
clear,
|
||||
discardPending,
|
||||
seal,
|
||||
stop,
|
||||
forceNewMessage,
|
||||
};
|
||||
}
|
||||
935
extensions/mattermost/src/mattermost/interactions.test.ts
Normal file
935
extensions/mattermost/src/mattermost/interactions.test.ts
Normal file
@@ -0,0 +1,935 @@
|
||||
// Mattermost tests cover interactions plugin behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../runtime-api.js";
|
||||
import { setMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostClient, MattermostPost } from "./client.js";
|
||||
import {
|
||||
buildButtonAttachments,
|
||||
computeInteractionCallbackUrl,
|
||||
createMattermostInteractionHandler,
|
||||
generateInteractionToken,
|
||||
getInteractionSecret,
|
||||
resolveInteractionCallbackPath,
|
||||
resolveInteractionCallbackUrl,
|
||||
setInteractionCallbackUrl,
|
||||
setInteractionSecret,
|
||||
verifyInteractionToken,
|
||||
} from "./interactions.js";
|
||||
|
||||
type ButtonAttachments = ReturnType<typeof buildButtonAttachments>;
|
||||
type ButtonAttachment = ButtonAttachments[number];
|
||||
type ButtonAction = NonNullable<ButtonAttachment["actions"]>[number];
|
||||
|
||||
function requireFirstAttachment(attachments: ButtonAttachments): ButtonAttachment {
|
||||
const [attachment] = attachments;
|
||||
if (!attachment) {
|
||||
throw new Error("Expected button attachment fixture");
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function requireActions(attachments: ButtonAttachments): ButtonAction[] {
|
||||
const attachment = requireFirstAttachment(attachments);
|
||||
if (!attachment.actions) {
|
||||
throw new Error("Expected button attachment fixture actions");
|
||||
}
|
||||
return attachment.actions;
|
||||
}
|
||||
|
||||
function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction {
|
||||
const action = requireActions(attachments).at(index);
|
||||
if (!action) {
|
||||
throw new Error(`Expected button attachment action at index ${index}`);
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
// ── 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("verifies nested context regardless of nested key order", () => {
|
||||
const originalContext = {
|
||||
action_id: "nested",
|
||||
payload: {
|
||||
model: "gpt-5",
|
||||
meta: {
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
const token = generateInteractionToken(originalContext);
|
||||
|
||||
const reorderedContext = {
|
||||
payload: {
|
||||
meta: {
|
||||
page: 2,
|
||||
provider: "openai",
|
||||
},
|
||||
model: "gpt-5",
|
||||
},
|
||||
action_id: "nested",
|
||||
};
|
||||
|
||||
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects nested context tampering", () => {
|
||||
const originalContext = {
|
||||
action_id: "nested",
|
||||
payload: {
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
},
|
||||
};
|
||||
const token = generateInteractionToken(originalContext);
|
||||
const tamperedContext = {
|
||||
action_id: "nested",
|
||||
payload: {
|
||||
provider: "anthropic",
|
||||
model: "gpt-5",
|
||||
},
|
||||
};
|
||||
|
||||
expect(verifyInteractionToken(tamperedContext, token)).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInteractionCallbackUrl", () => {
|
||||
afterEach(() => {
|
||||
for (const accountId of ["cached", "default", "acct", "myaccount"]) {
|
||||
setInteractionCallbackUrl(accountId, "");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers cached URL from registry", () => {
|
||||
setInteractionCallbackUrl("cached", "http://cached:1234/path");
|
||||
expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path");
|
||||
});
|
||||
|
||||
it("recomputes from config when bypassing the cache explicitly", () => {
|
||||
setInteractionCallbackUrl("acct", "http://cached:1234/path");
|
||||
const url = computeInteractionCallbackUrl("acct", {
|
||||
gateway: { port: 9999, customBindHost: "gateway.internal" },
|
||||
});
|
||||
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses interactions.callbackBaseUrl when configured", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
channels: {
|
||||
mattermost: {
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/openclaw/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("trims trailing slashes from callbackBaseUrl", () => {
|
||||
const url = resolveInteractionCallbackUrl("acct", {
|
||||
channels: {
|
||||
mattermost: {
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/root///",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses merged per-account interactions.callbackBaseUrl", () => {
|
||||
const cfg = {
|
||||
gateway: { port: 9999 },
|
||||
channels: {
|
||||
mattermost: {
|
||||
accounts: {
|
||||
acct: {
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: "acct",
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
const url = resolveInteractionCallbackUrl(account.accountId, {
|
||||
gateway: cfg.gateway,
|
||||
interactions: account.config.interactions,
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("falls back to gateway.customBindHost when configured", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
gateway: { port: 9999, customBindHost: "gateway.internal" },
|
||||
});
|
||||
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("falls back to localhost when customBindHost is a wildcard bind address", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
gateway: { port: 9999, customBindHost: "0.0.0.0" },
|
||||
});
|
||||
expect(url).toBe("http://localhost:9999/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("brackets IPv6 custom bind hosts", () => {
|
||||
const url = resolveInteractionCallbackUrl("acct", {
|
||||
gateway: { port: 9999, customBindHost: "::1" },
|
||||
});
|
||||
expect(url).toBe("http://[::1]:9999/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses default port 18789 when no config provided", () => {
|
||||
const url = resolveInteractionCallbackUrl("myaccount");
|
||||
expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInteractionCallbackPath", () => {
|
||||
it("builds the per-account callback path", () => {
|
||||
expect(resolveInteractionCallbackPath("acct")).toBe("/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(requireActions(result)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets type to 'button' on every action", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/cb",
|
||||
buttons: [{ id: "a", name: "A" }],
|
||||
});
|
||||
|
||||
expect(requireAction(result).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 = requireAction(result);
|
||||
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 = requireAction(result);
|
||||
// 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 = requireAction(result).integration.context;
|
||||
expect(ctx.tweet_id).toBe("123");
|
||||
expect(ctx.batch).toBe(true);
|
||||
expect(ctx.action_id).toBe("btn");
|
||||
expect(ctx["_token"]).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
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 requireActions(result)) {
|
||||
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(requireAction(result, 0).style).toBe("primary");
|
||||
expect(requireAction(result, 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(requireFirstAttachment(result).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(requireFirstAttachment(result).text).toBe("");
|
||||
});
|
||||
|
||||
it("generates verifiable tokens", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
|
||||
});
|
||||
|
||||
const ctx = requireAction(result).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 = requireAction(result).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.toReversed()) {
|
||||
reordered[key] = ctx[key];
|
||||
}
|
||||
expect(verifyInteractionToken(reordered, token)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMattermostInteractionHandler", () => {
|
||||
function setInteractionRuntime(
|
||||
enqueueSystemEvent: (
|
||||
text: string,
|
||||
options: { sessionKey?: string | null; sessionId?: string | null; userId?: string | null },
|
||||
) => boolean = () => true,
|
||||
) {
|
||||
setMattermostRuntime({
|
||||
system: {
|
||||
enqueueSystemEvent,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
function createMattermostClientMock(
|
||||
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
|
||||
): MattermostClient {
|
||||
return {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "bot-token",
|
||||
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
|
||||
fetchImpl: vi.fn<typeof fetch>(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setInteractionRuntime();
|
||||
setInteractionSecret("acct", "bot-token");
|
||||
});
|
||||
|
||||
function createReq(params: {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
remoteAddress?: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
const body =
|
||||
params.body === undefined
|
||||
? ""
|
||||
: typeof params.body === "string"
|
||||
? params.body
|
||||
: JSON.stringify(params.body);
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const req = {
|
||||
destroyed: false,
|
||||
method: params.method ?? "POST",
|
||||
headers: params.headers ?? {},
|
||||
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(handler);
|
||||
listeners.set(event, existing);
|
||||
return this;
|
||||
},
|
||||
removeListener(event: string, handler: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
listeners.set(
|
||||
event,
|
||||
existing.filter((entry) => entry !== handler),
|
||||
);
|
||||
return this;
|
||||
},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
},
|
||||
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
|
||||
|
||||
req.emitTest = (event: string, ...args: unknown[]) => {
|
||||
const handlers = listeners.get(event) ?? [];
|
||||
for (const handler of handlers) {
|
||||
handler(...args);
|
||||
}
|
||||
};
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (body) {
|
||||
req.emitTest("data", Buffer.from(body));
|
||||
}
|
||||
req.emitTest("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createRes(): ServerResponse & { headers: Record<string, string>; body: string } {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
setHeader(name: string, value: string | number | readonly string[]) {
|
||||
res.headers[name] = Array.isArray(value) ? value.join(",") : String(value);
|
||||
return res;
|
||||
},
|
||||
end(
|
||||
chunk?: string | Buffer | Uint8Array,
|
||||
_encoding?: BufferEncoding | (() => void),
|
||||
cb?: () => void,
|
||||
) {
|
||||
res.body = chunk ? String(chunk) : "";
|
||||
cb?.();
|
||||
return res;
|
||||
},
|
||||
} as ServerResponse & { headers: Record<string, string>; body: string };
|
||||
return res;
|
||||
}
|
||||
|
||||
function createActionContext(actionId = "approve", channelId = "chan-1") {
|
||||
const context = { action_id: actionId, __openclaw_channel_id: channelId };
|
||||
return { context, token: generateInteractionToken(context, "acct") };
|
||||
}
|
||||
|
||||
function createInteractionBody(params: {
|
||||
context: Record<string, unknown>;
|
||||
token: string;
|
||||
channelId?: string;
|
||||
postId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}) {
|
||||
return {
|
||||
user_id: params.userId ?? "user-1",
|
||||
...(params.userName ? { user_name: params.userName } : {}),
|
||||
channel_id: params.channelId ?? "chan-1",
|
||||
post_id: params.postId ?? "post-1",
|
||||
context: { ...params.context, _token: params.token },
|
||||
};
|
||||
}
|
||||
|
||||
async function runHandler(
|
||||
handler: ReturnType<typeof createMattermostInteractionHandler>,
|
||||
params: {
|
||||
body: unknown;
|
||||
remoteAddress?: string;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const req = createReq({
|
||||
remoteAddress: params.remoteAddress,
|
||||
headers: params.headers,
|
||||
body: params.body,
|
||||
});
|
||||
const res = createRes();
|
||||
await handler(req, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function expectForbiddenResponse(
|
||||
res: ServerResponse & { body: string },
|
||||
expectedMessage: string,
|
||||
) {
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toContain(expectedMessage);
|
||||
}
|
||||
|
||||
function expectSuccessfulApprovalUpdate(
|
||||
res: ServerResponse & { body: string },
|
||||
requestLog?: Array<{ path: string; method?: string }>,
|
||||
) {
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("{}");
|
||||
if (requestLog) {
|
||||
expect(requestLog).toEqual([
|
||||
{ path: "/posts/post-1", method: undefined },
|
||||
{ path: "/posts/post-1", method: "PUT" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function createActionPost(params?: {
|
||||
actionId?: string;
|
||||
actionName?: string;
|
||||
channelId?: string;
|
||||
rootId?: string;
|
||||
}): MattermostPost {
|
||||
return {
|
||||
id: "post-1",
|
||||
channel_id: params?.channelId ?? "chan-1",
|
||||
...(params?.rootId ? { root_id: params.rootId } : {}),
|
||||
message: "Choose",
|
||||
props: {
|
||||
attachments: [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
id: params?.actionId ?? "approve",
|
||||
name: params?.actionName ?? "Approve",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUnusedInteractionHandler() {
|
||||
return createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => ({ message: "unused" })),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
}
|
||||
|
||||
async function runApproveInteraction(params?: {
|
||||
actionName?: string;
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
remoteAddress?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const { context, token } = createActionContext();
|
||||
const requestLog: Array<{ path: string; method?: string }> = [];
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
|
||||
requestLog.push({ path, method: init?.method });
|
||||
if (init?.method === "PUT") {
|
||||
return { id: "post-1" };
|
||||
}
|
||||
return createActionPost({ actionName: params?.actionName });
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
allowedSourceIps: params?.allowedSourceIps,
|
||||
trustedProxies: params?.trustedProxies,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
remoteAddress: params?.remoteAddress,
|
||||
headers: params?.headers,
|
||||
body: createInteractionBody({ context, token, userName: "alice" }),
|
||||
});
|
||||
return { res, requestLog };
|
||||
}
|
||||
|
||||
async function runInvalidActionRequest(actionId: string) {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () =>
|
||||
createActionPost({ actionId, actionName: actionId }),
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
|
||||
return await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts callback requests from an allowlisted source IP", async () => {
|
||||
const { res, requestLog } = await runApproveInteraction({
|
||||
allowedSourceIps: ["198.51.100.8"],
|
||||
remoteAddress: "198.51.100.8",
|
||||
});
|
||||
|
||||
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON callback requests with a stable parser error", async () => {
|
||||
const log = vi.fn();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => {
|
||||
throw new Error("unexpected client request");
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
log,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, { body: "{not json" });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toBe(JSON.stringify({ error: "Invalid request body" }));
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"mattermost interaction: failed to parse body: Error: Mattermost interaction body was malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
|
||||
const { res } = await runApproveInteraction({
|
||||
allowedSourceIps: ["198.51.100.8"],
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: { "x-forwarded-for": "198.51.100.8" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("rejects callback requests from non-allowlisted source IPs", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => {
|
||||
throw new Error("should not fetch post for rejected origins");
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
allowedSourceIps: ["127.0.0.1"],
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
remoteAddress: "198.51.100.8",
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Forbidden origin");
|
||||
});
|
||||
|
||||
it("rejects requests with an invalid interaction token", async () => {
|
||||
const handler = createUnusedInteractionHandler();
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: {
|
||||
user_id: "user-1",
|
||||
channel_id: "chan-1",
|
||||
post_id: "post-1",
|
||||
context: { action_id: "approve", _token: "deadbeef" },
|
||||
},
|
||||
});
|
||||
expectForbiddenResponse(res, "Invalid token");
|
||||
});
|
||||
|
||||
it("rejects requests when the signed channel does not match the callback payload", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createUnusedInteractionHandler();
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token, channelId: "chan-2" }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Channel mismatch");
|
||||
});
|
||||
|
||||
it("rejects requests when the fetched post does not belong to the callback channel", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => createActionPost({ channelId: "chan-9" })),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Post/channel mismatch");
|
||||
});
|
||||
|
||||
it("rejects requests when the action is not present on the fetched post", async () => {
|
||||
const res = await runInvalidActionRequest("reject");
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toContain("Unknown action");
|
||||
});
|
||||
|
||||
it("accepts actions when the button name matches the action id", async () => {
|
||||
const { res, requestLog } = await runApproveInteraction({
|
||||
actionName: "approve",
|
||||
});
|
||||
|
||||
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||
});
|
||||
|
||||
it("blocks button dispatch when the sender is not allowed for the action", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const handleInteraction = vi.fn();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : createActionPost(),
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
authorizeButtonClick: async () => ({
|
||||
ok: false,
|
||||
response: {
|
||||
ephemeral_text: "blocked",
|
||||
},
|
||||
}),
|
||||
handleInteraction,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain("blocked");
|
||||
expect(handleInteraction).not.toHaveBeenCalled();
|
||||
expect(dispatchButtonClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards fetched post threading metadata to session and button callbacks", async () => {
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
setInteractionRuntime(enqueueSystemEvent);
|
||||
const { context, token } = createActionContext();
|
||||
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const fetchedPost = createActionPost({ rootId: "root-9" });
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
resolveSessionKey,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token, userName: "alice" }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(resolveSessionKey).toHaveBeenCalledWith({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
post: fetchedPost,
|
||||
});
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
'Mattermost button click: action="approve" by alice in channel chan-1',
|
||||
{
|
||||
sessionKey: "session:thread:root-9",
|
||||
contextKey: "mattermost:interaction:post-1:approve",
|
||||
},
|
||||
);
|
||||
expect(dispatchButtonClick).toHaveBeenCalledWith({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
userName: "alice",
|
||||
actionId: "approve",
|
||||
actionName: "Approve",
|
||||
postId: "post-1",
|
||||
post: fetchedPost,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
|
||||
const { context, token } = createActionContext("mdlprov");
|
||||
const requestLog: Array<{ path: string; method?: string }> = [];
|
||||
const handleInteraction = vi.fn().mockResolvedValue({
|
||||
ephemeral_text: "Only the original requester can use this picker.",
|
||||
});
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const originalPost = createActionPost({
|
||||
actionId: "mdlprov",
|
||||
actionName: "Browse providers",
|
||||
});
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
|
||||
requestLog.push({ path, method: init?.method });
|
||||
return originalPost;
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
handleInteraction,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
const body = createInteractionBody({
|
||||
context,
|
||||
token,
|
||||
userId: "user-2",
|
||||
userName: "alice",
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, { body });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
ephemeral_text: "Only the original requester can use this picker.",
|
||||
}),
|
||||
);
|
||||
expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]);
|
||||
expect(handleInteraction).toHaveBeenCalledWith({
|
||||
payload: body,
|
||||
userName: "alice",
|
||||
actionId: "mdlprov",
|
||||
actionName: "Browse providers",
|
||||
originalMessage: "Choose",
|
||||
context,
|
||||
post: originalPost,
|
||||
});
|
||||
expect(dispatchButtonClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
677
extensions/mattermost/src/mattermost/interactions.ts
Normal file
677
extensions/mattermost/src/mattermost/interactions.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
// Mattermost plugin module implements interactions behavior.
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
|
||||
import {
|
||||
isTrustedProxyAddress,
|
||||
readRequestBodyWithLimit,
|
||||
resolveClientIp,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||
const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id";
|
||||
|
||||
/**
|
||||
* Mattermost interactive message callback payload.
|
||||
* Sent by Mattermost when a user clicks an action button.
|
||||
* See: https://developers.mattermost.com/integrate/plugins/interactive-messages/
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
type MattermostInteractionAuthorizationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; statusCode?: number; response?: MattermostInteractionResponse };
|
||||
|
||||
export type MattermostInteractiveButtonInput = {
|
||||
id?: string;
|
||||
callback_data?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ── Callback URL registry ──────────────────────────────────────────────
|
||||
|
||||
const callbackUrls = new Map<string, string>();
|
||||
|
||||
export function setInteractionCallbackUrl(accountId: string, url: string): void {
|
||||
callbackUrls.set(accountId, url);
|
||||
}
|
||||
|
||||
type InteractionCallbackConfig = Pick<OpenClawConfig, "gateway" | "channels"> & {
|
||||
interactions?: {
|
||||
callbackBaseUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveInteractionCallbackPath(accountId: string): string {
|
||||
return `/mattermost/interactions/${accountId}`;
|
||||
}
|
||||
|
||||
function isWildcardBindHost(rawHost: string): boolean {
|
||||
const trimmed = rawHost.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||
}
|
||||
|
||||
function normalizeCallbackBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeOptionalString(value[0]);
|
||||
}
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
function isAllowedInteractionSource(params: {
|
||||
req: IncomingMessage;
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
}): boolean {
|
||||
const { allowedSourceIps } = params;
|
||||
if (!allowedSourceIps?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const clientIp = resolveClientIp({
|
||||
remoteAddr: params.req.socket?.remoteAddress,
|
||||
forwardedFor: headerValue(params.req.headers["x-forwarded-for"]),
|
||||
realIp: headerValue(params.req.headers["x-real-ip"]),
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
});
|
||||
return isTrustedProxyAddress(clientIp, allowedSourceIps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the interaction callback URL for an account.
|
||||
* Falls back to computing it from interactions.callbackBaseUrl or gateway host config.
|
||||
*/
|
||||
export function computeInteractionCallbackUrl(
|
||||
accountId: string,
|
||||
cfg?: InteractionCallbackConfig,
|
||||
): string {
|
||||
const path = resolveInteractionCallbackPath(accountId);
|
||||
// Prefer merged per-account config when available, but keep the top-level path for
|
||||
// callers/tests that still pass the root Mattermost config shape directly.
|
||||
const callbackBaseUrl =
|
||||
normalizeOptionalString(cfg?.interactions?.callbackBaseUrl) ??
|
||||
normalizeOptionalString(cfg?.channels?.mattermost?.interactions?.callbackBaseUrl);
|
||||
if (callbackBaseUrl) {
|
||||
return `${normalizeCallbackBaseUrl(callbackBaseUrl)}${path}`;
|
||||
}
|
||||
const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789;
|
||||
let host =
|
||||
cfg?.gateway?.customBindHost && !isWildcardBindHost(cfg.gateway.customBindHost)
|
||||
? cfg.gateway.customBindHost.trim()
|
||||
: "localhost";
|
||||
|
||||
// Bracket IPv6 literals so the URL is valid: http://[::1]:18789/...
|
||||
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
return `http://${host}:${port}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the interaction callback URL for an account.
|
||||
* Prefers the in-memory registered URL (set by the gateway monitor) so callers outside the
|
||||
* monitor lifecycle can reuse the runtime-validated callback destination.
|
||||
*/
|
||||
export function resolveInteractionCallbackUrl(
|
||||
accountId: string,
|
||||
cfg?: InteractionCallbackConfig,
|
||||
): string {
|
||||
const cached = callbackUrls.get(accountId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return computeInteractionCallbackUrl(accountId, cfg);
|
||||
}
|
||||
|
||||
// ── 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",
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalizeInteractionContext(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => canonicalizeInteractionContext(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function generateInteractionToken(
|
||||
context: Record<string, unknown>,
|
||||
accountId?: string,
|
||||
): string {
|
||||
const secret = getInteractionSecret(accountId);
|
||||
const payload = JSON.stringify(canonicalizeInteractionContext(context));
|
||||
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);
|
||||
return safeEqualSecret(expected, token);
|
||||
}
|
||||
|
||||
// ── Button builder helpers ─────────────────────────────────────────────
|
||||
|
||||
type MattermostButton = {
|
||||
id: string;
|
||||
type: "button" | "select";
|
||||
name: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
integration: {
|
||||
url: string;
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildButtonProps(params: {
|
||||
callbackUrl: string;
|
||||
accountId?: string;
|
||||
channelId: string;
|
||||
buttons: Array<unknown>;
|
||||
text?: string;
|
||||
}): Record<string, unknown> | undefined {
|
||||
const rawButtons = params.buttons.flatMap((item) =>
|
||||
Array.isArray(item) ? item : [item],
|
||||
) as MattermostInteractiveButtonInput[];
|
||||
|
||||
const buttons = rawButtons
|
||||
.map((btn) => ({
|
||||
id: normalizeStringifiedOptionalString(btn.id ?? btn.callback_data) ?? "",
|
||||
name: normalizeStringifiedOptionalString(btn.text ?? btn.name ?? btn.label) ?? "",
|
||||
style: btn.style ?? "default",
|
||||
context:
|
||||
typeof btn.context === "object" && btn.context !== null
|
||||
? {
|
||||
...btn.context,
|
||||
[SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId,
|
||||
}
|
||||
: { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId },
|
||||
}))
|
||||
.filter((btn) => btn.id && btn.name);
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
attachments: buildButtonAttachments({
|
||||
callbackUrl: params.callbackUrl,
|
||||
accountId: params.accountId,
|
||||
buttons,
|
||||
text: params.text,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Request body reader ────────────────────────────────────────────────
|
||||
|
||||
function readInteractionBody(req: IncomingMessage): Promise<string> {
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes: INTERACTION_MAX_BODY_BYTES,
|
||||
timeoutMs: INTERACTION_BODY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||
|
||||
export function createMattermostInteractionHandler(params: {
|
||||
client: MattermostClient;
|
||||
botUserId: string;
|
||||
accountId: string;
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
resolveSessionKey?: (params: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<string>;
|
||||
handleInteraction?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
userName: string;
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
originalMessage: string;
|
||||
context: Record<string, unknown>;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionResponse | null>;
|
||||
authorizeButtonClick?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionAuthorizationResult>;
|
||||
dispatchButtonClick?: (opts: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
postId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<void>;
|
||||
log?: (message: string) => void;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
const { client, accountId, log } = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
function parseInteractionPayload(raw: string): MattermostInteractionPayload {
|
||||
try {
|
||||
return JSON.parse(raw) as MattermostInteractionPayload;
|
||||
} catch {
|
||||
throw new Error("Mattermost interaction body was malformed JSON");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
!isAllowedInteractionSource({
|
||||
req,
|
||||
allowedSourceIps: params.allowedSourceIps,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
})
|
||||
) {
|
||||
log?.(
|
||||
`mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Forbidden origin" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: MattermostInteractionPayload;
|
||||
try {
|
||||
const raw = await readInteractionBody(req);
|
||||
payload = parseInteractionPayload(raw);
|
||||
} 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;
|
||||
}
|
||||
|
||||
const signedChannelId =
|
||||
typeof contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY] === "string"
|
||||
? contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY].trim()
|
||||
: "";
|
||||
if (signedChannelId && signedChannelId !== payload.channel_id) {
|
||||
log?.(
|
||||
`mattermost interaction: signed channel mismatch payload=${payload.channel_id} signed=${signedChannelId}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Channel mismatch" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userName = payload.user_name ?? payload.user_id;
|
||||
let originalMessage;
|
||||
let originalPost: MattermostPost | null;
|
||||
let clickedButtonName: string | null = null;
|
||||
try {
|
||||
originalPost = await client.request<MattermostPost>(`/posts/${payload.post_id}`);
|
||||
const postChannelId = originalPost.channel_id?.trim();
|
||||
if (!postChannelId || postChannelId !== payload.channel_id) {
|
||||
log?.(
|
||||
`mattermost interaction: post channel mismatch payload=${payload.channel_id} post=${postChannelId ?? "<missing>"}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Post/channel mismatch" }));
|
||||
return;
|
||||
}
|
||||
originalMessage = originalPost.message ?? "";
|
||||
|
||||
// Ensure the callback can only target an action that exists on the original post.
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (clickedButtonName === null) {
|
||||
log?.(`mattermost interaction: action ${actionId} not found in post ${payload.post_id}`);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Unknown action" }));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: failed to validate post ${payload.post_id}: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to validate interaction" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!originalPost) {
|
||||
log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to load interaction post" }));
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(
|
||||
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||
);
|
||||
|
||||
if (params.authorizeButtonClick) {
|
||||
try {
|
||||
const authorization = await params.authorizeButtonClick({
|
||||
payload,
|
||||
post: originalPost,
|
||||
});
|
||||
if (!authorization.ok) {
|
||||
res.statusCode = authorization.statusCode ?? 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
authorization.response ?? {
|
||||
ephemeral_text: "You are not allowed to use this action here.",
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.handleInteraction) {
|
||||
try {
|
||||
const response = await params.handleInteraction({
|
||||
payload,
|
||||
userName,
|
||||
actionId,
|
||||
actionName: clickedButtonName,
|
||||
originalMessage,
|
||||
context: contextWithoutToken,
|
||||
post: originalPost,
|
||||
});
|
||||
if (response !== null) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: custom handler failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Interaction handler failed" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
channelId: payload.channel_id,
|
||||
userId: payload.user_id,
|
||||
post: originalPost,
|
||||
})
|
||||
: `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)}`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
post: originalPost,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
279
extensions/mattermost/src/mattermost/model-picker.test.ts
Normal file
279
extensions/mattermost/src/mattermost/model-picker.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// Mattermost tests cover model picker plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import {
|
||||
buildMattermostAllowedModelRefs,
|
||||
parseMattermostModelPickerContext,
|
||||
renderMattermostModelSummaryView,
|
||||
renderMattermostModelsPickerView,
|
||||
renderMattermostProviderPickerView,
|
||||
resolveMattermostModelPickerCurrentModel,
|
||||
resolveMattermostModelPickerEntry,
|
||||
} from "./model-picker.js";
|
||||
|
||||
const data = {
|
||||
byProvider: new Map<string, Set<string>>([
|
||||
["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])],
|
||||
["openai", new Set(["gpt-4.1", "gpt-5"])],
|
||||
]),
|
||||
providers: ["anthropic", "openai"],
|
||||
resolvedDefault: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
},
|
||||
modelNames: new Map<string, string>(),
|
||||
};
|
||||
|
||||
describe("Mattermost model picker", () => {
|
||||
it("resolves bare /model and /models entry points", () => {
|
||||
expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" });
|
||||
expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" });
|
||||
expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({
|
||||
kind: "models",
|
||||
provider: "openai",
|
||||
});
|
||||
expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the allowed model refs set", () => {
|
||||
expect(buildMattermostAllowedModelRefs(data)).toEqual(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-5",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the summary view with a browse button", () => {
|
||||
const view = renderMattermostModelSummaryView({
|
||||
ownerUserId: "user-1",
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
|
||||
expect(view.text).toContain("Current: openai/gpt-5");
|
||||
expect(view.text).toContain("Tap below to browse models");
|
||||
expect(view.text).toContain("/oc_model <provider/model> to switch");
|
||||
expect(view.text).toContain("Browse keeps the current runtime");
|
||||
expect(view.text).toContain("/oc_model <provider/model> --runtime <runtime>");
|
||||
const firstRow = view.buttons[0];
|
||||
if (!firstRow) {
|
||||
throw new Error("expected Mattermost model picker button row");
|
||||
}
|
||||
const browseButton = firstRow[0];
|
||||
if (!browseButton) {
|
||||
throw new Error("expected Mattermost browse providers button");
|
||||
}
|
||||
expect(browseButton.text).toBe("Browse providers");
|
||||
});
|
||||
|
||||
it("trims accidental model spacing in Mattermost current-model text", () => {
|
||||
const view = renderMattermostModelSummaryView({
|
||||
ownerUserId: "user-1",
|
||||
currentModel: " OpenAI/ gpt-5 ",
|
||||
});
|
||||
|
||||
expect(view.text).toContain("Current: openai/gpt-5");
|
||||
});
|
||||
|
||||
it("renders providers and models with Telegram-style navigation", () => {
|
||||
const providersView = renderMattermostProviderPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
const providerTexts = providersView.buttons.flat().map((button) => button.text);
|
||||
expect(providerTexts).toContain("anthropic (2)");
|
||||
expect(providerTexts).toContain("openai (2)");
|
||||
|
||||
const modelsView = renderMattermostModelsPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
provider: "openai",
|
||||
page: 1,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
const modelTexts = modelsView.buttons.flat().map((button) => button.text);
|
||||
expect(modelsView.text).toContain("Models (openai) - 2 available");
|
||||
expect(modelTexts).toContain("gpt-5 [current]");
|
||||
expect(modelTexts).toContain("Back to providers");
|
||||
});
|
||||
|
||||
it("renders unique alphanumeric action ids per button", () => {
|
||||
const modelsView = renderMattermostModelsPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
provider: "openai",
|
||||
page: 1,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
|
||||
const ids = modelsView.buttons.flat().map((button) => button.id);
|
||||
expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("parses signed picker contexts", () => {
|
||||
expect(
|
||||
parseMattermostModelPickerContext({
|
||||
oc_model_picker: true,
|
||||
action: "select",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
model: "gpt-5",
|
||||
}),
|
||||
).toEqual({
|
||||
action: "select",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
model: "gpt-5",
|
||||
});
|
||||
expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not coerce partial page strings in signed picker contexts", () => {
|
||||
expect(
|
||||
parseMattermostModelPickerContext({
|
||||
oc_model_picker: true,
|
||||
action: "list",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: "+02",
|
||||
}),
|
||||
).toEqual({
|
||||
action: "list",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
});
|
||||
expect(
|
||||
parseMattermostModelPickerContext({
|
||||
oc_model_picker: true,
|
||||
action: "list",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: "2next",
|
||||
}),
|
||||
).toEqual({
|
||||
action: "list",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the routed agent default model when no override is stored", () => {
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(testDir, "{agentId}.json"),
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
model: "openai/gpt-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const providerData = {
|
||||
byProvider: new Map<string, Set<string>>([
|
||||
["anthropic", new Set(["claude-opus-4-5"])],
|
||||
["openai", new Set(["gpt-5"])],
|
||||
]),
|
||||
providers: ["anthropic", "openai"],
|
||||
resolvedDefault: {
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
},
|
||||
modelNames: new Map<string, string>(),
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route: {
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:main",
|
||||
},
|
||||
data: providerData,
|
||||
}),
|
||||
).toBe("openai/gpt-5");
|
||||
} finally {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves current and parent model overrides from targeted session entries", () => {
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
|
||||
try {
|
||||
const storePath = path.join(testDir, "{agentId}.json");
|
||||
const supportStorePath = path.join(testDir, "support.json");
|
||||
const parentSessionKey = "agent:support:mattermost:default:channel-1";
|
||||
const childSessionKey = "agent:support:mattermost:default:child-with-explicit-parent";
|
||||
const directSessionKey = "agent:support:mattermost:default:direct-1";
|
||||
fs.writeFileSync(
|
||||
supportStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[parentSessionKey]: {
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4-5",
|
||||
sessionId: "parent-session",
|
||||
},
|
||||
[childSessionKey]: {
|
||||
parentSessionKey,
|
||||
sessionId: "child-session",
|
||||
},
|
||||
[directSessionKey]: {
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5",
|
||||
sessionId: "direct-session",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route: {
|
||||
agentId: "support",
|
||||
sessionKey: directSessionKey,
|
||||
},
|
||||
data,
|
||||
}),
|
||||
).toBe("openai/gpt-5");
|
||||
expect(
|
||||
resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route: {
|
||||
agentId: "support",
|
||||
sessionKey: childSessionKey,
|
||||
},
|
||||
data,
|
||||
}),
|
||||
).toBe("anthropic/claude-sonnet-4-5");
|
||||
} finally {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
411
extensions/mattermost/src/mattermost/model-picker.ts
Normal file
411
extensions/mattermost/src/mattermost/model-picker.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
// Mattermost plugin module implements model picker behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
resolveStoredModelOverride,
|
||||
type ModelsProviderData,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MattermostInteractiveButtonInput } from "./interactions.js";
|
||||
|
||||
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
|
||||
const MODELS_PAGE_SIZE = 8;
|
||||
const ACTION_IDS = {
|
||||
providers: "mdlprov",
|
||||
list: "mdllist",
|
||||
select: "mdlsel",
|
||||
back: "mdlback",
|
||||
} as const;
|
||||
|
||||
type MattermostModelPickerEntry =
|
||||
| { kind: "summary" }
|
||||
| { kind: "providers" }
|
||||
| { kind: "models"; provider: string };
|
||||
|
||||
type MattermostModelPickerState =
|
||||
| { action: "providers"; ownerUserId: string }
|
||||
| { action: "back"; ownerUserId: string }
|
||||
| { action: "list"; ownerUserId: string; provider: string; page: number }
|
||||
| { action: "select"; ownerUserId: string; provider: string; page: number; model: string };
|
||||
|
||||
type MattermostModelPickerRenderedView = {
|
||||
text: string;
|
||||
buttons: MattermostInteractiveButtonInput[][];
|
||||
};
|
||||
|
||||
function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null {
|
||||
const trimmed = normalizeOptionalString(modelRef);
|
||||
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const provider = normalizeProviderId(match[1]);
|
||||
// Mattermost copy should normalize accidental whitespace around the model.
|
||||
const model = normalizeOptionalString(match[2]);
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function readContextString(context: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = context[key];
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function readContextNumber(context: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = context[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return parseStrictInteger(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePage(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.floor(value as number));
|
||||
}
|
||||
|
||||
function paginateItems<T>(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) {
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const safePage = Math.max(1, Math.min(normalizePage(page), totalPages));
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return {
|
||||
items: items.slice(start, start + pageSize),
|
||||
page: safePage,
|
||||
totalPages,
|
||||
hasPrev: safePage > 1,
|
||||
hasNext: safePage < totalPages,
|
||||
totalItems: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(state: MattermostModelPickerState): Record<string, unknown> {
|
||||
return {
|
||||
[MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true,
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
function buildButtonId(state: MattermostModelPickerState): string {
|
||||
const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12);
|
||||
return `${ACTION_IDS[state.action]}${digest}`;
|
||||
}
|
||||
|
||||
function buildButton(params: {
|
||||
action: MattermostModelPickerState["action"];
|
||||
ownerUserId: string;
|
||||
text: string;
|
||||
provider?: string;
|
||||
page?: number;
|
||||
model?: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
}): MattermostInteractiveButtonInput {
|
||||
const baseState =
|
||||
params.action === "providers" || params.action === "back"
|
||||
? {
|
||||
action: params.action,
|
||||
ownerUserId: params.ownerUserId,
|
||||
}
|
||||
: params.action === "list"
|
||||
? {
|
||||
action: "list" as const,
|
||||
ownerUserId: params.ownerUserId,
|
||||
provider: normalizeProviderId(params.provider ?? ""),
|
||||
page: normalizePage(params.page),
|
||||
}
|
||||
: {
|
||||
action: "select" as const,
|
||||
ownerUserId: params.ownerUserId,
|
||||
provider: normalizeProviderId(params.provider ?? ""),
|
||||
page: normalizePage(params.page),
|
||||
model: normalizeStringifiedOptionalString(params.model) ?? "",
|
||||
};
|
||||
|
||||
return {
|
||||
// Mattermost requires action IDs to be unique within a post.
|
||||
id: buildButtonId(baseState),
|
||||
text: params.text,
|
||||
...(params.style ? { style: params.style } : {}),
|
||||
context: buildContext(baseState),
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderModels(data: ModelsProviderData, provider: string): string[] {
|
||||
return [...(data.byProvider.get(normalizeProviderId(provider)) ?? new Set<string>())].toSorted();
|
||||
}
|
||||
|
||||
function formatCurrentModelLine(currentModel?: string): string {
|
||||
const parsed = splitModelRef(currentModel);
|
||||
if (!parsed) {
|
||||
return "Current: default";
|
||||
}
|
||||
return `Current: ${parsed.provider}/${parsed.model}`;
|
||||
}
|
||||
|
||||
export function resolveMattermostModelPickerEntry(
|
||||
commandText: string,
|
||||
): MattermostModelPickerEntry | null {
|
||||
const normalized = commandText.trim().replace(/\s+/g, " ");
|
||||
if (/^\/model$/i.test(normalized)) {
|
||||
return { kind: "summary" };
|
||||
}
|
||||
if (/^\/models$/i.test(normalized)) {
|
||||
return { kind: "providers" };
|
||||
}
|
||||
const providerMatch = normalized.match(/^\/models\s+(\S+)$/i);
|
||||
if (!providerMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "models",
|
||||
provider: normalizeProviderId(providerMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMattermostModelPickerContext(
|
||||
context: Record<string, unknown>,
|
||||
): MattermostModelPickerState | null {
|
||||
if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerUserId = normalizeOptionalString(readContextString(context, "ownerUserId")) ?? "";
|
||||
const action = normalizeOptionalString(readContextString(context, "action")) ?? "";
|
||||
if (!ownerUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === "providers" || action === "back") {
|
||||
return { action, ownerUserId };
|
||||
}
|
||||
|
||||
const provider = normalizeProviderId(readContextString(context, "provider"));
|
||||
const page = readContextNumber(context, "page");
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === "list") {
|
||||
return {
|
||||
action,
|
||||
ownerUserId,
|
||||
provider,
|
||||
page: normalizePage(page),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "select") {
|
||||
const model = normalizeOptionalString(readContextString(context, "model")) ?? "";
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
action,
|
||||
ownerUserId,
|
||||
provider,
|
||||
page: normalizePage(page),
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
for (const provider of data.providers) {
|
||||
for (const model of data.byProvider.get(provider) ?? []) {
|
||||
refs.add(`${provider}/${model}`);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function resolveMattermostModelPickerCurrentModel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: { agentId: string; sessionKey: string };
|
||||
data: ModelsProviderData;
|
||||
readConsistency?: "latest";
|
||||
}): string {
|
||||
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
try {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
const sessionEntry = getSessionEntry({
|
||||
storePath,
|
||||
sessionKey: params.route.sessionKey,
|
||||
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
|
||||
});
|
||||
const override = resolveStoredModelOverride({
|
||||
sessionEntry,
|
||||
loadSessionEntry: (sessionKey) =>
|
||||
getSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
|
||||
}),
|
||||
sessionKey: params.route.sessionKey,
|
||||
parentSessionKey: sessionEntry?.parentSessionKey,
|
||||
defaultProvider: params.data.resolvedDefault.provider,
|
||||
});
|
||||
if (!override?.model) {
|
||||
return fallback;
|
||||
}
|
||||
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
|
||||
return provider ? `${provider}/${override.model}` : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMattermostModelSummaryView(params: {
|
||||
ownerUserId: string;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
return {
|
||||
text: [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
"",
|
||||
"Tap below to browse models, or use:",
|
||||
"/oc_model <provider/model> to switch",
|
||||
"Browse keeps the current runtime; use /oc_model <provider/model> --runtime <runtime> to switch runtime too",
|
||||
"/oc_model status for details",
|
||||
].join("\n"),
|
||||
buttons: [
|
||||
[
|
||||
buildButton({
|
||||
action: "providers",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Browse providers",
|
||||
style: "primary",
|
||||
}),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMattermostProviderPickerView(params: {
|
||||
ownerUserId: string;
|
||||
data: ModelsProviderData;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
const currentProvider = splitModelRef(params.currentModel)?.provider;
|
||||
const rows = params.data.providers.map((provider) => [
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`,
|
||||
provider,
|
||||
page: 1,
|
||||
style: provider === currentProvider ? "primary" : "default",
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"),
|
||||
buttons: rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMattermostModelsPickerView(params: {
|
||||
ownerUserId: string;
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
page?: number;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const models = getProviderModels(params.data, provider);
|
||||
const current = splitModelRef(params.currentModel);
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join(
|
||||
"\n",
|
||||
),
|
||||
buttons: [
|
||||
[
|
||||
buildButton({
|
||||
action: "back",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Back to providers",
|
||||
}),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const page = paginateItems(models, params.page);
|
||||
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
|
||||
const isCurrent = current?.provider === provider && current?.model === model;
|
||||
return [
|
||||
buildButton({
|
||||
action: "select",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: isCurrent ? `${model} [current]` : model,
|
||||
provider,
|
||||
model,
|
||||
page: page.page,
|
||||
style: isCurrent ? "primary" : "default",
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
const navRow: MattermostInteractiveButtonInput[] = [];
|
||||
if (page.hasPrev) {
|
||||
navRow.push(
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Prev",
|
||||
provider,
|
||||
page: page.page - 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (page.hasNext) {
|
||||
navRow.push(
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Next",
|
||||
provider,
|
||||
page: page.page + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (navRow.length > 0) {
|
||||
rows.push(navRow);
|
||||
}
|
||||
|
||||
rows.push([
|
||||
buildButton({
|
||||
action: "back",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Back to providers",
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
text: [
|
||||
`Models (${provider}) - ${page.totalItems} available`,
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Page ${page.page}/${page.totalPages}`,
|
||||
"Select a model to switch immediately.",
|
||||
].join("\n"),
|
||||
buttons: rows,
|
||||
};
|
||||
}
|
||||
180
extensions/mattermost/src/mattermost/monitor-auth.test.ts
Normal file
180
extensions/mattermost/src/mattermost/monitor-auth.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Mattermost tests cover monitor auth plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
|
||||
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
|
||||
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
|
||||
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
|
||||
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
authorizeMattermostCommandInvocation,
|
||||
formatMattermostDirectMessageDropLog,
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
} = await import("./monitor-auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
isDangerousNameMatchingEnabled.mockReset();
|
||||
resolveAllowlistMatchSimple.mockReset();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries", () => {
|
||||
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
|
||||
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
|
||||
expect(normalizeMattermostAllowEntry("accessGroup:Ops")).toBe("accessGroup:Ops");
|
||||
expect(normalizeMattermostAllowEntry("*")).toBe("*");
|
||||
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
|
||||
"alice",
|
||||
"*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks sender allowlists against normalized ids and names", () => {
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
|
||||
expect(
|
||||
isMattermostSenderAllowed({
|
||||
senderId: "@Alice",
|
||||
senderName: "Alice",
|
||||
allowFrom: [" mattermost:alice "],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(resolveAllowlistMatchSimple).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
senderId: "alice",
|
||||
senderName: "alice",
|
||||
allowNameMatching: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats direct-message drops with the ingress reason and open-policy hint", () => {
|
||||
expect(
|
||||
formatMattermostDirectMessageDropLog({
|
||||
senderId: "alice-id",
|
||||
dmPolicy: "open",
|
||||
reasonCode: "dm_policy_not_allowlisted",
|
||||
}),
|
||||
).toBe(
|
||||
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves direct command authorization from shared ingress", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
await expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
|
||||
kind: "direct",
|
||||
chatType: "direct",
|
||||
channelName: "alice",
|
||||
channelDisplay: "Alice",
|
||||
roomLabel: "#alice",
|
||||
});
|
||||
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
|
||||
|
||||
await expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
|
||||
kind: "direct",
|
||||
chatType: "direct",
|
||||
channelName: "alice",
|
||||
channelDisplay: "Alice",
|
||||
roomLabel: "#alice",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "disabled" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
denyReason: "dm-disabled",
|
||||
commandAuthorized: false,
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
|
||||
kind: "direct",
|
||||
chatType: "direct",
|
||||
channelName: "alice",
|
||||
channelDisplay: "Alice",
|
||||
roomLabel: "#alice",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { groupPolicy: "allowlist" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "chan-1",
|
||||
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" } as never,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
denyReason: "channel-no-allowlist",
|
||||
commandAuthorized: false,
|
||||
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" },
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "town-square",
|
||||
channelDisplay: "Town Square",
|
||||
roomLabel: "#town-square",
|
||||
});
|
||||
});
|
||||
});
|
||||
335
extensions/mattermost/src/mattermost/monitor-auth.ts
Normal file
335
extensions/mattermost/src/mattermost/monitor-auth.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// Mattermost plugin module implements monitor auth behavior.
|
||||
import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups";
|
||||
import {
|
||||
type ChannelIngressDecision,
|
||||
type ChannelIngressEventInput,
|
||||
type ChannelIngressIdentifierKind,
|
||||
resolveStableChannelMessageIngress,
|
||||
type StableChannelIngressIdentityParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostChannel } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple } from "./runtime-api.js";
|
||||
|
||||
const MATTERMOST_USER_NAME_KIND =
|
||||
"plugin:mattermost-user-name" as const satisfies ChannelIngressIdentifierKind;
|
||||
const mattermostIngressIdentity = {
|
||||
key: "sender-id",
|
||||
normalize: normalizeMattermostAllowEntry,
|
||||
aliases: [
|
||||
{
|
||||
key: "sender-name",
|
||||
kind: MATTERMOST_USER_NAME_KIND,
|
||||
normalizeEntry: normalizeMattermostAllowEntry,
|
||||
normalizeSubject: normalizeMattermostAllowEntry,
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeMattermostAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
`mattermost-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "user"}`,
|
||||
} satisfies StableChannelIngressIdentityParams;
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
const accessGroupName = parseAccessGroupAllowFromEntry(trimmed);
|
||||
if (accessGroupName) {
|
||||
return `accessGroup:${accessGroupName}`;
|
||||
}
|
||||
const normalized = trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim();
|
||||
return normalized ? normalizeLowercaseStringOrEmpty(normalized) : "";
|
||||
}
|
||||
|
||||
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
|
||||
const normalized = entries
|
||||
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
return uniqueStrings(normalized);
|
||||
}
|
||||
|
||||
export function formatMattermostDirectMessageDropLog(params: {
|
||||
senderId: string;
|
||||
dmPolicy: string;
|
||||
reasonCode?: string;
|
||||
}): string {
|
||||
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
|
||||
const hint =
|
||||
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
|
||||
? " hint=add-allowFrom-wildcard"
|
||||
: "";
|
||||
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const allowFrom = normalizeMattermostAllowList(params.allowFrom);
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const match = resolveAllowlistMatchSimple({
|
||||
allowFrom,
|
||||
senderId: normalizeMattermostAllowEntry(params.senderId),
|
||||
senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
return match.allowed;
|
||||
}
|
||||
|
||||
function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" {
|
||||
const normalized = channelType?.trim().toUpperCase();
|
||||
if (normalized === "D") {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "G" || normalized === "P") {
|
||||
return "group";
|
||||
}
|
||||
return "channel";
|
||||
}
|
||||
|
||||
export type MattermostCommandAuthDecision =
|
||||
| {
|
||||
ok: true;
|
||||
commandAuthorized: boolean;
|
||||
channelInfo: MattermostChannel;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
denyReason:
|
||||
| "unknown-channel"
|
||||
| "dm-disabled"
|
||||
| "dm-pairing"
|
||||
| "unauthorized"
|
||||
| "channels-disabled"
|
||||
| "channel-no-allowlist";
|
||||
commandAuthorized: false;
|
||||
channelInfo: MattermostChannel | null;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
type MattermostCommandDenyReason = Extract<
|
||||
MattermostCommandAuthDecision,
|
||||
{ ok: false }
|
||||
>["denyReason"];
|
||||
|
||||
export async function resolveMattermostMonitorInboundAccess(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
channelId: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
groupPolicy: "allowlist" | "open" | "disabled";
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
readStoreAllowFrom?: () => Promise<Array<string | number>>;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
eventKind?: ChannelIngressEventInput["kind"];
|
||||
mayPair?: boolean;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
storeAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
} = params;
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const configAllowFrom = account.config.allowFrom ?? [];
|
||||
const configGroupAllowFrom = account.config.groupAllowFrom ?? [];
|
||||
const readStoreAllowFrom =
|
||||
params.readStoreAllowFrom ??
|
||||
(storeAllowFrom != null ? async () => [...storeAllowFrom] : undefined);
|
||||
const ingress = await resolveStableChannelMessageIngress({
|
||||
channelId: "mattermost",
|
||||
accountId: account.accountId,
|
||||
identity: mattermostIngressIdentity,
|
||||
cfg,
|
||||
...(readStoreAllowFrom ? { readStoreAllowFrom } : {}),
|
||||
useDefaultPairingStore: params.readStoreAllowFrom === undefined && storeAllowFrom == null,
|
||||
subject: {
|
||||
stableId: senderId,
|
||||
aliases: { "sender-name": senderName },
|
||||
},
|
||||
conversation: {
|
||||
kind,
|
||||
id: channelId,
|
||||
},
|
||||
event: {
|
||||
kind: params.eventKind ?? "message",
|
||||
authMode: "inbound",
|
||||
mayPair: params.mayPair ?? true,
|
||||
},
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
},
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
command: {
|
||||
allowTextCommands,
|
||||
hasControlCommand: allowTextCommands && hasControlCommand,
|
||||
directGroupAllowFrom: kind === "direct" ? "effective" : "none",
|
||||
},
|
||||
});
|
||||
return ingress;
|
||||
}
|
||||
|
||||
function resolveMattermostCommandDenyReason(params: {
|
||||
decision: ChannelIngressDecision;
|
||||
kind: "direct" | "group" | "channel";
|
||||
dmPolicy: string;
|
||||
}): MattermostCommandDenyReason | null {
|
||||
if (params.decision.decision === "allow") {
|
||||
return null;
|
||||
}
|
||||
if (params.kind === "direct") {
|
||||
if (params.decision.reasonCode === "dm_policy_disabled") {
|
||||
return "dm-disabled";
|
||||
}
|
||||
if (
|
||||
params.dmPolicy === "pairing" &&
|
||||
(params.decision.admission === "pairing-required" ||
|
||||
params.decision.reasonCode === "dm_policy_pairing_required")
|
||||
) {
|
||||
return "dm-pairing";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
if (params.decision.reasonCode === "group_policy_disabled") {
|
||||
return "channels-disabled";
|
||||
}
|
||||
if (params.decision.reasonCode === "group_policy_empty_allowlist") {
|
||||
return "channel-no-allowlist";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
|
||||
export async function authorizeMattermostCommandInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
channelId: string;
|
||||
channelInfo: MattermostChannel | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
readStoreAllowFrom?: () => Promise<Array<string | number>>;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
}): Promise<MattermostCommandAuthDecision> {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
readStoreAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
} = params;
|
||||
|
||||
if (!channelInfo?.type) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unknown-channel",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: `#${channelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const kind = mapMattermostChannelKind(channelInfo.type);
|
||||
const chatType = kind;
|
||||
const channelName = channelInfo.name ?? "";
|
||||
const channelDisplay = channelInfo.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
|
||||
const ingress = await resolveMattermostMonitorInboundAccess({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
storeAllowFrom,
|
||||
readStoreAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
eventKind: "native-command",
|
||||
mayPair: true,
|
||||
});
|
||||
const denyReason = resolveMattermostCommandDenyReason({
|
||||
decision: ingress.ingress,
|
||||
kind,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
});
|
||||
|
||||
if (denyReason) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason,
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized: ingress.commandAccess.authorized,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
185
extensions/mattermost/src/mattermost/monitor-gating.test.ts
Normal file
185
extensions/mattermost/src/mattermost/monitor-gating.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// Mattermost tests cover monitor gating plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
evaluateMattermostMentionGate,
|
||||
mapMattermostChannelTypeToChatType,
|
||||
resolveMattermostTrustedChatKind,
|
||||
} from "./monitor-gating.js";
|
||||
|
||||
describe("mattermost monitor gating", () => {
|
||||
it("maps mattermost channel types to chat types", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("G")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType(null)).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("")).toBe("direct");
|
||||
});
|
||||
|
||||
it("derives chat kind from trusted channel lookup before fallback state", () => {
|
||||
expect(
|
||||
resolveMattermostTrustedChatKind({
|
||||
channelType: "O",
|
||||
fallback: "direct",
|
||||
}),
|
||||
).toBe("channel");
|
||||
expect(
|
||||
resolveMattermostTrustedChatKind({
|
||||
channelType: "D",
|
||||
fallback: "channel",
|
||||
}),
|
||||
).toBe("direct");
|
||||
expect(resolveMattermostTrustedChatKind({ fallback: "group" })).toBe("group");
|
||||
expect(resolveMattermostTrustedChatKind({})).toBe("direct");
|
||||
});
|
||||
|
||||
it("drops non-mentioned traffic when onchar is enabled but not triggered", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: true,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
dropReason: "onchar-not-triggered",
|
||||
});
|
||||
});
|
||||
|
||||
it("processes engaged thread follow-ups without a mention", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
threadAlreadyEngaged: true,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: true,
|
||||
dropReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("engaged threads respond even when onchar is enabled but not triggered", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
threadAlreadyEngaged: true,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: true,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: true,
|
||||
dropReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-mentioned channel traffic outside an engaged thread", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
threadAlreadyEngaged: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
dropReason: "missing-mention",
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses mention for authorized control commands and allows direct chats", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: true,
|
||||
commandAuthorized: true,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
dropReason: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "direct",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: false,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
dropReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
118
extensions/mattermost/src/mattermost/monitor-gating.ts
Normal file
118
extensions/mattermost/src/mattermost/monitor-gating.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Mattermost plugin module implements monitor gating behavior.
|
||||
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
|
||||
const normalized = channelType?.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "D") {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "G" || normalized === "P") {
|
||||
return "group";
|
||||
}
|
||||
return "channel";
|
||||
}
|
||||
|
||||
export function resolveMattermostTrustedChatKind(params: {
|
||||
channelType?: string | null;
|
||||
fallback?: ChatType;
|
||||
}): ChatType {
|
||||
const channelType = params.channelType?.trim();
|
||||
if (channelType) {
|
||||
return mapMattermostChannelTypeToChatType(channelType);
|
||||
}
|
||||
return params.fallback ?? "direct";
|
||||
}
|
||||
|
||||
export type MattermostRequireMentionResolverInput = {
|
||||
cfg: OpenClawConfig;
|
||||
channel: "mattermost";
|
||||
accountId: string;
|
||||
groupId: string;
|
||||
requireMentionOverride?: boolean;
|
||||
};
|
||||
|
||||
export type MattermostMentionGateInput = {
|
||||
kind: ChatType;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadRootId?: string;
|
||||
requireMentionOverride?: boolean;
|
||||
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
|
||||
wasMentioned: boolean;
|
||||
// Bot has already replied in this thread; treat follow-ups as addressed so the
|
||||
// user need not re-mention on every turn (parity with Slack thread participation).
|
||||
threadAlreadyEngaged?: boolean;
|
||||
isControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
oncharEnabled: boolean;
|
||||
oncharTriggered: boolean;
|
||||
canDetectMention: boolean;
|
||||
};
|
||||
|
||||
type MattermostMentionGateDecision = {
|
||||
shouldRequireMention: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
dropReason: "onchar-not-triggered" | "missing-mention" | null;
|
||||
};
|
||||
|
||||
export function evaluateMattermostMentionGate(
|
||||
params: MattermostMentionGateInput,
|
||||
): MattermostMentionGateDecision {
|
||||
const shouldRequireMention =
|
||||
params.kind !== "direct" &&
|
||||
params.resolveRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "mattermost",
|
||||
accountId: params.accountId,
|
||||
groupId: params.channelId,
|
||||
requireMentionOverride: params.requireMentionOverride,
|
||||
});
|
||||
const shouldBypassMention =
|
||||
params.isControlCommand &&
|
||||
shouldRequireMention &&
|
||||
!params.wasMentioned &&
|
||||
params.commandAuthorized;
|
||||
const effectiveWasMentioned =
|
||||
params.wasMentioned ||
|
||||
shouldBypassMention ||
|
||||
params.oncharTriggered ||
|
||||
params.threadAlreadyEngaged === true;
|
||||
if (
|
||||
params.oncharEnabled &&
|
||||
!params.oncharTriggered &&
|
||||
!params.wasMentioned &&
|
||||
!params.isControlCommand &&
|
||||
params.threadAlreadyEngaged !== true
|
||||
) {
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: "onchar-not-triggered",
|
||||
};
|
||||
}
|
||||
if (
|
||||
params.kind !== "direct" &&
|
||||
shouldRequireMention &&
|
||||
params.canDetectMention &&
|
||||
!effectiveWasMentioned
|
||||
) {
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: "missing-mention",
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: null,
|
||||
};
|
||||
}
|
||||
186
extensions/mattermost/src/mattermost/monitor-helpers.test.ts
Normal file
186
extensions/mattermost/src/mattermost/monitor-helpers.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// Mattermost tests cover monitor helpers plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMention, shouldDropEmptyMattermostBody } from "./monitor-helpers.js";
|
||||
|
||||
describe("normalizeMention", () => {
|
||||
it("returns trimmed text when no mention provided", () => {
|
||||
expect(normalizeMention(" hello world ", undefined)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips bot mention from text", () => {
|
||||
expect(normalizeMention("@echobot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("strips mention case-insensitively", () => {
|
||||
expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("preserves newlines in multi-line messages", () => {
|
||||
const input = "@echobot\nline1\nline2\nline3";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("preserves Markdown headings", () => {
|
||||
const input = "@echobot\n# Heading\n\nSome text";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("# Heading");
|
||||
expect(result).toContain("\n");
|
||||
});
|
||||
|
||||
it("preserves Markdown blockquotes", () => {
|
||||
const input = "@echobot\n> quoted line\n> second line";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("> quoted line");
|
||||
expect(result).toContain("> second line");
|
||||
});
|
||||
|
||||
it("preserves Markdown lists", () => {
|
||||
const input = "@echobot\n- item A\n- item B\n - sub B1";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- item A");
|
||||
expect(result).toContain("- item B");
|
||||
});
|
||||
|
||||
it("preserves task lists", () => {
|
||||
const input = "@echobot\n- [ ] todo\n- [x] done";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- [ ] todo");
|
||||
expect(result).toContain("- [x] done");
|
||||
});
|
||||
|
||||
it("handles mention in middle of text", () => {
|
||||
const input = "hey @echobot check this\nout";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("hey check this\nout");
|
||||
});
|
||||
|
||||
it("preserves leading indentation for nested lists", () => {
|
||||
const input = "@echobot\n- item\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" - nested");
|
||||
expect(result).toContain(" - deep");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for nested list items", () => {
|
||||
const input = "@echobot\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" - nested\n - deep");
|
||||
});
|
||||
|
||||
it("preserves indented code blocks", () => {
|
||||
const input = "@echobot\ntext\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" code line 1");
|
||||
expect(result).toContain(" code line 2");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for indented code blocks", () => {
|
||||
const input = "@echobot\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" code line 1\n code line 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropEmptyMattermostBody", () => {
|
||||
it("drops a non-mention message that normalizes to an empty body", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText: " ",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a message that still has body text", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "hello",
|
||||
rawText: "hello",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a bare mention in a group", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText: "@openclaw",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a bare mention in a direct message", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText: "@OpenClaw",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops an empty body when the bot username is unknown", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText: "@someoneelse",
|
||||
botUsername: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a blank post even when a generic mention pattern matched it", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText: "",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a bot mention with only a Unicode control residual", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "\u0085",
|
||||
rawText: "@openclaw\u0085",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a bot mention with only a combining-mark residual", () => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "\ufe0f",
|
||||
rawText: "@openclaw\ufe0f",
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"@openclaw @openclaw",
|
||||
"@openclaw\n@openclaw",
|
||||
"@openclaw\n",
|
||||
"\n@openclaw",
|
||||
"@openclaw\r\n",
|
||||
"@openclaw\u2028",
|
||||
"@openclaw\u2029",
|
||||
"\v@openclaw\f",
|
||||
"@openclaw\u00a0",
|
||||
"\u2003@openclaw",
|
||||
])("drops an invalid empty-body candidate: %j", (rawText) => {
|
||||
expect(
|
||||
shouldDropEmptyMattermostBody({
|
||||
bodyText: "",
|
||||
rawText,
|
||||
botUsername: "openclaw",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
69
extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
69
extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Mattermost helper module supports monitor helpers behavior.
|
||||
import { formatInboundFromLabel as formatInboundFromLabelShared } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveThreadSessionKeys as resolveThreadSessionKeysShared } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
|
||||
export { rawDataToString };
|
||||
|
||||
export const formatInboundFromLabel = formatInboundFromLabelShared;
|
||||
|
||||
export function resolveThreadSessionKeys(params: {
|
||||
baseSessionKey: string;
|
||||
threadId?: string | null;
|
||||
parentSessionKey?: string;
|
||||
useSuffix?: boolean;
|
||||
}): { sessionKey: string; parentSessionKey?: string } {
|
||||
return resolveThreadSessionKeysShared({
|
||||
...params,
|
||||
normalizeThreadId: (threadId) => threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip bot mention from message text while preserving newlines and
|
||||
* block-level Markdown formatting (headings, lists, blockquotes).
|
||||
*/
|
||||
export function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) {
|
||||
return text.trim();
|
||||
}
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const hasMentionRe = new RegExp(`@${escaped}\\b`, "i");
|
||||
const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i");
|
||||
const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i");
|
||||
const normalizedLines = text.split("\n").map((line) => {
|
||||
const hadMention = hasMentionRe.test(line);
|
||||
const normalizedLine = line
|
||||
.replace(leadingMentionRe, "$1")
|
||||
.replace(trailingMentionRe, "")
|
||||
.replace(new RegExp(`@${escaped}\\b`, "gi"), "")
|
||||
.replace(/(\S)[ \t]{2,}/g, "$1 ");
|
||||
return {
|
||||
text: normalizedLine,
|
||||
mentionOnlyBlank: hadMention && normalizedLine.trim() === "",
|
||||
};
|
||||
});
|
||||
|
||||
while (normalizedLines[0]?.mentionOnlyBlank) {
|
||||
normalizedLines.shift();
|
||||
}
|
||||
while (normalizedLines.at(-1)?.text.trim() === "") {
|
||||
normalizedLines.pop();
|
||||
}
|
||||
|
||||
return normalizedLines.map((line) => line.text).join("\n");
|
||||
}
|
||||
|
||||
export function shouldDropEmptyMattermostBody(params: {
|
||||
bodyText: string;
|
||||
rawText: string;
|
||||
botUsername?: string | null;
|
||||
}): boolean {
|
||||
if (/[^\p{White_Space}\p{Cc}\p{Cf}\p{M}]/u.test(params.bodyText)) {
|
||||
return false;
|
||||
}
|
||||
const botUsername = normalizeLowercaseStringOrEmpty(params.botUsername ?? "");
|
||||
const bareMention = params.rawText.match(/^[ \t]*(@\S+)[ \t]*$/u)?.[1];
|
||||
return !botUsername || normalizeLowercaseStringOrEmpty(bareMention ?? "") !== `@${botUsername}`;
|
||||
}
|
||||
33
extensions/mattermost/src/mattermost/monitor-onchar.test.ts
Normal file
33
extensions/mattermost/src/mattermost/monitor-onchar.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Mattermost tests cover monitor onchar plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
||||
|
||||
describe("mattermost monitor onchar", () => {
|
||||
it("uses defaults when prefixes are missing or empty after trimming", () => {
|
||||
expect(resolveOncharPrefixes(undefined)).toEqual([">", "!"]);
|
||||
expect(resolveOncharPrefixes([" ", ""])).toEqual([">", "!"]);
|
||||
});
|
||||
|
||||
it("trims configured prefixes and preserves order", () => {
|
||||
expect(resolveOncharPrefixes([" ?? ", " !", " /bot "])).toEqual(["??", "!", "/bot"]);
|
||||
});
|
||||
|
||||
it("strips the first matching prefix after leading whitespace", () => {
|
||||
expect(stripOncharPrefix(" ! hello world", ["!", ">"])).toEqual({
|
||||
triggered: true,
|
||||
stripped: "hello world",
|
||||
});
|
||||
|
||||
expect(stripOncharPrefix("??multi prefix", ["??", "?"])).toEqual({
|
||||
triggered: true,
|
||||
stripped: "multi prefix",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the original text when no prefix matches", () => {
|
||||
expect(stripOncharPrefix("hello world", ["!", ">"])).toEqual({
|
||||
triggered: false,
|
||||
stripped: "hello world",
|
||||
});
|
||||
});
|
||||
});
|
||||
28
extensions/mattermost/src/mattermost/monitor-onchar.ts
Normal file
28
extensions/mattermost/src/mattermost/monitor-onchar.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Mattermost plugin module implements monitor onchar behavior.
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes ? normalizeStringEntries(prefixes) : DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
export function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
235
extensions/mattermost/src/mattermost/monitor-resources.test.ts
Normal file
235
extensions/mattermost/src/mattermost/monitor-resources.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// Mattermost tests cover monitor resources plugin behavior.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUser = vi.hoisted(() => vi.fn());
|
||||
const sendMattermostTyping = vi.hoisted(() => vi.fn());
|
||||
const updateMattermostPost = vi.hoisted(() => vi.fn());
|
||||
const buildButtonProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostUser,
|
||||
sendMattermostTyping,
|
||||
updateMattermostPost,
|
||||
}));
|
||||
|
||||
vi.mock("./interactions.js", () => ({
|
||||
buildButtonProps,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor resources", () => {
|
||||
let createMattermostMonitorResources: typeof import("./monitor-resources.js").createMattermostMonitorResources;
|
||||
let formatMattermostInboundMediaText: typeof import("./monitor-resources.js").formatMattermostInboundMediaText;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createMattermostMonitorResources, formatMattermostInboundMediaText } =
|
||||
await import("./monitor-resources.js"));
|
||||
});
|
||||
|
||||
it("keeps media-only download failures visible to the agent", () => {
|
||||
expect(
|
||||
formatMattermostInboundMediaText({
|
||||
body: "",
|
||||
mediaPlaceholder: "",
|
||||
expectedCount: 1,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
).toBe("[mattermost attachment unavailable]");
|
||||
});
|
||||
|
||||
it("preserves successful media placeholders on partial failures", () => {
|
||||
expect(
|
||||
formatMattermostInboundMediaText({
|
||||
body: "<media:document> (2 files)",
|
||||
mediaPlaceholder: "<media:document> (2 files)",
|
||||
expectedCount: 2,
|
||||
mediaCount: 1,
|
||||
}),
|
||||
).toBe("<media:document> (2 files)\n\n[mattermost attachment unavailable]");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMattermostChannel.mockReset();
|
||||
fetchMattermostUser.mockReset();
|
||||
sendMattermostTyping.mockReset();
|
||||
updateMattermostPost.mockReset();
|
||||
buildButtonProps.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downloads media, preserves auth headers, and infers media kind", async () => {
|
||||
const saveRemoteMedia = vi.fn(async () => ({
|
||||
path: "/tmp/file.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
baseUrl: "https://chat.example.com",
|
||||
token: "bot-token",
|
||||
} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia,
|
||||
mediaKindFromMime: () => "image",
|
||||
});
|
||||
|
||||
await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([
|
||||
{
|
||||
path: "/tmp/file.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(saveRemoteMedia).toHaveBeenCalledWith({
|
||||
url: "https://chat.example.com/api/v4/files/file-1",
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "Bearer bot-token",
|
||||
},
|
||||
},
|
||||
filePathHint: "file-1",
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowedHostnames: ["chat.example.com"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("caches channel and user lookups and falls back to empty picker props", async () => {
|
||||
fetchMattermostChannel.mockResolvedValue({ id: "chan-1", name: "town-square" });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "user-1", username: "alice" });
|
||||
buildButtonProps.mockReturnValue(undefined);
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "town-square",
|
||||
});
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "town-square",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
});
|
||||
|
||||
expect(fetchMattermostChannel).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
await resources.updateModelPickerPost({
|
||||
channelId: "chan-1",
|
||||
postId: "post-1",
|
||||
message: "Pick a model",
|
||||
});
|
||||
|
||||
expect(updateMattermostPost).toHaveBeenCalledWith({}, "post-1", {
|
||||
message: "Pick a model",
|
||||
props: { attachments: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse cached lookups while the process clock is invalid", async () => {
|
||||
fetchMattermostChannel
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "old" })
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "fresh" })
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "recovered" });
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "old",
|
||||
});
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "fresh",
|
||||
});
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "recovered",
|
||||
});
|
||||
|
||||
expect(fetchMattermostChannel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not cache lookups when cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
fetchMattermostUser
|
||||
.mockResolvedValueOnce({ id: "user-1", username: "first" })
|
||||
.mockResolvedValueOnce({ id: "user-1", username: "second" });
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "first",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "second",
|
||||
});
|
||||
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("proxies typing indicators to the mattermost client helper", async () => {
|
||||
const client = {} as never;
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await resources.sendTypingIndicator("chan-1", "root-1");
|
||||
expect(sendMattermostTyping).toHaveBeenCalledWith(client, {
|
||||
channelId: "chan-1",
|
||||
parentId: "root-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
211
extensions/mattermost/src/mattermost/monitor-resources.ts
Normal file
211
extensions/mattermost/src/mattermost/monitor-resources.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Mattermost plugin module implements monitor resources behavior.
|
||||
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostUser,
|
||||
sendMattermostTyping,
|
||||
updateMattermostPost,
|
||||
type MattermostChannel,
|
||||
type MattermostClient,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import { buildButtonProps, type MattermostInteractionResponse } from "./interactions.js";
|
||||
|
||||
export type MattermostMediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
export type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
kind: MattermostMediaKind;
|
||||
};
|
||||
|
||||
export function formatMattermostInboundMediaText(params: {
|
||||
body: string;
|
||||
mediaPlaceholder: string;
|
||||
expectedCount: number;
|
||||
mediaCount: number;
|
||||
}): string {
|
||||
const unavailableCount = Math.max(0, params.expectedCount - params.mediaCount);
|
||||
if (unavailableCount === 0) {
|
||||
return params.body;
|
||||
}
|
||||
return formatInboundMediaUnavailableText({
|
||||
body: params.body,
|
||||
mediaPlaceholder: params.mediaCount === 0 ? params.mediaPlaceholder : undefined,
|
||||
notice: `[mattermost ${unavailableCount > 1 ? `${unavailableCount} attachments` : "attachment"} unavailable]`,
|
||||
});
|
||||
}
|
||||
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
type SaveRemoteMedia = (params: {
|
||||
url: string;
|
||||
requestInit?: RequestInit;
|
||||
filePathHint?: string;
|
||||
maxBytes: number;
|
||||
ssrfPolicy?: { allowedHostnames?: string[] };
|
||||
}) => Promise<{ path: string; contentType?: string | null }>;
|
||||
|
||||
export function createMattermostMonitorResources(params: {
|
||||
accountId: string;
|
||||
callbackUrl: string;
|
||||
client: MattermostClient;
|
||||
logger: { debug?: (...args: unknown[]) => void };
|
||||
mediaMaxBytes: number;
|
||||
saveRemoteMedia: SaveRemoteMedia;
|
||||
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
|
||||
}) {
|
||||
const {
|
||||
accountId,
|
||||
callbackUrl,
|
||||
client,
|
||||
logger,
|
||||
mediaMaxBytes,
|
||||
saveRemoteMedia,
|
||||
mediaKindFromMime,
|
||||
} = params;
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
|
||||
const getCachedValue = <T>(
|
||||
cache: Map<string, { value: T | null; expiresAt: number }>,
|
||||
key: string,
|
||||
nowMs: number | undefined,
|
||||
): T | null | undefined => {
|
||||
const cached = cache.get(key);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
if (nowMs !== undefined && cached.expiresAt > nowMs) {
|
||||
return cached.value;
|
||||
}
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const setCachedValue = <T>(
|
||||
cache: Map<string, { value: T | null; expiresAt: number }>,
|
||||
key: string,
|
||||
value: T | null,
|
||||
ttlMs: number,
|
||||
rawNowMs: number,
|
||||
): void => {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNowMs });
|
||||
if (expiresAt !== undefined) {
|
||||
cache.set(key, { value, expiresAt });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
const ids = normalizeStringEntries(fileIds ?? []);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: MattermostMediaInfo[] = [];
|
||||
for (const fileId of ids) {
|
||||
try {
|
||||
const saved = await saveRemoteMedia({
|
||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
},
|
||||
filePathHint: fileId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
|
||||
});
|
||||
const contentType = saved.contentType ?? undefined;
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType,
|
||||
kind: mediaKindFromMime(contentType) ?? "unknown",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
||||
await sendMattermostTyping(client, { channelId, parentId });
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(channelCache, channelId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
setCachedValue(channelCache, channelId, info, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
setCachedValue(channelCache, channelId, null, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(userCache, userId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
setCachedValue(userCache, userId, info, USER_CACHE_TTL_MS, rawNow);
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
setCachedValue(userCache, userId, null, USER_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildModelPickerProps = (
|
||||
channelId: string,
|
||||
buttons: Array<unknown>,
|
||||
): Record<string, unknown> | undefined =>
|
||||
buildButtonProps({
|
||||
callbackUrl,
|
||||
accountId,
|
||||
channelId,
|
||||
buttons,
|
||||
});
|
||||
|
||||
const updateModelPickerPost = async (paramsLocal: {
|
||||
channelId: string;
|
||||
postId: string;
|
||||
message: string;
|
||||
buttons?: Array<unknown>;
|
||||
}): Promise<MattermostInteractionResponse> => {
|
||||
const props = buildModelPickerProps(paramsLocal.channelId, paramsLocal.buttons ?? []) ?? {
|
||||
attachments: [],
|
||||
};
|
||||
await updateMattermostPost(client, paramsLocal.postId, {
|
||||
message: paramsLocal.message,
|
||||
props,
|
||||
});
|
||||
return {};
|
||||
};
|
||||
|
||||
return {
|
||||
resolveMattermostMedia,
|
||||
sendTypingIndicator,
|
||||
resolveChannelInfo,
|
||||
resolveUserInfo,
|
||||
updateModelPickerPost,
|
||||
};
|
||||
}
|
||||
227
extensions/mattermost/src/mattermost/monitor-slash.test.ts
Normal file
227
extensions/mattermost/src/mattermost/monitor-slash.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
// Mattermost tests cover monitor slash plugin behavior.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listSkillCommandsForAgents = vi.hoisted(() => vi.fn());
|
||||
const parseTcpPort = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUserTeams = vi.hoisted(() => vi.fn());
|
||||
const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value));
|
||||
const isSlashCommandsEnabled = vi.hoisted(() => vi.fn());
|
||||
const registerSlashCommands = vi.hoisted(() => vi.fn());
|
||||
const resolveCallbackUrl = vi.hoisted(() => vi.fn());
|
||||
const resolveSlashCommandConfig = vi.hoisted(() => vi.fn());
|
||||
const activateSlashCommands = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
listSkillCommandsForAgents,
|
||||
parseTcpPort,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||
return {
|
||||
...actual,
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./slash-commands.js", () => ({
|
||||
DEFAULT_COMMAND_SPECS: [
|
||||
{ trigger: "ping", description: "ping" },
|
||||
{ trigger: "ping", description: "duplicate" },
|
||||
],
|
||||
isSlashCommandsEnabled,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveSlashCommandConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./slash-state.js", () => ({
|
||||
activateSlashCommands,
|
||||
}));
|
||||
|
||||
function requireFirstMockCall<TArgs extends unknown[]>(
|
||||
mock: { mock: { calls: TArgs[] } },
|
||||
label: string,
|
||||
): TArgs {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("mattermost monitor slash", () => {
|
||||
let registerMattermostMonitorSlashCommands: typeof import("./monitor-slash.js").registerMattermostMonitorSlashCommands;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
listSkillCommandsForAgents.mockReset();
|
||||
parseTcpPort.mockReset();
|
||||
fetchMattermostUserTeams.mockReset();
|
||||
normalizeMattermostBaseUrl.mockClear();
|
||||
isSlashCommandsEnabled.mockReset();
|
||||
registerSlashCommands.mockReset();
|
||||
resolveCallbackUrl.mockReset();
|
||||
resolveSlashCommandConfig.mockReset();
|
||||
activateSlashCommands.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns early when slash commands are disabled", async () => {
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: false });
|
||||
isSlashCommandsEnabled.mockReturnValue(false);
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
account: { config: {} } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(fetchMattermostUserTeams).not.toHaveBeenCalled();
|
||||
expect(activateSlashCommands).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers deduped default and native skill commands across teams", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18888");
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: true });
|
||||
isSlashCommandsEnabled.mockReturnValue(true);
|
||||
parseTcpPort.mockReturnValue(18888);
|
||||
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
|
||||
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
|
||||
listSkillCommandsForAgents.mockReturnValue([
|
||||
{ name: "skill", description: "Skill run" },
|
||||
{ name: "oc_ping", description: "Already prefixed" },
|
||||
{ name: " ", description: "ignored" },
|
||||
]);
|
||||
registerSlashCommands
|
||||
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
|
||||
.mockResolvedValueOnce([{ token: "token-2", trigger: "oc_skill" }]);
|
||||
const client = {} as never;
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client,
|
||||
cfg: { gateway: { port: 18789 } } as never,
|
||||
runtime: runtime as never,
|
||||
account: { config: { commands: {} }, accountId: "default" } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(registerSlashCommands).toHaveBeenCalledTimes(2);
|
||||
const [firstRegistration] = requireFirstMockCall(
|
||||
registerSlashCommands,
|
||||
"first Mattermost slash command registration",
|
||||
);
|
||||
expect(firstRegistration).toEqual({
|
||||
client,
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "https://openclaw.test/slash",
|
||||
commands: [
|
||||
{ trigger: "ping", description: "ping" },
|
||||
{
|
||||
trigger: "oc_skill",
|
||||
description: "Skill run",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: "skill",
|
||||
},
|
||||
{
|
||||
trigger: "oc_ping",
|
||||
description: "Already prefixed",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: "oc_ping",
|
||||
},
|
||||
],
|
||||
log: firstRegistration.log,
|
||||
});
|
||||
expect(typeof firstRegistration.log).toBe("function");
|
||||
const [activation] = requireFirstMockCall(
|
||||
activateSlashCommands,
|
||||
"Mattermost slash command activation",
|
||||
);
|
||||
expect(activation?.commandTokens).toStrictEqual(["token-1", "token-2"]);
|
||||
expect(activation?.triggerMap).toStrictEqual(
|
||||
new Map([
|
||||
["oc_skill", "skill"],
|
||||
["oc_ping", "oc_ping"],
|
||||
]),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: slash commands registered (2 commands across 2 teams, callback=https://openclaw.test/slash)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the configured gateway port when the env port is out of range", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "65536");
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
|
||||
isSlashCommandsEnabled.mockReturnValue(true);
|
||||
parseTcpPort.mockReturnValue(null);
|
||||
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]);
|
||||
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
|
||||
registerSlashCommands.mockResolvedValue([{ token: "token-1", trigger: "ping" }]);
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: { gateway: { port: 18789 } } as never,
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
account: { config: { commands: {} }, accountId: "default" } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(parseTcpPort).toHaveBeenCalledWith("65536");
|
||||
expect(resolveCallbackUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gatewayPort: 18789 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns on loopback callback urls and reports partial team failures", async () => {
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
|
||||
isSlashCommandsEnabled.mockReturnValue(true);
|
||||
parseTcpPort.mockReturnValue(null);
|
||||
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
|
||||
resolveCallbackUrl.mockReturnValue("http://127.0.0.1:18789/slash");
|
||||
registerSlashCommands
|
||||
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: { gateway: { customBindHost: "loopback" } } as never,
|
||||
runtime: runtime as never,
|
||||
account: { config: { commands: {} }, accountId: "default" } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: slash commands callbackUrl resolved to http://127.0.0.1:18789/slash (loopback) while baseUrl is https://chat.example.com. 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).",
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: failed to register slash commands for team team-2: Error: boom",
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: slash command registration completed with 1 team error(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
211
extensions/mattermost/src/mattermost/monitor-slash.ts
Normal file
211
extensions/mattermost/src/mattermost/monitor-slash.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Mattermost plugin module implements monitor slash behavior.
|
||||
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
type MattermostClient,
|
||||
} from "./client.js";
|
||||
import {
|
||||
listSkillCommandsForAgents,
|
||||
parseTcpPort,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
isSlashCommandsEnabled,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveSlashCommandConfig,
|
||||
type MattermostCommandSpec,
|
||||
type MattermostRegisteredCommand,
|
||||
type MattermostSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
import { activateSlashCommands } from "./slash-state.js";
|
||||
|
||||
function buildSlashCommands(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
nativeSkills: boolean;
|
||||
}): MattermostCommandSpec[] {
|
||||
const commandsToRegister: MattermostCommandSpec[] = [...DEFAULT_COMMAND_SPECS];
|
||||
if (!params.nativeSkills) {
|
||||
return commandsToRegister;
|
||||
}
|
||||
try {
|
||||
const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg });
|
||||
for (const spec of skillCommands) {
|
||||
const name = typeof spec.name === "string" ? spec.name.trim() : "";
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const trigger = name.startsWith("oc_") ? name : `oc_${name}`;
|
||||
commandsToRegister.push({
|
||||
trigger,
|
||||
description: spec.description || `Run skill ${name}`,
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: name,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
params.runtime.error?.(`mattermost: failed to list skill commands: ${String(err)}`);
|
||||
}
|
||||
return commandsToRegister;
|
||||
}
|
||||
|
||||
function dedupeSlashCommands(commands: MattermostCommandSpec[]): MattermostCommandSpec[] {
|
||||
const seen = new Set<string>();
|
||||
return commands.filter((cmd) => {
|
||||
const key = cmd.trigger.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildTriggerMap(commands: MattermostCommandSpec[]): Map<string, string> {
|
||||
const triggerMap = new Map<string, string>();
|
||||
for (const cmd of commands) {
|
||||
if (cmd.originalName) {
|
||||
triggerMap.set(cmd.trigger, cmd.originalName);
|
||||
}
|
||||
}
|
||||
return triggerMap;
|
||||
}
|
||||
|
||||
function warnOnSuspiciousCallbackUrl(params: {
|
||||
runtime: RuntimeEnv;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
}) {
|
||||
try {
|
||||
const mmHost = new URL(normalizeMattermostBaseUrl(params.baseUrl) ?? params.baseUrl).hostname;
|
||||
const callbackHost = new URL(params.callbackUrl).hostname;
|
||||
|
||||
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
||||
params.runtime.error?.(
|
||||
`mattermost: slash commands callbackUrl resolved to ${params.callbackUrl} (loopback) while baseUrl is ${params.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 {
|
||||
// Ignore malformed URLs and let the downstream registration fail naturally.
|
||||
}
|
||||
}
|
||||
|
||||
async function registerSlashCommandsAcrossTeams(params: {
|
||||
client: MattermostClient;
|
||||
teams: Array<{ id: string }>;
|
||||
botUserId: string;
|
||||
callbackUrl: string;
|
||||
commands: MattermostCommandSpec[];
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<{
|
||||
registered: MattermostRegisteredCommand[];
|
||||
teamRegistrationFailures: number;
|
||||
}> {
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
let teamRegistrationFailures = 0;
|
||||
|
||||
for (const team of params.teams) {
|
||||
try {
|
||||
const created = await registerSlashCommands({
|
||||
client: params.client,
|
||||
teamId: team.id,
|
||||
creatorUserId: params.botUserId,
|
||||
callbackUrl: params.callbackUrl,
|
||||
commands: params.commands,
|
||||
log: (msg) => params.runtime.log?.(msg),
|
||||
});
|
||||
registered.push(...created);
|
||||
} catch (err) {
|
||||
teamRegistrationFailures += 1;
|
||||
params.runtime.error?.(
|
||||
`mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { registered, teamRegistrationFailures };
|
||||
}
|
||||
|
||||
export async function registerMattermostMonitorSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
account: ResolvedMattermostAccount;
|
||||
baseUrl: string;
|
||||
botUserId: string;
|
||||
}) {
|
||||
const commandsRaw = params.account.config.commands as
|
||||
| Partial<MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
const slashConfig = resolveSlashCommandConfig(commandsRaw);
|
||||
if (!isSlashCommandsEnabled(slashConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const teams = await fetchMattermostUserTeams(params.client, params.botUserId);
|
||||
const envPort = parseTcpPort(process.env.OPENCLAW_GATEWAY_PORT);
|
||||
const slashGatewayPort = envPort ?? params.cfg.gateway?.port ?? 18789;
|
||||
const slashCallbackUrl = resolveCallbackUrl({
|
||||
config: slashConfig,
|
||||
gatewayPort: slashGatewayPort,
|
||||
gatewayHost: params.cfg.gateway?.customBindHost ?? undefined,
|
||||
});
|
||||
|
||||
warnOnSuspiciousCallbackUrl({
|
||||
runtime: params.runtime,
|
||||
baseUrl: params.baseUrl,
|
||||
callbackUrl: slashCallbackUrl,
|
||||
});
|
||||
|
||||
const dedupedCommands = dedupeSlashCommands(
|
||||
buildSlashCommands({
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
nativeSkills: slashConfig.nativeSkills === true,
|
||||
}),
|
||||
);
|
||||
const { registered, teamRegistrationFailures } = await registerSlashCommandsAcrossTeams({
|
||||
client: params.client,
|
||||
teams,
|
||||
botUserId: params.botUserId,
|
||||
callbackUrl: slashCallbackUrl,
|
||||
commands: dedupedCommands,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
if (registered.length === 0) {
|
||||
params.runtime.error?.(
|
||||
"mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamRegistrationFailures > 0) {
|
||||
params.runtime.error?.(
|
||||
`mattermost: slash command registration completed with ${teamRegistrationFailures} team error(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
activateSlashCommands({
|
||||
account: params.account,
|
||||
commandTokens: registered.map((cmd) => cmd.token).filter(Boolean),
|
||||
registeredCommands: registered,
|
||||
triggerMap: buildTriggerMap(dedupedCommands),
|
||||
api: { cfg: params.cfg, runtime: params.runtime },
|
||||
log: (msg) => params.runtime.log?.(msg),
|
||||
});
|
||||
|
||||
params.runtime.log?.(
|
||||
`mattermost: slash commands registered (${registered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
510
extensions/mattermost/src/mattermost/monitor-websocket.test.ts
Normal file
510
extensions/mattermost/src/mattermost/monitor-websocket.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
// Mattermost tests cover monitor websocket plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime-api.js";
|
||||
import {
|
||||
createMattermostConnectOnce,
|
||||
type MattermostWebSocketLike,
|
||||
WebSocketClosedBeforeOpenError,
|
||||
} from "./monitor-websocket.js";
|
||||
|
||||
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (predicate(item)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class FakeWebSocket implements MattermostWebSocketLike {
|
||||
public readonly sent: string[] = [];
|
||||
public pingCalls = 0;
|
||||
public closeCalls = 0;
|
||||
public terminateCalls = 0;
|
||||
private openListeners: Array<() => void> = [];
|
||||
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
|
||||
private pongListeners: Array<(data: Buffer) => void> = [];
|
||||
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
||||
private errorListeners: Array<(err: unknown) => void> = [];
|
||||
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
||||
on(event: "pong", listener: (data: Buffer) => void): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
on(event: "open" | "message" | "pong" | "close" | "error", listener: unknown): void {
|
||||
if (event === "open") {
|
||||
this.openListeners.push(listener as () => void);
|
||||
return;
|
||||
}
|
||||
if (event === "message") {
|
||||
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
|
||||
return;
|
||||
}
|
||||
if (event === "pong") {
|
||||
this.pongListeners.push(listener as (data: Buffer) => void);
|
||||
return;
|
||||
}
|
||||
if (event === "close") {
|
||||
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
|
||||
return;
|
||||
}
|
||||
this.errorListeners.push(listener as (err: unknown) => void);
|
||||
}
|
||||
|
||||
ping(): void {
|
||||
this.pingCalls++;
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCalls++;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminateCalls++;
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
for (const listener of this.openListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(data: Buffer): void {
|
||||
for (const listener of this.messageListeners) {
|
||||
void listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitPong(data = Buffer.alloc(0)): void {
|
||||
for (const listener of this.pongListeners) {
|
||||
listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitClose(code: number, reason = ""): void {
|
||||
const buffer = Buffer.from(reason, "utf8");
|
||||
for (const listener of this.closeListeners) {
|
||||
listener(code, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(err: unknown): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testRuntime = (): RuntimeEnv =>
|
||||
({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
}) as RuntimeEnv;
|
||||
|
||||
describe("mattermost websocket monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects when websocket closes before open", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
socket.emitClose(1006, "connection refused");
|
||||
});
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await connectOnce();
|
||||
} catch (caught) {
|
||||
failure = caught;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||
expect((failure as WebSocketClosedBeforeOpenError).message).toBe(
|
||||
"websocket closed before open (code 1006)",
|
||||
);
|
||||
expect((failure as WebSocketClosedBeforeOpenError).code).toBe(1006);
|
||||
expect((failure as WebSocketClosedBeforeOpenError).reason).toBe("connection refused");
|
||||
});
|
||||
|
||||
it("retries when first attempt errors before open and next attempt succeeds", async () => {
|
||||
const patches: Array<Record<string, unknown>> = [];
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: (() => {
|
||||
let seq = 1;
|
||||
return () => seq++;
|
||||
})(),
|
||||
onPosted: async () => {},
|
||||
statusSink: (patch) => {
|
||||
patches.push(patch as Record<string, unknown>);
|
||||
},
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const attempt = sockets.length;
|
||||
sockets.push(socket);
|
||||
queueMicrotask(() => {
|
||||
if (attempt === 0) {
|
||||
socket.emitError(new Error("boom"));
|
||||
socket.emitClose(1006, "connection refused");
|
||||
return;
|
||||
}
|
||||
socket.emitOpen();
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
|
||||
const firstAttempt = connectOnce();
|
||||
await expect(firstAttempt).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||
|
||||
await connectOnce();
|
||||
|
||||
expect(sockets).toHaveLength(2);
|
||||
expect(sockets[0].closeCalls).toBe(1);
|
||||
expect(sockets[1].sent).toHaveLength(1);
|
||||
expect(JSON.parse(sockets[1].sent[0] ?? "")).toEqual({
|
||||
action: "authentication_challenge",
|
||||
data: { token: "token" },
|
||||
seq: 1,
|
||||
});
|
||||
expect(countMatching(patches, (patch) => patch.connected === true)).toBe(1);
|
||||
expect(countMatching(patches, (patch) => patch.connected === false)).toBe(2);
|
||||
});
|
||||
|
||||
it("dispatches reaction events to the reaction handler", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const onPosted = vi.fn(async () => {});
|
||||
const onReaction = vi.fn(async (payload) => payload);
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted,
|
||||
onReaction,
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
queueMicrotask(() => {
|
||||
socket.emitOpen();
|
||||
socket.emitMessage(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
event: "reaction_added",
|
||||
data: {
|
||||
reaction: JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
|
||||
await connected;
|
||||
|
||||
expect(onReaction).toHaveBeenCalledTimes(1);
|
||||
expect(onPosted).not.toHaveBeenCalled();
|
||||
const reaction = JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
});
|
||||
const payload = onReaction.mock.calls.at(0)?.[0];
|
||||
expect(payload).toEqual({
|
||||
event: "reaction_added",
|
||||
data: { reaction },
|
||||
});
|
||||
});
|
||||
|
||||
it("terminates when bot update_at changes (disable/enable cycle)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
let updateAt = 1000;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => updateAt,
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
// Let initial getBotUpdateAt resolve
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// update_at unchanged — no terminate
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
// Simulate disable/enable — update_at changes
|
||||
updateAt = 2000;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
|
||||
);
|
||||
|
||||
socket.emitClose(1006);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps connection alive when update_at stays the same", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => 1000,
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("continues protocol keepalive when Mattermost responds with pong", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
pingIntervalMs: 100,
|
||||
pongTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.pingCalls).toBe(1);
|
||||
|
||||
socket.emitPong();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
expect(socket.pingCalls).toBe(2);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("terminates silent websocket drops when Mattermost misses pong timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
let pollCount = 0;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
pollCount++;
|
||||
return 1000;
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
pingIntervalMs: 50,
|
||||
pongTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(socket.pingCalls).toBe(1);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(socket.terminateCalls).toBe(1);
|
||||
expect(runtime.error).toHaveBeenCalledWith("mattermost websocket pong timeout — reconnecting");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(socket.pingCalls).toBe(1);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
socket.emitClose(1006);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not terminate when getBotUpdateAt throws", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
let shouldThrow = false;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
if (shouldThrow) {
|
||||
throw new Error("network error");
|
||||
}
|
||||
return 1000;
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// API error — should log but not terminate
|
||||
shouldThrow = true;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: health check error: Error: network error",
|
||||
);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps polling when the initial getBotUpdateAt call fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
const responses: Array<number | Error> = [new Error("network error"), 1000, 2000];
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
const next = responses.shift();
|
||||
if (next instanceof Error) {
|
||||
throw next;
|
||||
}
|
||||
return next ?? 2000;
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: failed to get initial update_at: Error: network error",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
|
||||
);
|
||||
|
||||
socket.emitClose(1006);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not overlap health checks when a prior poll is still running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const resolvers: Array<(value: number) => void> = [];
|
||||
let pollCount = 0;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
pollCount++;
|
||||
return await new Promise<number>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
resolvers[0]?.(1000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(pollCount).toBe(2);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
432
extensions/mattermost/src/mattermost/monitor-websocket.ts
Normal file
432
extensions/mattermost/src/mattermost/monitor-websocket.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// Mattermost plugin module implements monitor websocket behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
captureWsEvent,
|
||||
createDebugProxyWebSocketAgent,
|
||||
resolveDebugProxySettings,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import WebSocket from "ws";
|
||||
import { z } from "zod";
|
||||
import { MattermostPostSchema, type MattermostPost } from "./client.js";
|
||||
import { rawDataToString } from "./monitor-helpers.js";
|
||||
import type { ChannelAccountSnapshot, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
export type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string | MattermostPost;
|
||||
reaction?: string | Record<string, unknown>;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostWebSocketLike = {
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
|
||||
on(event: "pong", listener: (data: Buffer) => void): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
ping(): void;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
terminate(): void;
|
||||
};
|
||||
|
||||
export type MattermostWebSocketFactory = (url: string) => MattermostWebSocketLike;
|
||||
const MattermostEventPayloadSchema = z.object({
|
||||
event: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
post: z.union([z.string(), MattermostPostSchema]).optional(),
|
||||
reaction: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
|
||||
channel_id: z.string().optional(),
|
||||
channel_name: z.string().optional(),
|
||||
channel_display_name: z.string().optional(),
|
||||
channel_type: z.string().optional(),
|
||||
sender_name: z.string().optional(),
|
||||
team_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
broadcast: z
|
||||
.object({
|
||||
channel_id: z.string().optional(),
|
||||
team_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}) as z.ZodType<MattermostEventPayload>;
|
||||
|
||||
function parseMattermostEventPayload(raw: string): MattermostEventPayload | null {
|
||||
return safeParseJsonWithSchema(MattermostEventPayloadSchema, raw);
|
||||
}
|
||||
|
||||
function parseMattermostPost(value: unknown): MattermostPost | null {
|
||||
if (typeof value === "string") {
|
||||
return safeParseJsonWithSchema(MattermostPostSchema, value);
|
||||
}
|
||||
return safeParseWithSchema(MattermostPostSchema, value);
|
||||
}
|
||||
|
||||
export class WebSocketClosedBeforeOpenError extends Error {
|
||||
constructor(
|
||||
public readonly code: number,
|
||||
public readonly reason?: string,
|
||||
) {
|
||||
super(`websocket closed before open (code ${code})`);
|
||||
this.name = "WebSocketClosedBeforeOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
type CreateMattermostConnectOnceOpts = {
|
||||
wsUrl: string;
|
||||
botToken: string;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
runtime: RuntimeEnv;
|
||||
nextSeq: () => number;
|
||||
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
||||
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
|
||||
webSocketFactory?: MattermostWebSocketFactory;
|
||||
/**
|
||||
* Called periodically to check whether the bot account has been modified
|
||||
* (e.g. disabled then re-enabled) since the WebSocket was opened.
|
||||
* Returns the bot's current `update_at` timestamp. When it differs from
|
||||
* the value recorded at connect time, the connection is terminated so the
|
||||
* reconnect loop can establish a fresh one.
|
||||
*/
|
||||
getBotUpdateAt?: () => Promise<number>;
|
||||
healthCheckIntervalMs?: number;
|
||||
pingIntervalMs?: number;
|
||||
pongTimeoutMs?: number;
|
||||
};
|
||||
|
||||
const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) => {
|
||||
const agent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
|
||||
return new WebSocket(url, agent ? { agent } : undefined) as MattermostWebSocketLike;
|
||||
};
|
||||
|
||||
function parsePostedPayload(
|
||||
payload: MattermostEventPayload,
|
||||
): { payload: MattermostEventPayload; post: MattermostPost } | null {
|
||||
if (payload.event !== "posted") {
|
||||
return null;
|
||||
}
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) {
|
||||
return null;
|
||||
}
|
||||
const post = parseMattermostPost(postData);
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
return { payload, post };
|
||||
}
|
||||
|
||||
export function createMattermostConnectOnce(
|
||||
opts: CreateMattermostConnectOnceOpts,
|
||||
): () => Promise<void> {
|
||||
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
|
||||
const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000;
|
||||
const pingIntervalMs = opts.pingIntervalMs ?? 30_000;
|
||||
const pongTimeoutMs = opts.pongTimeoutMs ?? 10_000;
|
||||
return async () => {
|
||||
const flowId = randomUUID();
|
||||
const ws = webSocketFactory(opts.wsUrl);
|
||||
const onAbort = () => ws.terminate();
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
const getBotUpdateAt = opts.getBotUpdateAt;
|
||||
|
||||
try {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let opened = false;
|
||||
let settled = false;
|
||||
let healthCheckEnabled = getBotUpdateAt != null;
|
||||
let healthCheckInFlight = false;
|
||||
let healthCheckTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let protocolKeepaliveEnabled = true;
|
||||
let protocolPingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let protocolPongTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let initialUpdateAt: number | undefined;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (healthCheckTimer !== undefined) {
|
||||
clearTimeout(healthCheckTimer);
|
||||
healthCheckTimer = undefined;
|
||||
}
|
||||
if (protocolPingTimer !== undefined) {
|
||||
clearTimeout(protocolPingTimer);
|
||||
protocolPingTimer = undefined;
|
||||
}
|
||||
if (protocolPongTimer !== undefined) {
|
||||
clearTimeout(protocolPongTimer);
|
||||
protocolPongTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stopHealthChecks = () => {
|
||||
healthCheckEnabled = false;
|
||||
protocolKeepaliveEnabled = false;
|
||||
clearTimers();
|
||||
};
|
||||
|
||||
const sendProtocolPing = () => {
|
||||
if (!protocolKeepaliveEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
if (protocolPongTimer !== undefined) {
|
||||
clearTimeout(protocolPongTimer);
|
||||
}
|
||||
protocolPongTimer = setTimeout(() => {
|
||||
protocolPongTimer = undefined;
|
||||
if (!protocolKeepaliveEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
opts.runtime.error?.("mattermost websocket pong timeout — reconnecting");
|
||||
stopHealthChecks();
|
||||
ws.terminate();
|
||||
}, pongTimeoutMs);
|
||||
try {
|
||||
ws.ping();
|
||||
} catch (err) {
|
||||
if (!protocolKeepaliveEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
opts.runtime.error?.(`mattermost websocket ping failed: ${String(err)}`);
|
||||
stopHealthChecks();
|
||||
ws.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleProtocolPing = () => {
|
||||
if (!protocolKeepaliveEnabled || settled || protocolPingTimer !== undefined) {
|
||||
return;
|
||||
}
|
||||
protocolPingTimer = setTimeout(() => {
|
||||
protocolPingTimer = undefined;
|
||||
sendProtocolPing();
|
||||
}, pingIntervalMs);
|
||||
};
|
||||
|
||||
const scheduleHealthCheck = () => {
|
||||
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
|
||||
return;
|
||||
}
|
||||
healthCheckTimer = setTimeout(() => {
|
||||
healthCheckTimer = undefined;
|
||||
void runHealthCheck();
|
||||
}, healthCheckIntervalMs);
|
||||
};
|
||||
|
||||
const runHealthCheck = async () => {
|
||||
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
|
||||
return;
|
||||
}
|
||||
healthCheckInFlight = true;
|
||||
try {
|
||||
const current = await getBotUpdateAt();
|
||||
if (!healthCheckEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
if (initialUpdateAt === undefined) {
|
||||
initialUpdateAt = current;
|
||||
return;
|
||||
}
|
||||
if (current !== initialUpdateAt) {
|
||||
opts.runtime.log?.(
|
||||
`mattermost: bot account updated (update_at changed: ${initialUpdateAt} → ${current}) — reconnecting`,
|
||||
);
|
||||
stopHealthChecks();
|
||||
ws.terminate();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!healthCheckEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
const label =
|
||||
initialUpdateAt === undefined
|
||||
? "mattermost: failed to get initial update_at"
|
||||
: "mattermost: health check error";
|
||||
opts.runtime.error?.(`${label}: ${String(err)}`);
|
||||
} finally {
|
||||
healthCheckInFlight = false;
|
||||
scheduleHealthCheck();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
stopHealthChecks();
|
||||
resolve();
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
stopHealthChecks();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.on("open", () => {
|
||||
opened = true;
|
||||
captureWsEvent({
|
||||
url: opts.wsUrl,
|
||||
direction: "local",
|
||||
kind: "ws-open",
|
||||
flowId,
|
||||
meta: { subsystem: "mattermost-websocket" },
|
||||
});
|
||||
opts.statusSink?.({
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
const authPayload = JSON.stringify({
|
||||
seq: opts.nextSeq(),
|
||||
action: "authentication_challenge",
|
||||
data: { token: opts.botToken },
|
||||
});
|
||||
captureWsEvent({
|
||||
url: opts.wsUrl,
|
||||
direction: "outbound",
|
||||
kind: "ws-frame",
|
||||
flowId,
|
||||
payload: authPayload,
|
||||
meta: { subsystem: "mattermost-websocket", eventType: "authentication_challenge" },
|
||||
});
|
||||
ws.send(authPayload);
|
||||
scheduleProtocolPing();
|
||||
|
||||
// Periodically check if the bot account was modified (e.g. disable/enable).
|
||||
// After such a cycle the WebSocket silently stops delivering events even
|
||||
// though the connection itself stays alive. Comparing update_at detects
|
||||
// this reliably regardless of how quickly the cycle happens.
|
||||
if (getBotUpdateAt) {
|
||||
// Use a recursive timeout so only one REST poll can be in flight at a time.
|
||||
void runHealthCheck();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("pong", () => {
|
||||
if (protocolPongTimer !== undefined) {
|
||||
clearTimeout(protocolPongTimer);
|
||||
protocolPongTimer = undefined;
|
||||
}
|
||||
scheduleProtocolPing();
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
captureWsEvent({
|
||||
url: opts.wsUrl,
|
||||
direction: "inbound",
|
||||
kind: "ws-frame",
|
||||
flowId,
|
||||
payload: Buffer.from(rawDataToString(data)),
|
||||
meta: { subsystem: "mattermost-websocket" },
|
||||
});
|
||||
const raw = rawDataToString(data);
|
||||
const payload = parseMattermostEventPayload(raw);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.event === "reaction_added" || payload.event === "reaction_removed") {
|
||||
if (!opts.onReaction) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await opts.onReaction(payload);
|
||||
} catch (err) {
|
||||
opts.runtime.error?.(`mattermost reaction handler failed: ${String(err)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.event !== "posted") {
|
||||
return;
|
||||
}
|
||||
const parsed = parsePostedPayload(payload);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await opts.onPosted(parsed.post, parsed.payload);
|
||||
} catch (err) {
|
||||
opts.runtime.error?.(`mattermost handler failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
captureWsEvent({
|
||||
url: opts.wsUrl,
|
||||
direction: "local",
|
||||
kind: "ws-close",
|
||||
flowId,
|
||||
closeCode: code,
|
||||
payload: reason,
|
||||
meta: { subsystem: "mattermost-websocket" },
|
||||
});
|
||||
stopHealthChecks();
|
||||
const message = reasonToString(reason);
|
||||
opts.statusSink?.({
|
||||
connected: false,
|
||||
lastDisconnect: {
|
||||
at: Date.now(),
|
||||
status: code,
|
||||
error: message || undefined,
|
||||
},
|
||||
});
|
||||
if (opened) {
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
rejectOnce(new WebSocketClosedBeforeOpenError(code, message || undefined));
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
captureWsEvent({
|
||||
url: opts.wsUrl,
|
||||
direction: "local",
|
||||
kind: "error",
|
||||
flowId,
|
||||
errorText: String(err),
|
||||
meta: { subsystem: "mattermost-websocket" },
|
||||
});
|
||||
opts.runtime.error?.(`mattermost websocket error: ${String(err)}`);
|
||||
opts.statusSink?.({
|
||||
lastError: String(err),
|
||||
});
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function reasonToString(reason: Buffer | string | undefined): string {
|
||||
if (!reason) {
|
||||
return "";
|
||||
}
|
||||
if (typeof reason === "string") {
|
||||
return reason;
|
||||
}
|
||||
return reason.length > 0 ? reason.toString("utf8") : "";
|
||||
}
|
||||
304
extensions/mattermost/src/mattermost/monitor.authz.test.ts
Normal file
304
extensions/mattermost/src/mattermost/monitor.authz.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
// Mattermost tests cover monitor.authz plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
resolveMattermostMonitorInboundAccess,
|
||||
} from "./monitor-auth.js";
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
streamingMode: "partial",
|
||||
config: {},
|
||||
};
|
||||
|
||||
function authorizeGroupCommand(senderId: string) {
|
||||
return authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["trusted-user"],
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
senderId,
|
||||
senderName: senderId,
|
||||
channelId: "chan-1",
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("mattermost monitor authz", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.senderAccess.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
});
|
||||
|
||||
it("uses explicit groupAllowFrom without pairing-store inheritance", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "group-owner",
|
||||
senderName: "Group Owner",
|
||||
channelId: "chan-1",
|
||||
kind: "channel",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("falls group allowlist back to allowFrom without pairing-store entries", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "chan-1",
|
||||
kind: "channel",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
|
||||
const access = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(access.ingress.decision).toBe("block");
|
||||
expect(access.commandAccess.authorized).toBe(false);
|
||||
});
|
||||
|
||||
it("denies group control commands when the sender is outside the allowlist", async () => {
|
||||
const decision = await authorizeGroupCommand("attacker");
|
||||
|
||||
expect(decision).toEqual({
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "general",
|
||||
channelDisplay: "General",
|
||||
roomLabel: "#general",
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes group control commands for allowlisted senders", async () => {
|
||||
const decision = await authorizeGroupCommand("trusted-user");
|
||||
|
||||
expect(decision).toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "general",
|
||||
channelDisplay: "General",
|
||||
roomLabel: "#general",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies command invocations when channel type is unavailable", async () => {
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "open",
|
||||
allowFrom: ["trusted-user"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "new-user",
|
||||
senderName: "New User",
|
||||
channelId: "dm-1",
|
||||
channelInfo: {
|
||||
id: "dm-1",
|
||||
name: "",
|
||||
display_name: "",
|
||||
},
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
ok: false,
|
||||
denyReason: "unknown-channel",
|
||||
commandAuthorized: false,
|
||||
channelInfo: {
|
||||
id: "dm-1",
|
||||
name: "",
|
||||
display_name: "",
|
||||
},
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: "#dm-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes group senders through static access groups", async () => {
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:oncall"],
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
accessGroups: {
|
||||
oncall: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
mattermost: ["mattermost:trusted-user"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "chan-1",
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "general",
|
||||
channelDisplay: "General",
|
||||
roomLabel: "#general",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails direct reaction access without pairing admission", async () => {
|
||||
const access = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "new-user",
|
||||
senderName: "New User",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
eventKind: "reaction",
|
||||
mayPair: false,
|
||||
});
|
||||
|
||||
expect(access.ingress.decision).toBe("block");
|
||||
expect(access.ingress.reasonCode).toBe("event_pairing_not_allowed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Mattermost tests cover monitor.channel kind plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapMattermostChannelTypeToChatType } from "./monitor.js";
|
||||
|
||||
describe("mapMattermostChannelTypeToChatType", () => {
|
||||
it("maps direct and group dm channel types", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("g")).toBe("group");
|
||||
});
|
||||
|
||||
it("maps private channels to group", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType(" p ")).toBe("group");
|
||||
});
|
||||
|
||||
it("keeps public channels and unknown typed values as channel", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType("x")).toBe("channel");
|
||||
});
|
||||
|
||||
it("treats missing channel type as direct", () => {
|
||||
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType(null)).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("")).toBe("direct");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
1041
extensions/mattermost/src/mattermost/monitor.test.ts
Normal file
1041
extensions/mattermost/src/mattermost/monitor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2319
extensions/mattermost/src/mattermost/monitor.ts
Normal file
2319
extensions/mattermost/src/mattermost/monitor.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
// Mattermost tests cover no visible reply diagnostic plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateMattermostNoVisibleReply,
|
||||
formatMattermostNoVisibleReplyLog,
|
||||
} from "./no-visible-reply-diagnostic.js";
|
||||
|
||||
describe("evaluateMattermostNoVisibleReply", () => {
|
||||
it("flags substantive text payloads that delivered empty (regression: #80501)", () => {
|
||||
const violation = evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: { text: "Here is the result of the work I did..." },
|
||||
});
|
||||
expect(violation).toStrictEqual({
|
||||
reason: "no-visible-reply-after-final-delivery",
|
||||
outcome: "empty",
|
||||
finalTextLength: "Here is the result of the work I did...".length,
|
||||
mediaUrlCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags payloads with media URLs that delivered empty (regression: #80501)", () => {
|
||||
const violation = evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: { mediaUrl: "https://example.org/a.png" },
|
||||
});
|
||||
expect(violation).toStrictEqual({
|
||||
reason: "no-visible-reply-after-final-delivery",
|
||||
outcome: "empty",
|
||||
finalTextLength: 0,
|
||||
mediaUrlCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("follows the SDK legacy media fallback when counting media URLs", () => {
|
||||
const violation = evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: {
|
||||
mediaUrl: "https://example.org/a.png",
|
||||
mediaUrls: ["https://example.org/b.png", "https://example.org/c.png"],
|
||||
},
|
||||
});
|
||||
expect(violation?.mediaUrlCount).toBe(2);
|
||||
});
|
||||
|
||||
it("does not flag reasoning_skipped outcome (intentional suppression)", () => {
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "reasoning_skipped",
|
||||
payload: { text: "Reasoning: hidden" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flag text outcome (visible delivery happened)", () => {
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "text",
|
||||
payload: { text: "hello" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flag media outcome (visible delivery happened)", () => {
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "media",
|
||||
payload: { mediaUrl: "https://example.org/a.png" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flag empty outcome when the payload was nominally empty (no text or media at all)", () => {
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: {},
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: { text: "" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: { text: " \n\t " },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace when measuring finalTextLength", () => {
|
||||
const violation = evaluateMattermostNoVisibleReply({
|
||||
outcome: "empty",
|
||||
payload: { text: " hello " },
|
||||
});
|
||||
expect(violation?.finalTextLength).toBe("hello".length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMattermostNoVisibleReplyLog", () => {
|
||||
it("emits a grep-friendly single-line diagnostic with the expected key/value pairs", () => {
|
||||
const line = formatMattermostNoVisibleReplyLog({
|
||||
violation: {
|
||||
reason: "no-visible-reply-after-final-delivery",
|
||||
outcome: "empty",
|
||||
finalTextLength: 137,
|
||||
mediaUrlCount: 0,
|
||||
},
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(line).toBe(
|
||||
"mattermost no-visible-reply: no-visible-reply-after-final-delivery" +
|
||||
" to=channel:town-square" +
|
||||
" accountId=default" +
|
||||
" agentId=main" +
|
||||
" outcome=empty" +
|
||||
" finalTextLength=137" +
|
||||
" mediaUrlCount=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to unknown when agentId is undefined", () => {
|
||||
const line = formatMattermostNoVisibleReplyLog({
|
||||
violation: {
|
||||
reason: "no-visible-reply-after-final-delivery",
|
||||
outcome: "empty",
|
||||
finalTextLength: 1,
|
||||
mediaUrlCount: 0,
|
||||
},
|
||||
to: "channel:x",
|
||||
accountId: "y",
|
||||
agentId: undefined,
|
||||
});
|
||||
expect(line).toContain("agentId=unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Mattermost plugin module implements no visible reply diagnostic behavior.
|
||||
import { countOutboundMedia } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { MattermostReplyDeliveryOutcome } from "./reply-delivery.js";
|
||||
|
||||
export type MattermostNoVisibleReplyViolation = {
|
||||
reason: "no-visible-reply-after-final-delivery";
|
||||
outcome: MattermostReplyDeliveryOutcome;
|
||||
finalTextLength: number;
|
||||
mediaUrlCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the #80501 symptom: `deliverMattermostReplyPayload` accepted a
|
||||
* substantive (non-reasoning) payload, called the underlying
|
||||
* `deliverTextOrMediaReply`, and the outcome was `"empty"` — meaning the
|
||||
* payload had no text and no media to send, so no Mattermost API call
|
||||
* happened. The agent's run completes successfully, but no visible
|
||||
* channel/thread reply ever surfaces to the user.
|
||||
*
|
||||
* Returns a structured violation when the outcome is `"empty"` for a payload
|
||||
* that nominally carried user-facing content (text or media bytes that ended
|
||||
* up dropped by `resolveSendableOutboundReplyParts`/`sendMediaWithLeadingCaption`).
|
||||
* Returns `null` for `"reasoning_skipped"` (intentional suppression),
|
||||
* `"text"`, or `"media"` (successful visible sends).
|
||||
*/
|
||||
export function evaluateMattermostNoVisibleReply(params: {
|
||||
outcome: MattermostReplyDeliveryOutcome;
|
||||
payload: ReplyPayload;
|
||||
}): MattermostNoVisibleReplyViolation | null {
|
||||
if (params.outcome !== "empty") {
|
||||
return null;
|
||||
}
|
||||
const finalText = typeof params.payload.text === "string" ? params.payload.text.trim() : "";
|
||||
const mediaUrlCount = countOutboundMedia(params.payload);
|
||||
// If the payload had no text and no media even nominally, the run had
|
||||
// nothing to send and "empty" is the correct outcome — do not flag.
|
||||
if (finalText.length === 0 && mediaUrlCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: "no-visible-reply-after-final-delivery",
|
||||
outcome: params.outcome,
|
||||
finalTextLength: finalText.length,
|
||||
mediaUrlCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMattermostNoVisibleReplyLog(params: {
|
||||
violation: MattermostNoVisibleReplyViolation;
|
||||
to: string;
|
||||
accountId: string;
|
||||
agentId: string | undefined;
|
||||
}): string {
|
||||
return (
|
||||
`mattermost no-visible-reply: ${params.violation.reason}` +
|
||||
` to=${params.to}` +
|
||||
` accountId=${params.accountId}` +
|
||||
` agentId=${params.agentId ?? "unknown"}` +
|
||||
` outcome=${params.violation.outcome}` +
|
||||
` finalTextLength=${params.violation.finalTextLength}` +
|
||||
` mediaUrlCount=${params.violation.mediaUrlCount}`
|
||||
);
|
||||
}
|
||||
195
extensions/mattermost/src/mattermost/probe.test.ts
Normal file
195
extensions/mattermost/src/mattermost/probe.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// Mattermost tests cover probe plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeMattermost } from "./probe.js";
|
||||
|
||||
const { mockFetchGuard, mockRelease } = vi.hoisted(() => ({
|
||||
mockFetchGuard: vi.fn(),
|
||||
mockRelease: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
|
||||
});
|
||||
|
||||
function requireFirstFetchCall() {
|
||||
const [call] = mockFetchGuard.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Mattermost probe fetch call");
|
||||
}
|
||||
return call[0] as {
|
||||
url?: string;
|
||||
init?: { headers?: unknown; signal?: unknown };
|
||||
auditContext?: string;
|
||||
policy?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
describe("probeMattermost", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchGuard.mockReset();
|
||||
mockRelease.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns baseUrl missing for empty base URL", async () => {
|
||||
await expect(probeMattermost(" ", "token")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "baseUrl missing",
|
||||
});
|
||||
expect(mockFetchGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes base URL and returns bot info", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
|
||||
|
||||
const fetchCall = requireFirstFetchCall();
|
||||
expect(fetchCall?.url).toBe("https://mm.example.com/api/v4/users/me");
|
||||
expect(fetchCall?.init?.headers).toStrictEqual({ Authorization: "Bearer bot-token" });
|
||||
expect(fetchCall?.init?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(fetchCall?.auditContext).toBe("mattermost-probe");
|
||||
expect(fetchCall?.policy).toBeUndefined();
|
||||
const { elapsedMs, ...stableResult } = result;
|
||||
expect(stableResult).toStrictEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
bot: { id: "bot-1", username: "clawbot" },
|
||||
});
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds and cancels oversized probe success JSON bodies", async () => {
|
||||
let canceled = false;
|
||||
let pulled = 0;
|
||||
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of "{"
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulled += 1;
|
||||
controller.enqueue(oversizeChunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com", "bot-token");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error).toContain("JSON response exceeds 16777216 bytes");
|
||||
expect(canceled).toBe(true);
|
||||
expect(pulled).toBeLessThanOrEqual(12);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards allowPrivateNetwork to the SSRF guard policy", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ id: "bot-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
await probeMattermost("https://mm.example.com", "bot-token", 2500, true);
|
||||
|
||||
const fetchCall = requireFirstFetchCall();
|
||||
expect(fetchCall?.policy).toStrictEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("clamps oversized probe timeouts before scheduling", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ id: "bot-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
await probeMattermost("https://mm.example.com", "bot-token", Number.MAX_SAFE_INTEGER);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns API error details from JSON response", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ message: "invalid auth token" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com", "bad-token");
|
||||
const { elapsedMs, ...stableResult } = result;
|
||||
expect(stableResult).toStrictEqual({
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: "invalid auth token",
|
||||
});
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to statusText when error body is empty", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response("", {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com", "token");
|
||||
const { elapsedMs, ...stableResult } = result;
|
||||
expect(stableResult).toStrictEqual({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "Forbidden",
|
||||
});
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns fetch error when request throws", async () => {
|
||||
mockFetchGuard.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com", "token");
|
||||
const { elapsedMs, ...stableResult } = result;
|
||||
expect(stableResult).toStrictEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: "network down",
|
||||
});
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
80
extensions/mattermost/src/mattermost/probe.ts
Normal file
80
extensions/mattermost/src/mattermost/probe.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Mattermost plugin module implements probe behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
|
||||
type MattermostProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
allowPrivateNetwork = false,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const resolvedTimeoutMs = timeoutMs > 0 ? resolveTimerTimeoutMs(timeoutMs, 2500) : 0;
|
||||
const controller = resolvedTimeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), resolvedTimeoutMs);
|
||||
}
|
||||
try {
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
},
|
||||
auditContext: "mattermost-probe",
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork),
|
||||
});
|
||||
try {
|
||||
const elapsedMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: detail || res.statusText,
|
||||
elapsedMs,
|
||||
};
|
||||
}
|
||||
const bot = await readProviderJsonResponse<MattermostUser>(res, "Mattermost probe /users/me");
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
bot,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
extensions/mattermost/src/mattermost/reactions.test-helpers.ts
Normal file
100
extensions/mattermost/src/mattermost/reactions.test-helpers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Mattermost helper module supports reactions helpers behavior.
|
||||
import { expect, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import type { MattermostFetch } from "./client.js";
|
||||
|
||||
export function requestUrl(url: string | URL | Request): string {
|
||||
if (typeof url === "string") {
|
||||
return url;
|
||||
}
|
||||
if (url instanceof URL) {
|
||||
return url.toString();
|
||||
}
|
||||
return url.url;
|
||||
}
|
||||
|
||||
export function createMattermostTestConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMattermostReactionFetchMock(params: {
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
mode: "add" | "remove" | "both";
|
||||
userId?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
}) {
|
||||
const userId = params.userId ?? "BOT123";
|
||||
const mode = params.mode;
|
||||
const allowAdd = mode === "add" || mode === "both";
|
||||
const allowRemove = mode === "remove" || mode === "both";
|
||||
const addStatus = params.status ?? 201;
|
||||
const removeStatus = params.status ?? 204;
|
||||
const removePath = `/api/v4/users/${userId}/posts/${params.postId}/reactions/${encodeURIComponent(params.emojiName)}`;
|
||||
|
||||
return vi.fn<typeof fetch>(async (url, init) => {
|
||||
const urlText = requestUrl(url);
|
||||
if (urlText.endsWith("/api/v4/users/me")) {
|
||||
return new Response(JSON.stringify({ id: userId }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (allowAdd && urlText.endsWith("/api/v4/reactions")) {
|
||||
expect(init?.method).toBe("POST");
|
||||
const requestBody = init?.body;
|
||||
if (typeof requestBody !== "string") {
|
||||
throw new Error("expected string POST body");
|
||||
}
|
||||
expect(JSON.parse(requestBody)).toEqual({
|
||||
user_id: userId,
|
||||
post_id: params.postId,
|
||||
emoji_name: params.emojiName,
|
||||
});
|
||||
|
||||
const responseBody = params.body === undefined ? { ok: true } : params.body;
|
||||
return new Response(
|
||||
responseBody === null ? null : JSON.stringify(responseBody),
|
||||
responseBody === null
|
||||
? { status: addStatus, headers: { "content-type": "text/plain" } }
|
||||
: { status: addStatus, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (allowRemove && urlText.endsWith(removePath)) {
|
||||
expect(init?.method).toBe("DELETE");
|
||||
const responseBody = params.body === undefined ? null : params.body;
|
||||
return new Response(
|
||||
responseBody === null ? null : JSON.stringify(responseBody),
|
||||
responseBody === null
|
||||
? { status: removeStatus, headers: { "content-type": "text/plain" } }
|
||||
: { status: removeStatus, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${urlText}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMockedGlobalFetch<T>(
|
||||
fetchImpl: MattermostFetch,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prevFetch = globalThis.fetch;
|
||||
globalThis.fetch = fetchImpl;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
globalThis.fetch = prevFetch;
|
||||
}
|
||||
}
|
||||
202
extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
202
extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Mattermost tests cover reactions plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addMattermostReaction,
|
||||
removeMattermostReaction,
|
||||
resetMattermostReactionBotUserCacheForTests,
|
||||
} from "./reactions.js";
|
||||
import {
|
||||
createMattermostReactionFetchMock,
|
||||
createMattermostTestConfig,
|
||||
requestUrl,
|
||||
} from "./reactions.test-helpers.js";
|
||||
|
||||
describe("mattermost reactions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function addReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return removeMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
}
|
||||
|
||||
it("adds reactions by calling /users/me then POST /reactions", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await addReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a Result error when add reaction API call fails", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
status: 500,
|
||||
body: { id: "err", message: "boom" },
|
||||
});
|
||||
|
||||
const result = await addReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("Mattermost add reaction failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "remove",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await removeReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches the bot user id across reaction mutations", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "both",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const cfg = createMattermostTestConfig();
|
||||
const addResult = await addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
const removeResult = await removeMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
||||
requestUrl(call[0]).endsWith("/api/v4/users/me"),
|
||||
);
|
||||
expect(addResult).toEqual({ ok: true });
|
||||
expect(removeResult).toEqual({ ok: true });
|
||||
expect(usersMeCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not reuse cached bot user ids while the process clock is invalid", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
const firstFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_OLD",
|
||||
});
|
||||
const secondFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST2",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_FRESH",
|
||||
});
|
||||
const thirdFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST3",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_RECOVERED",
|
||||
});
|
||||
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: firstFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST2",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: secondFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST3",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: thirdFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const usersMeCalls = [
|
||||
...firstFetch.mock.calls,
|
||||
...secondFetch.mock.calls,
|
||||
...thirdFetch.mock.calls,
|
||||
].filter((call) => requestUrl(call[0]).endsWith("/api/v4/users/me"));
|
||||
expect(usersMeCalls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not cache bot user ids when cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const cfg = createMattermostTestConfig();
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "both",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
removeMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
||||
requestUrl(call[0]).endsWith("/api/v4/users/me"),
|
||||
);
|
||||
expect(usersMeCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
144
extensions/mattermost/src/mattermost/reactions.ts
Normal file
144
extensions/mattermost/src/mattermost/reactions.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Mattermost plugin module implements reactions behavior.
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostMe,
|
||||
type MattermostClient,
|
||||
type MattermostFetch,
|
||||
} from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
type Result = { ok: true } | { ok: false; error: string };
|
||||
type ReactionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: MattermostFetch;
|
||||
};
|
||||
type ReactionMutation = (client: MattermostClient, params: MutationPayload) => Promise<void>;
|
||||
type MutationPayload = { userId: string; postId: string; emojiName: string };
|
||||
|
||||
const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
|
||||
|
||||
async function resolveBotUserId(
|
||||
client: MattermostClient,
|
||||
cacheKey: string,
|
||||
): Promise<string | null> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = botUserIdCache.get(cacheKey);
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
return cached.userId;
|
||||
}
|
||||
botUserIdCache.delete(cacheKey);
|
||||
}
|
||||
const me = await fetchMattermostMe(client);
|
||||
const userId = me?.id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(BOT_USER_CACHE_TTL_MS, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function addMattermostReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: MattermostFetch;
|
||||
}): Promise<Result> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "add",
|
||||
mutation: createReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMattermostReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: MattermostFetch;
|
||||
}): Promise<Result> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "remove",
|
||||
mutation: deleteReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetMattermostReactionBotUserCacheForTests(): void {
|
||||
botUserIdCache.clear();
|
||||
}
|
||||
|
||||
async function runMattermostReaction(
|
||||
params: ReactionParams,
|
||||
options: {
|
||||
action: "add" | "remove";
|
||||
mutation: ReactionMutation;
|
||||
},
|
||||
): Promise<Result> {
|
||||
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const baseUrl = resolved.baseUrl?.trim();
|
||||
const botToken = resolved.botToken?.trim();
|
||||
if (!baseUrl || !botToken) {
|
||||
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
|
||||
}
|
||||
|
||||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken,
|
||||
fetchImpl: params.fetchImpl,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(resolved.config),
|
||||
});
|
||||
|
||||
const cacheKey = `${baseUrl}:${botToken}`;
|
||||
const userId = await resolveBotUserId(client, cacheKey);
|
||||
if (!userId) {
|
||||
return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." };
|
||||
}
|
||||
|
||||
try {
|
||||
await options.mutation(client, {
|
||||
userId,
|
||||
postId: params.postId,
|
||||
emojiName: params.emojiName,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function createReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
|
||||
await client.request<Record<string, unknown>>("/reactions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
user_id: params.userId,
|
||||
post_id: params.postId,
|
||||
emoji_name: params.emojiName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
|
||||
const emoji = encodeURIComponent(params.emojiName);
|
||||
await client.request<unknown>(
|
||||
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
202
extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
202
extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Mattermost tests cover reconnect plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function resolveReconnectRun(promise: Promise<void>): Promise<void> {
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
}
|
||||
|
||||
describe("runWithReconnect", () => {
|
||||
it("retries after connectFn resolves (normal close)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 3) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries after connectFn throws (connection error)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const onError = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onError,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
expect(onError.mock.calls.map(([error]) => error)).toStrictEqual([
|
||||
new Error("fetch failed"),
|
||||
new Error("fetch failed"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses exponential backoff on consecutive errors, capped at maxDelayMs", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 6) {
|
||||
abort.abort();
|
||||
return;
|
||||
}
|
||||
throw new Error("connection refused");
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 10,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(6);
|
||||
expect(delays).toEqual([1, 2, 4, 8, 10]);
|
||||
});
|
||||
|
||||
it("resets backoff after successful connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("first failure");
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return;
|
||||
}
|
||||
if (callCount === 3) {
|
||||
throw new Error("second failure");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(4);
|
||||
expect(delays).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("stops immediately when abort signal is pre-fired", async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const connectFn = vi.fn(async () => {});
|
||||
|
||||
await runWithReconnect(connectFn, { abortSignal: abort.signal });
|
||||
|
||||
expect(connectFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops after current connection when abort fires mid-connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("abort signal interrupts backoff sleep immediately", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
setTimeout(() => abort.abort(), 10);
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies jitter to reconnect delay when configured", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("connection refused");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 10,
|
||||
jitterRatio: 0.5,
|
||||
random: () => 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(2);
|
||||
expect(delays).toEqual([15]);
|
||||
});
|
||||
|
||||
it("supports strategy hook to stop reconnecting after failure", async () => {
|
||||
const onReconnect = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
throw new Error("fatal");
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
initialDelayMs: 1,
|
||||
onReconnect,
|
||||
shouldReconnect: (params) => params.outcome !== "rejected",
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
115
extensions/mattermost/src/mattermost/reconnect.ts
Normal file
115
extensions/mattermost/src/mattermost/reconnect.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Mattermost plugin module implements reconnect behavior.
|
||||
type ReconnectOutcome = "resolved" | "rejected";
|
||||
|
||||
type ShouldReconnectParams = {
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
outcome: ReconnectOutcome;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type RunWithReconnectOpts = {
|
||||
abortSignal?: AbortSignal;
|
||||
onError?: (err: unknown) => void;
|
||||
onReconnect?: (delayMs: number) => void;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitterRatio?: number;
|
||||
random?: () => number;
|
||||
shouldReconnect?: (params: ShouldReconnectParams) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnection loop with exponential backoff.
|
||||
*
|
||||
* Calls `connectFn` in a while loop. On normal resolve (connection closed),
|
||||
* the backoff resets. On thrown error (connection failed), the current delay is
|
||||
* used, then doubled for the next retry.
|
||||
* The loop exits when `abortSignal` fires.
|
||||
*/
|
||||
export async function runWithReconnect(
|
||||
connectFn: () => Promise<void>,
|
||||
opts: RunWithReconnectOpts = {},
|
||||
): Promise<void> {
|
||||
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
|
||||
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
|
||||
const random = opts.random ?? Math.random;
|
||||
const backoff = createReconnectBackoff(initialDelayMs, maxDelayMs);
|
||||
let attempt = 0;
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
let outcome: ReconnectOutcome = "resolved";
|
||||
let error: unknown;
|
||||
try {
|
||||
await connectFn();
|
||||
backoff.reset();
|
||||
} catch (err) {
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
outcome = "rejected";
|
||||
error = err;
|
||||
opts.onError?.(err);
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const delayMs = withJitter(backoff.current(), jitterRatio, random);
|
||||
const shouldReconnect =
|
||||
opts.shouldReconnect?.({
|
||||
attempt,
|
||||
delayMs,
|
||||
outcome,
|
||||
error,
|
||||
}) ?? true;
|
||||
if (!shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
opts.onReconnect?.(delayMs);
|
||||
await sleepAbortable(delayMs, opts.abortSignal);
|
||||
if (outcome === "rejected") {
|
||||
backoff.increase();
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
function createReconnectBackoff(initialDelayMs: number, maxDelayMs: number) {
|
||||
let retryDelay = initialDelayMs;
|
||||
return {
|
||||
current: () => retryDelay,
|
||||
reset: () => {
|
||||
retryDelay = initialDelayMs;
|
||||
},
|
||||
increase: () => {
|
||||
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
|
||||
if (jitterRatio <= 0) {
|
||||
return baseMs;
|
||||
}
|
||||
const normalized = Math.max(0, Math.min(1, random()));
|
||||
const spread = baseMs * jitterRatio;
|
||||
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
|
||||
}
|
||||
|
||||
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
312
extensions/mattermost/src/mattermost/reply-delivery.test.ts
Normal file
312
extensions/mattermost/src/mattermost/reply-delivery.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
// Mattermost tests cover reply delivery plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
|
||||
import {
|
||||
createMattermostReplyDeliveryBarrier,
|
||||
deliverMattermostReplyPayload,
|
||||
} from "./reply-delivery.js";
|
||||
|
||||
type DeliverMattermostReplyPayloadParams = Parameters<typeof deliverMattermostReplyPayload>[0];
|
||||
type ReplyDeliveryMarkdownTableMode = Parameters<
|
||||
DeliverMattermostReplyPayloadParams["core"]["channel"]["text"]["convertMarkdownTables"]
|
||||
>[1];
|
||||
|
||||
function createReplyDeliveryCore(): DeliverMattermostReplyPayloadParams["core"] {
|
||||
return {
|
||||
channel: {
|
||||
text: {
|
||||
chunkByNewline: vi.fn((text: string) => [text]),
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
chunkText: vi.fn((text: string) => [text]),
|
||||
chunkTextWithMode: vi.fn((text: string) => [text]),
|
||||
resolveMarkdownTableMode: vi.fn<() => ReplyDeliveryMarkdownTableMode>(() => "off"),
|
||||
resolveChunkMode: vi.fn<() => ChunkMode>(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(
|
||||
(
|
||||
_cfg?: OpenClawConfig,
|
||||
_provider?: string,
|
||||
_accountId?: string | null,
|
||||
opts?: { fallbackLimit?: number },
|
||||
) => opts?.fallbackLimit ?? 4000,
|
||||
),
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
}
|
||||
|
||||
describe("createMattermostReplyDeliveryBarrier", () => {
|
||||
it("extends while direct deliveries or DM resolution remain unsettled", async () => {
|
||||
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
|
||||
const policy = barrier.resolveTimeoutPolicy({
|
||||
queuedCounts: { tool: 1, block: 0, final: 1 },
|
||||
humanDelayBudgetMs: 0,
|
||||
});
|
||||
expect(policy?.maxTimeoutMs).toBe(420_000);
|
||||
expect(policy?.shouldExtend()).toBe(true);
|
||||
|
||||
let resolveResolution: () => void = () => {};
|
||||
const resolution = new Promise<void>((resolve) => {
|
||||
resolveResolution = resolve;
|
||||
});
|
||||
barrier.trackDmChannelResolution(resolution);
|
||||
expect(policy?.shouldExtend()).toBe(true);
|
||||
|
||||
resolveResolution();
|
||||
await resolution;
|
||||
await Promise.resolve();
|
||||
expect(policy?.shouldExtend()).toBe(true);
|
||||
|
||||
barrier.markDeliverySettled();
|
||||
expect(policy?.shouldExtend()).toBe(true);
|
||||
|
||||
barrier.markDeliverySettled();
|
||||
expect(policy?.shouldExtend()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays extended between failed retries until queued deliveries settle", async () => {
|
||||
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
|
||||
const policy = barrier.resolveTimeoutPolicy({
|
||||
queuedCounts: { tool: 1, block: 0, final: 1 },
|
||||
humanDelayBudgetMs: 0,
|
||||
});
|
||||
let rejectResolution: (error: Error) => void = () => {};
|
||||
const resolution = new Promise<void>((_resolve, reject) => {
|
||||
rejectResolution = reject;
|
||||
});
|
||||
barrier.trackDmChannelResolution(resolution);
|
||||
|
||||
rejectResolution(new Error("DM creation failed"));
|
||||
await expect(resolution).rejects.toThrow("DM creation failed");
|
||||
await Promise.resolve();
|
||||
barrier.markDeliverySettled();
|
||||
expect(policy?.shouldExtend()).toBe(true);
|
||||
|
||||
barrier.markDeliverySettled();
|
||||
expect(policy?.shouldExtend()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not extend non-DM delivery", () => {
|
||||
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: false });
|
||||
expect(
|
||||
barrier.resolveTimeoutPolicy({
|
||||
queuedCounts: { tool: 1, block: 1, final: 1 },
|
||||
humanDelayBudgetMs: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deliverMattermostReplyPayload", () => {
|
||||
it("suppresses payloads flagged as reasoning", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
|
||||
const outcome = await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "hidden", isReasoning: true },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(outcome).toBe("reasoning_skipped");
|
||||
});
|
||||
|
||||
it("returns 'empty' for substantive text that produced no send (regression: #80501)", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
// Make the markdown table converter strip the text to empty so
|
||||
// deliverTextOrMediaReply sees an empty chunked text and returns "empty".
|
||||
core.channel.text.convertMarkdownTables = vi.fn(() => "");
|
||||
core.channel.text.chunkMarkdownTextWithMode = vi.fn(() => []);
|
||||
|
||||
const outcome = await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "non-trivial input that the converter strips" },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(outcome).toBe("empty");
|
||||
});
|
||||
|
||||
it("suppresses reasoning-prefixed payloads even without an explicit flag", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: " \n Reasoning:\n_hidden_" },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payloads formatted as a Mattermost blockquote", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "> Reasoning:\n> _hidden_" },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress messages that mention Reasoning: mid-text", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "Intro line\nReasoning: appears in content but is not a prefix" },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"channel:town-square",
|
||||
"Intro line\nReasoning: appears in content but is not a prefix",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
replyToId: "root-post",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes agent-scoped mediaLocalRoots when sending media paths", async () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mm-state-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
try {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const core = createReplyDeliveryCore();
|
||||
|
||||
const agentId = "agent-1";
|
||||
const mediaUrl = `file://${path.join(stateDir, `workspace-${agentId}`, "photo.png")}`;
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "caption", mediaUrl },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId,
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"channel:town-square",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
mediaUrl,
|
||||
replyToId: "root-post",
|
||||
mediaLocalRoots: expect.arrayContaining([
|
||||
path.join(stateDir, "media"),
|
||||
path.join(stateDir, "canvas"),
|
||||
path.join(stateDir, "workspace"),
|
||||
path.join(stateDir, "sandboxes"),
|
||||
path.join(stateDir, `workspace-${agentId}`),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards replyToId for text-only chunked replies", async () => {
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
const core = createReplyDeliveryCore();
|
||||
core.channel.text.chunkMarkdownTextWithMode = vi.fn(() => ["hello"]);
|
||||
|
||||
const outcome = await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload: { text: "hello" },
|
||||
to: "channel:town-square",
|
||||
accountId: "default",
|
||||
agentId: "agent-1",
|
||||
replyToId: "root-post",
|
||||
textLimit: 4000,
|
||||
tableMode: "off",
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"channel:town-square",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
replyToId: "root-post",
|
||||
}),
|
||||
);
|
||||
expect(outcome).toBe("text");
|
||||
});
|
||||
});
|
||||
147
extensions/mattermost/src/mattermost/reply-delivery.ts
Normal file
147
extensions/mattermost/src/mattermost/reply-delivery.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Mattermost plugin module implements reply delivery behavior.
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
isReasoningReplyPayload,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type {
|
||||
ReplyDispatchKind,
|
||||
ReplyFollowupAdmissionBarrierTimeoutPolicy,
|
||||
ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
resolveMattermostReplyDeliveryBarrierTimeoutMs,
|
||||
type CreateDmChannelRetryOptions,
|
||||
} from "./client.js";
|
||||
|
||||
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
|
||||
|
||||
type SendMattermostMessage = (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
replyToId?: string;
|
||||
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function createMattermostReplyDeliveryBarrier(params: {
|
||||
isDirect: boolean;
|
||||
dmRetryOptions?: CreateDmChannelRetryOptions;
|
||||
}) {
|
||||
let activeDmChannelResolutions = 0;
|
||||
let queuedDeliveryCount = 0;
|
||||
let settledDeliveryCount = 0;
|
||||
const trackDmChannelResolution = (resolution: PromiseLike<unknown>) => {
|
||||
activeDmChannelResolutions += 1;
|
||||
void Promise.resolve(resolution).then(
|
||||
() => {
|
||||
activeDmChannelResolutions -= 1;
|
||||
},
|
||||
() => {
|
||||
activeDmChannelResolutions -= 1;
|
||||
},
|
||||
);
|
||||
};
|
||||
const markDeliverySettled = () => {
|
||||
settledDeliveryCount += 1;
|
||||
};
|
||||
const resolveTimeoutPolicy = (context: {
|
||||
queuedCounts: Readonly<Record<ReplyDispatchKind, number>>;
|
||||
humanDelayBudgetMs: number;
|
||||
}): ReplyFollowupAdmissionBarrierTimeoutPolicy | undefined => {
|
||||
const { queuedCounts } = context;
|
||||
queuedDeliveryCount = Object.values(queuedCounts).reduce((sum, count) => sum + count, 0);
|
||||
const maxTimeoutMs = resolveMattermostReplyDeliveryBarrierTimeoutMs({
|
||||
isDirect: params.isDirect,
|
||||
dmRetryOptions: params.dmRetryOptions,
|
||||
queuedCounts,
|
||||
humanDelayBudgetMs: context.humanDelayBudgetMs,
|
||||
});
|
||||
if (maxTimeoutMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
maxTimeoutMs,
|
||||
shouldExtend: () =>
|
||||
activeDmChannelResolutions > 0 || settledDeliveryCount < queuedDeliveryCount,
|
||||
};
|
||||
};
|
||||
return {
|
||||
trackDmChannelResolution,
|
||||
markDeliverySettled,
|
||||
resolveTimeoutPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `deliverMattermostReplyPayload`. Callers in `monitor.ts` use this
|
||||
* to distinguish a successful visible send from an intentionally suppressed
|
||||
* reasoning payload from a substantive payload that ended up sending nothing
|
||||
* (the silent-completion symptom in #80501).
|
||||
*/
|
||||
export type MattermostReplyDeliveryOutcome = "reasoning_skipped" | "empty" | "text" | "media";
|
||||
|
||||
export async function deliverMattermostReplyPayload(params: {
|
||||
core: PluginRuntime;
|
||||
cfg: OpenClawConfig;
|
||||
payload: ReplyPayload;
|
||||
to: string;
|
||||
accountId: string;
|
||||
agentId?: string;
|
||||
replyToId?: string;
|
||||
textLimit: number;
|
||||
tableMode: MarkdownTableMode;
|
||||
sendMessage: SendMattermostMessage;
|
||||
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
|
||||
}): Promise<MattermostReplyDeliveryOutcome> {
|
||||
if (isReasoningReplyPayload(params.payload)) {
|
||||
return "reasoning_skipped";
|
||||
}
|
||||
const reply = resolveSendableOutboundReplyParts(params.payload, {
|
||||
text: params.core.channel.text.convertMarkdownTables(
|
||||
params.payload.text ?? "",
|
||||
params.tableMode,
|
||||
),
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
|
||||
const chunkMode = params.core.channel.text.resolveChunkMode(
|
||||
params.cfg,
|
||||
"mattermost",
|
||||
params.accountId,
|
||||
);
|
||||
return await deliverTextOrMediaReply({
|
||||
payload: params.payload,
|
||||
text: reply.text,
|
||||
chunkText: (value) =>
|
||||
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
await params.sendMessage(params.to, chunk, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
replyToId: params.replyToId,
|
||||
...(params.onDmChannelResolution
|
||||
? { onDmChannelResolution: params.onDmChannelResolution }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
await params.sendMessage(params.to, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
replyToId: params.replyToId,
|
||||
...(params.onDmChannelResolution
|
||||
? { onDmChannelResolution: params.onDmChannelResolution }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
51
extensions/mattermost/src/mattermost/runtime-api.ts
Normal file
51
extensions/mattermost/src/mattermost/runtime-api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Mattermost API module exposes the plugin public contract.
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChatType,
|
||||
HistoryEntry,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
listSkillCommandsForAgents,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
// Legacy map-helper exports stay for older plugin consumers. New message-turn
|
||||
// code should use createChannelHistoryWindow.
|
||||
export {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
buildInboundHistoryFromMap,
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
isTrustedProxyAddress,
|
||||
parseStrictPositiveInteger,
|
||||
resolveClientIp,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { parseTcpPort } from "openclaw/plugin-sdk/number-runtime";
|
||||
746
extensions/mattermost/src/mattermost/send.test.ts
Normal file
746
extensions/mattermost/src/mattermost/send.test.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
// Mattermost tests cover send plugin behavior.
|
||||
import { expectProvidedCfgSkipsRuntimeLoad } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget;
|
||||
let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost;
|
||||
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
|
||||
|
||||
type SendMessageMattermostOptions = NonNullable<
|
||||
Parameters<typeof import("./send.js").sendMessageMattermost>[2]
|
||||
>;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
loadOutboundMediaFromUrl: vi.fn(),
|
||||
recordActivity: vi.fn(),
|
||||
resolveMattermostAccount: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
})),
|
||||
createMattermostClient: vi.fn(),
|
||||
createMattermostDirectChannel: vi.fn(),
|
||||
createMattermostDirectChannelWithRetry: vi.fn(),
|
||||
createMattermostPost: vi.fn(),
|
||||
fetchMattermostChannelByName: vi.fn(),
|
||||
fetchMattermostMe: vi.fn(),
|
||||
fetchMattermostUser: vi.fn(),
|
||||
fetchMattermostUserTeams: vi.fn(),
|
||||
fetchMattermostUserByUsername: vi.fn(),
|
||||
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
||||
uploadMattermostFile: vi.fn(),
|
||||
}));
|
||||
|
||||
type MattermostPostParams = {
|
||||
channelId?: string;
|
||||
message?: string;
|
||||
props?: {
|
||||
attachments?: Array<{
|
||||
actions?: Array<{ id?: string; name?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type MattermostUploadParams = {
|
||||
channelId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type MattermostDirectRetryOptions = {
|
||||
maxRetries?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
timeoutMs?: number;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
function mockCall(mock: unknown, label: string, index = 0): unknown[] {
|
||||
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
|
||||
const call = calls?.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`Expected ${label} call ${index + 1}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function uploadMattermostFileCall() {
|
||||
return mockCall(mockState.uploadMattermostFile, "uploadMattermostFile") as [
|
||||
unknown,
|
||||
MattermostUploadParams?,
|
||||
];
|
||||
}
|
||||
|
||||
function createMattermostPostParams() {
|
||||
const params = mockCall(mockState.createMattermostPost, "createMattermostPost")[1] as
|
||||
| MattermostPostParams
|
||||
| undefined;
|
||||
if (!params) {
|
||||
throw new Error("Expected createMattermostPost params");
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMattermostPostCall() {
|
||||
return mockCall(mockState.createMattermostPost, "createMattermostPost") as [
|
||||
unknown,
|
||||
MattermostPostParams?,
|
||||
];
|
||||
}
|
||||
|
||||
function directChannelRetryCall() {
|
||||
return mockCall(
|
||||
mockState.createMattermostDirectChannelWithRetry,
|
||||
"createMattermostDirectChannelWithRetry",
|
||||
) as [unknown, unknown, MattermostDirectRetryOptions?];
|
||||
}
|
||||
|
||||
vi.mock("../../runtime-api.js", () => ({
|
||||
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
|
||||
requireRuntimeConfig: (cfg: unknown) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
throw new Error("Mattermost send requires a resolved runtime config");
|
||||
},
|
||||
resolveMarkdownTableMode: vi.fn(() => "off"),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-chunking", () => ({
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
|
||||
normalizeLowercaseStringOrEmpty: vi.fn((value: string | null | undefined) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}),
|
||||
normalizeOptionalString: vi.fn((value: string | null | undefined) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}),
|
||||
normalizeStringifiedOptionalString: vi.fn((value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveMattermostAccount: mockState.resolveMattermostAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMattermostClient: mockState.createMattermostClient,
|
||||
createMattermostDirectChannel: mockState.createMattermostDirectChannel,
|
||||
createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry,
|
||||
createMattermostPost: mockState.createMattermostPost,
|
||||
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
|
||||
fetchMattermostMe: mockState.fetchMattermostMe,
|
||||
fetchMattermostUser: mockState.fetchMattermostUser,
|
||||
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
|
||||
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile: mockState.uploadMattermostFile,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMattermostRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: mockState.loadConfig,
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
getChildLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: () => "off",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
activity: {
|
||||
record: mockState.recordActivity,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("sendMessageMattermost", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockState.loadConfig.mockReset();
|
||||
mockState.loadConfig.mockReturnValue({});
|
||||
mockState.recordActivity.mockReset();
|
||||
mockState.resolveMattermostAccount.mockReset();
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.loadOutboundMediaFromUrl.mockReset();
|
||||
mockState.createMattermostClient.mockReset();
|
||||
mockState.createMattermostDirectChannel.mockReset();
|
||||
mockState.createMattermostDirectChannelWithRetry.mockReset();
|
||||
mockState.createMattermostPost.mockReset();
|
||||
mockState.fetchMattermostChannelByName.mockReset();
|
||||
mockState.fetchMattermostMe.mockReset();
|
||||
mockState.fetchMattermostUser.mockReset();
|
||||
mockState.fetchMattermostUserTeams.mockReset();
|
||||
mockState.fetchMattermostUserByUsername.mockReset();
|
||||
mockState.uploadMattermostFile.mockReset();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-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" });
|
||||
({ parseMattermostTarget, sendMessageMattermost } = await import("./send.js"));
|
||||
({ resetMattermostOpaqueTargetCacheForTests } = await import("./target-resolution.js"));
|
||||
resetMattermostOpaqueTargetCacheForTests();
|
||||
});
|
||||
|
||||
it("uses provided cfg and skips runtime loadConfig", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "provided-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "work",
|
||||
botToken: "provided-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const options: SendMessageMattermostOptions = {
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
};
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello", {
|
||||
...options,
|
||||
});
|
||||
|
||||
expectProvidedCfgSkipsRuntimeLoad({
|
||||
loadConfig: mockState.loadConfig,
|
||||
resolveAccount: mockState.resolveMattermostAccount,
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails hard when cfg is omitted", async () => {
|
||||
await expect(
|
||||
sendMessageMattermost("channel:town-square", "hello", undefined as never),
|
||||
).rejects.toThrow("Mattermost send requires a resolved runtime config");
|
||||
expect(mockState.loadConfig).not.toHaveBeenCalled();
|
||||
expect(mockState.resolveMattermostAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "provided-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "work",
|
||||
botToken: "provided-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.recordActivity.mockImplementation(() => {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("post-1");
|
||||
expect(result.channelId).toBe("town-square");
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("post-1");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
|
||||
expect(result.receipt.parts).toHaveLength(1);
|
||||
expect(result.receipt.parts[0]?.platformMessageId).toBe("post-1");
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
expect(mockState.loadConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads outbound media with trusted local roots before upload", async () => {
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"file:///tmp/agent-workspace/photo.png",
|
||||
{
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
},
|
||||
);
|
||||
const uploadCall = uploadMattermostFileCall();
|
||||
expect(uploadCall?.[0]).toEqual({});
|
||||
expect(uploadCall?.[1]?.channelId).toBe("town-square");
|
||||
expect(uploadCall?.[1]?.fileName).toBe("photo.png");
|
||||
expect(uploadCall?.[1]?.contentType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("fails instead of posting text-only when required media cannot be loaded", async () => {
|
||||
mockState.loadOutboundMediaFromUrl.mockRejectedValueOnce(new Error("local root denied"));
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
requireMediaUpload: true,
|
||||
}),
|
||||
).rejects.toThrow("Mattermost media upload failed: local root denied");
|
||||
|
||||
expect(mockState.createMattermostPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds interactive button props when buttons are provided", async () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "Pick a model", {
|
||||
cfg: TEST_CFG,
|
||||
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
|
||||
});
|
||||
|
||||
const postCall = createMattermostPostCall();
|
||||
expect(postCall?.[0]).toEqual({});
|
||||
expect(postCall?.[1]?.channelId).toBe("town-square");
|
||||
expect(postCall?.[1]?.message).toBe("Pick a model");
|
||||
const attachments = postCall?.[1]?.props?.attachments;
|
||||
expect(Array.isArray(attachments)).toBe(true);
|
||||
const actions = attachments?.[0]?.actions;
|
||||
expect(Array.isArray(actions)).toBe(true);
|
||||
expect(actions?.[0]?.id).toBe("mdlprov");
|
||||
expect(actions?.[0]?.name).toBe("Browse providers");
|
||||
});
|
||||
|
||||
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
|
||||
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(userId, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
|
||||
const dmRetryCall = directChannelRetryCall();
|
||||
expect(dmRetryCall?.[0]).toEqual({});
|
||||
expect(dmRetryCall?.[1]).toEqual(["bot-user", userId]);
|
||||
expect(Object.keys(dmRetryCall?.[2] ?? {})).toEqual(["onRetry"]);
|
||||
expect(dmRetryCall?.[2]?.onRetry).toBeTypeOf("function");
|
||||
const uploadCall = uploadMattermostFileCall();
|
||||
expect(uploadCall?.[0]).toEqual({});
|
||||
expect(uploadCall?.[1]?.channelId).toBe("dm-channel-1");
|
||||
expect(result.channelId).toBe("dm-channel-1");
|
||||
});
|
||||
|
||||
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
|
||||
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(
|
||||
new Error("Mattermost API 404 Not Found: user not found"),
|
||||
);
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(channelId, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
const uploadCall = uploadMattermostFileCall();
|
||||
expect(uploadCall?.[0]).toEqual({});
|
||||
expect(uploadCall?.[1]?.channelId).toBe(channelId);
|
||||
expect(result.channelId).toBe(channelId);
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
// Each test uses a unique (token, id) pair to avoid module-level cache collisions.
|
||||
// userIdResolutionCache and dmChannelCache are module singletons that survive across tests.
|
||||
// Using unique cache keys per test ensures full isolation without needing a cache reset API.
|
||||
describe("sendMessageMattermost user-first resolution", () => {
|
||||
function makeAccount(token: string, config = {}) {
|
||||
return {
|
||||
accountId: "default",
|
||||
botToken: token,
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id" });
|
||||
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
});
|
||||
|
||||
it("resolves unprefixed 26-char id as user and sends via DM channel", async () => {
|
||||
// Unique token + id to avoid cache pollution from other tests
|
||||
const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res = await sendMessageMattermost(userId, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
const params = createMattermostPostParams();
|
||||
expect(params.channelId).toBe("dm-channel-id");
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
expect(res.messageId).toBe("post-id");
|
||||
expect(res.receipt.primaryPlatformMessageId).toBe("post-id");
|
||||
expect(res.receipt.platformMessageIds).toEqual(["post-id"]);
|
||||
});
|
||||
|
||||
it("falls back to channel id when user lookup returns 404", async () => {
|
||||
// Unique token + id for this test
|
||||
const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2"));
|
||||
const err = new Error("Mattermost API 404: user not found");
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
|
||||
|
||||
const res = await sendMessageMattermost(channelId, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
const params = createMattermostPostParams();
|
||||
expect(params.channelId).toBe(channelId);
|
||||
expect(res.channelId).toBe(channelId);
|
||||
});
|
||||
|
||||
it("falls back to channel id without caching negative result on transient error", async () => {
|
||||
// Two unique tokens so each call has its own cache namespace
|
||||
const userId = "cccccc3333333333cccccc3333"; // 26 chars
|
||||
const tokenA = "token-transient-t3a";
|
||||
const tokenB = "token-transient-t3b";
|
||||
const transientErr = new Error("Mattermost API 503: service unavailable");
|
||||
|
||||
// First call: transient error → fall back to channel id, do NOT cache negative
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
|
||||
|
||||
const res1 = await sendMessageMattermost(userId, "first", { cfg: TEST_CFG });
|
||||
expect(res1.channelId).toBe(userId);
|
||||
|
||||
// Second call with a different token (new cache key) → retries user lookup
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" });
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res2 = await sendMessageMattermost(userId, "second", { cfg: TEST_CFG });
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(res2.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit user: prefix", async () => {
|
||||
// Unique token + id — explicit user: prefix bypasses probe, goes straight to DM
|
||||
const userId = "dddddd4444444444dddddd4444"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
|
||||
const res = await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("observes cache-miss DM resolution but not cached sends", async () => {
|
||||
const userId = "iiiiii9999999999iiiiii9999"; // 26 chars
|
||||
const onDmChannelResolution = vi.fn();
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-dm-observer-t9"));
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "first", {
|
||||
cfg: TEST_CFG,
|
||||
onDmChannelResolution,
|
||||
});
|
||||
await sendMessageMattermost(`user:${userId}`, "second", {
|
||||
cfg: TEST_CFG,
|
||||
onDmChannelResolution,
|
||||
});
|
||||
|
||||
expect(onDmChannelResolution).toHaveBeenCalledTimes(1);
|
||||
expect(onDmChannelResolution).toHaveBeenCalledWith(expect.any(Promise));
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit channel: prefix", async () => {
|
||||
// Unique token + id — explicit channel: prefix, no probe, no DM
|
||||
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
|
||||
|
||||
const res = await sendMessageMattermost(`channel:${chanId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
const params = createMattermostPostParams();
|
||||
expect(params.channelId).toBe(chanId);
|
||||
expect(res.channelId).toBe(chanId);
|
||||
});
|
||||
|
||||
it("passes dmRetryOptions from opts to createMattermostDirectChannelWithRetry", async () => {
|
||||
const userId = "ffffff6666666666ffffff6666"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-retry-opts-t6"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const retryOptions = {
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 5000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
dmRetryOptions: retryOptions,
|
||||
});
|
||||
|
||||
const retryCall = directChannelRetryCall();
|
||||
expect(retryCall?.[0]).toEqual({});
|
||||
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
|
||||
expect(retryCall?.[2]?.maxRetries).toBe(retryOptions.maxRetries);
|
||||
expect(retryCall?.[2]?.initialDelayMs).toBe(retryOptions.initialDelayMs);
|
||||
expect(retryCall?.[2]?.maxDelayMs).toBe(retryOptions.maxDelayMs);
|
||||
expect(retryCall?.[2]?.timeoutMs).toBe(retryOptions.timeoutMs);
|
||||
});
|
||||
|
||||
it("uses dmChannelRetry from account config when opts.dmRetryOptions not provided", async () => {
|
||||
const userId = "gggggg7777777777gggggg7777"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "token-retry-config-t7",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {
|
||||
dmChannelRetry: {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 8000,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
const retryCall = directChannelRetryCall();
|
||||
expect(retryCall?.[0]).toEqual({});
|
||||
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
|
||||
expect(retryCall?.[2]?.maxRetries).toBe(4);
|
||||
expect(retryCall?.[2]?.initialDelayMs).toBe(2000);
|
||||
expect(retryCall?.[2]?.maxDelayMs).toBe(8000);
|
||||
expect(retryCall?.[2]?.timeoutMs).toBe(15000);
|
||||
});
|
||||
|
||||
it("opts.dmRetryOptions overrides provided fields and preserves account defaults", async () => {
|
||||
const userId = "hhhhhh8888888888hhhhhh8888"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "token-retry-override-t8",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {
|
||||
dmChannelRetry: {
|
||||
maxRetries: 2,
|
||||
initialDelayMs: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const overrideOptions = {
|
||||
maxRetries: 7,
|
||||
timeoutMs: 20000,
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
dmRetryOptions: overrideOptions,
|
||||
});
|
||||
|
||||
const retryCall = directChannelRetryCall();
|
||||
expect(retryCall?.[0]).toEqual({});
|
||||
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
|
||||
expect(retryCall?.[2]?.maxRetries).toBe(overrideOptions.maxRetries);
|
||||
expect(retryCall?.[2]?.timeoutMs).toBe(overrideOptions.timeoutMs);
|
||||
expect(retryCall?.[2]?.initialDelayMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
538
extensions/mattermost/src/mattermost/send.ts
Normal file
538
extensions/mattermost/src/mattermost/send.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
// Mattermost plugin module implements send behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannelWithRetry,
|
||||
createMattermostPost,
|
||||
fetchMattermostChannelByName,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
type CreateDmChannelRetryOptions,
|
||||
} from "./client.js";
|
||||
import {
|
||||
buildButtonProps,
|
||||
resolveInteractionCallbackUrl,
|
||||
setInteractionSecret,
|
||||
} from "./interactions.js";
|
||||
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
cfg: OpenClawConfig;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
workspaceDir?: string;
|
||||
/** Fail the send if media cannot be loaded/uploaded instead of posting text-only. */
|
||||
requireMediaUpload?: boolean;
|
||||
replyToId?: string;
|
||||
props?: Record<string, unknown>;
|
||||
buttons?: Array<unknown>;
|
||||
attachmentText?: string;
|
||||
/** Retry options for DM channel creation */
|
||||
dmRetryOptions?: CreateDmChannelRetryOptions;
|
||||
/** Observe the bounded cache-miss DM channel resolution lifecycle. */
|
||||
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
receipt: MessageReceipt;
|
||||
};
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "channel-name"; name: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
const channelByNameCache = new Map<string, string>();
|
||||
const dmChannelCache = new Map<string, string>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
function createMattermostSendReceipt(params: {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
replyToId?: string;
|
||||
}): MessageReceipt {
|
||||
const messageIds =
|
||||
params.messageId.trim() && params.messageId !== "unknown" ? [params.messageId] : [];
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
kind: params.kind,
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
results: messageIds.map((messageId) => ({
|
||||
channel: "mattermost",
|
||||
messageId,
|
||||
channelId: params.channelId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMattermostReceiptKind(params: {
|
||||
fileIds?: readonly string[];
|
||||
buttons?: readonly unknown[];
|
||||
props?: Record<string, unknown>;
|
||||
}): MessageReceiptPartKind {
|
||||
if (params.fileIds?.length) {
|
||||
return "media";
|
||||
}
|
||||
if (params.buttons?.length || params.props) {
|
||||
return "card";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
function recordMattermostOutboundActivity(accountId: string): void {
|
||||
try {
|
||||
getCore().channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "Mattermost runtime not initialized") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = normalizeOptionalString(text) ?? "";
|
||||
const media = normalizeOptionalString(mediaUrl);
|
||||
return [trimmed, media].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
export function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Mattermost sends");
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
if (!id) {
|
||||
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 };
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
if (!id) {
|
||||
throw new Error("User id is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
if (!id) {
|
||||
throw new Error("User id is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
if (!username) {
|
||||
throw new Error("Username is required for Mattermost sends");
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
async function resolveBotUser(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
allowPrivateNetwork?: boolean,
|
||||
): Promise<MattermostUser> {
|
||||
const key = cacheKey(baseUrl, token);
|
||||
const cached = botUserCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
|
||||
const user = await fetchMattermostMe(client);
|
||||
botUserCache.set(key, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
username: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, username } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::${normalizeLowercaseStringOrEmpty(username)}`;
|
||||
const cached = userByNameCache.get(key);
|
||||
if (cached?.id) {
|
||||
return cached.id;
|
||||
}
|
||||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken: token,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
const user = await fetchMattermostUserByUsername(client, username);
|
||||
userByNameCache.set(key, user);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function resolveChannelIdByName(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
name: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, name } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::channel::${normalizeLowercaseStringOrEmpty(name)}`;
|
||||
const cached = channelByNameCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken: token,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
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`);
|
||||
}
|
||||
|
||||
type ResolveTargetChannelIdParams = {
|
||||
target: MattermostTarget;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
dmRetryOptions?: CreateDmChannelRetryOptions;
|
||||
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
|
||||
logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void };
|
||||
};
|
||||
|
||||
function mergeDmRetryOptions(
|
||||
base?: CreateDmChannelRetryOptions,
|
||||
override?: CreateDmChannelRetryOptions,
|
||||
): CreateDmChannelRetryOptions | undefined {
|
||||
const merged: CreateDmChannelRetryOptions = {
|
||||
maxRetries: override?.maxRetries ?? base?.maxRetries,
|
||||
initialDelayMs: override?.initialDelayMs ?? base?.initialDelayMs,
|
||||
maxDelayMs: override?.maxDelayMs ?? base?.maxDelayMs,
|
||||
timeoutMs: override?.timeoutMs ?? base?.timeoutMs,
|
||||
onRetry: override?.onRetry,
|
||||
};
|
||||
|
||||
if (
|
||||
merged.maxRetries === undefined &&
|
||||
merged.initialDelayMs === undefined &&
|
||||
merged.maxDelayMs === undefined &&
|
||||
merged.timeoutMs === undefined &&
|
||||
merged.onRetry === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Promise<string> {
|
||||
if (params.target.kind === "channel") {
|
||||
return params.target.id;
|
||||
}
|
||||
if (params.target.kind === "channel-name") {
|
||||
return await resolveChannelIdByName({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
name: params.target.name,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
}
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
|
||||
const cachedDm = dmChannelCache.get(dmKey);
|
||||
if (cachedDm) {
|
||||
return cachedDm;
|
||||
}
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token, params.allowPrivateNetwork);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
|
||||
const resolution = createMattermostDirectChannelWithRetry(client, [botUser.id, userId], {
|
||||
...params.dmRetryOptions,
|
||||
onRetry: (attempt, delayMs, error) => {
|
||||
// Call user's onRetry if provided
|
||||
params.dmRetryOptions?.onRetry?.(attempt, delayMs, error);
|
||||
// Log if verbose mode is enabled
|
||||
if (params.logger) {
|
||||
params.logger.warn?.(
|
||||
`DM channel creation retry ${attempt} after ${delayMs}ms: ${error.message}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
params.onDmChannelResolution?.(resolution);
|
||||
const channel = await resolution;
|
||||
dmChannelCache.set(dmKey, channel.id);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
type MattermostSendContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
token: string;
|
||||
baseUrl: string;
|
||||
channelId: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
async function resolveMattermostSendContext(
|
||||
to: string,
|
||||
opts: MattermostSendOpts,
|
||||
): Promise<MattermostSendContext> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
if (!opts?.cfg) {
|
||||
throw new Error(
|
||||
"Mattermost send requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Mattermost send");
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = normalizeOptionalString(opts.botToken) ?? normalizeOptionalString(account.botToken);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedTo = normalizeOptionalString(to) ?? "";
|
||||
const opaqueTarget = await resolveMattermostOpaqueTarget({
|
||||
input: trimmedTo,
|
||||
token,
|
||||
baseUrl,
|
||||
});
|
||||
const target =
|
||||
opaqueTarget?.kind === "user"
|
||||
? { kind: "user" as const, id: opaqueTarget.id }
|
||||
: opaqueTarget?.kind === "channel"
|
||||
? { kind: "channel" as const, id: opaqueTarget.id }
|
||||
: parseMattermostTarget(trimmedTo);
|
||||
// Build retry options from account config, allowing opts to override
|
||||
const accountRetryConfig: CreateDmChannelRetryOptions | undefined = account.config.dmChannelRetry
|
||||
? {
|
||||
maxRetries: account.config.dmChannelRetry.maxRetries,
|
||||
initialDelayMs: account.config.dmChannelRetry.initialDelayMs,
|
||||
maxDelayMs: account.config.dmChannelRetry.maxDelayMs,
|
||||
timeoutMs: account.config.dmChannelRetry.timeoutMs,
|
||||
}
|
||||
: undefined;
|
||||
const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions);
|
||||
|
||||
const allowPrivateNetwork = isPrivateNetworkOptInEnabled(account.config);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
token,
|
||||
allowPrivateNetwork,
|
||||
dmRetryOptions,
|
||||
onDmChannelResolution: opts.onDmChannelResolution,
|
||||
logger: core.logging.shouldLogVerbose() ? logger : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
baseUrl,
|
||||
channelId,
|
||||
allowPrivateNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts,
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const { cfg, accountId, token, baseUrl, channelId, allowPrivateNetwork } =
|
||||
await resolveMattermostSendContext(to, opts);
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
|
||||
let props = opts.props;
|
||||
if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) {
|
||||
setInteractionSecret(accountId, token);
|
||||
props = buildButtonProps({
|
||||
callbackUrl: resolveInteractionCallbackUrl(accountId, {
|
||||
gateway: cfg.gateway,
|
||||
interactions: resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
}).config?.interactions,
|
||||
}),
|
||||
accountId,
|
||||
channelId,
|
||||
buttons: opts.buttons,
|
||||
text: opts.attachmentText,
|
||||
});
|
||||
}
|
||||
let message = normalizeOptionalString(text) ?? "";
|
||||
let fileIds: string[] | undefined;
|
||||
let uploadError: Error | undefined;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const media = await loadOutboundMediaFromUrl(mediaUrl, {
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
mediaReadFile: opts.mediaReadFile,
|
||||
workspaceDir: opts.workspaceDir,
|
||||
});
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (opts.requireMediaUpload) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId,
|
||||
});
|
||||
message = convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`, {
|
||||
cause: uploadError,
|
||||
});
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
props,
|
||||
});
|
||||
|
||||
recordMattermostOutboundActivity(accountId);
|
||||
const messageId = post.id ?? "unknown";
|
||||
|
||||
return {
|
||||
messageId,
|
||||
channelId,
|
||||
receipt: createMattermostSendReceipt({
|
||||
messageId,
|
||||
channelId,
|
||||
kind: resolveMattermostReceiptKind({
|
||||
fileIds,
|
||||
buttons: opts.buttons,
|
||||
props,
|
||||
}),
|
||||
replyToId: opts.replyToId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
247
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
247
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Mattermost tests cover slash commands plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
MATTERMOST_SLASH_POST_METHOD,
|
||||
parseSlashCommandPayload,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveCommandText,
|
||||
resolveSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
describe("slash-commands", () => {
|
||||
async function registerSingleStatusCommand(
|
||||
requestImpl: (path: string, init?: RequestInit) => Promise<unknown>,
|
||||
) {
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "bot-token",
|
||||
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
|
||||
fetchImpl: vi.fn<typeof fetch>(),
|
||||
};
|
||||
return registerSlashCommands({
|
||||
client,
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "http://gateway/callback",
|
||||
commands: [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
description: "status",
|
||||
autoComplete: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("parses application/x-www-form-urlencoded payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
expect(payload).toEqual({
|
||||
token: "t1",
|
||||
team_id: "team",
|
||||
team_domain: undefined,
|
||||
channel_id: "ch1",
|
||||
channel_name: undefined,
|
||||
user_id: "u1",
|
||||
user_name: undefined,
|
||||
command: "/oc_status",
|
||||
text: "now",
|
||||
trigger_id: undefined,
|
||||
response_url: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses application/json payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({
|
||||
token: "t2",
|
||||
team_id: "team",
|
||||
channel_id: "ch2",
|
||||
user_id: "u2",
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
}),
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(payload).toEqual({
|
||||
token: "t2",
|
||||
team_id: "team",
|
||||
team_domain: undefined,
|
||||
channel_id: "ch2",
|
||||
channel_name: undefined,
|
||||
user_id: "u2",
|
||||
user_name: undefined,
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
trigger_id: undefined,
|
||||
response_url: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed payloads missing required fields", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({ token: "t3", command: "/oc_help" }),
|
||||
"application/json",
|
||||
);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves command text with trigger map fallback", () => {
|
||||
const triggerMap = new Map<string, string>([["oc_status", "status"]]);
|
||||
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
|
||||
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
|
||||
expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai");
|
||||
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
|
||||
});
|
||||
|
||||
it("registers both public model slash commands", () => {
|
||||
expect(
|
||||
DEFAULT_COMMAND_SPECS.filter(
|
||||
(spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models",
|
||||
).map((spec) => spec.trigger),
|
||||
).toEqual(["oc_model", "oc_models"]);
|
||||
});
|
||||
|
||||
it("registers the queue command mapped to the core /queue directive", () => {
|
||||
const queueSpec = DEFAULT_COMMAND_SPECS.find((spec) => spec.trigger === "oc_queue");
|
||||
expect(queueSpec?.originalName).toBe("queue");
|
||||
const triggerMap = new Map<string, string>([["oc_queue", "queue"]]);
|
||||
expect(resolveCommandText("oc_queue", " collect drop:summarize ", triggerMap)).toBe(
|
||||
"/queue collect drop:summarize",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes callback path in slash config", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
|
||||
expect(config.callbackPath).toBe("/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("falls back to localhost callback URL for wildcard bind hosts", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "/api/channels/mattermost/command" });
|
||||
const callbackUrl = resolveCallbackUrl({
|
||||
config,
|
||||
gatewayPort: 18789,
|
||||
gatewayHost: "0.0.0.0",
|
||||
});
|
||||
expect(callbackUrl).toBe("http://localhost:18789/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("reuses existing command when trigger already points to callback URL", async () => {
|
||||
const request = vi.fn(async (path: string) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "tok-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const result = await registerSingleStatusCommand(request);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const firstCommand = result[0];
|
||||
if (!firstCommand) {
|
||||
throw new Error("expected Mattermost slash command result");
|
||||
}
|
||||
expect(firstCommand.managed).toBe(false);
|
||||
expect(firstCommand.id).toBe("cmd-1");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
|
||||
const request = vi.fn(async (path: string, init?: { method?: string }) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-foreign-1",
|
||||
token: "tok-foreign-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "another-bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://foreign/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (init?.method === "POST" || init?.method === "PUT" || init?.method === "DELETE") {
|
||||
throw new Error("should not mutate foreign commands");
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const result = await registerSingleStatusCommand(request);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates owned commands when callback method drifts from POST", async () => {
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "tok-old",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "G",
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (path === "/commands/cmd-1" && init?.method === "PUT") {
|
||||
expect(JSON.parse(typeof init.body === "string" ? init.body : "{}")).toEqual({
|
||||
id: "cmd-1",
|
||||
team_id: "team-1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "http://gateway/callback",
|
||||
description: "status",
|
||||
auto_complete: true,
|
||||
auto_complete_desc: "status",
|
||||
auto_complete_hint: undefined,
|
||||
});
|
||||
return {
|
||||
id: "cmd-1",
|
||||
token: "tok-updated",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const result = await registerSingleStatusCommand(request);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "cmd-1",
|
||||
trigger: "oc_status",
|
||||
teamId: "team-1",
|
||||
token: "tok-updated",
|
||||
url: "http://gateway/callback",
|
||||
managed: false,
|
||||
},
|
||||
]);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
598
extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
598
extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
// Mattermost plugin module implements slash commands behavior.
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MATTERMOST_SLASH_POST_METHOD = "P";
|
||||
|
||||
export type MattermostSlashCommandConfig = {
|
||||
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
|
||||
native: boolean | "auto";
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: boolean | "auto";
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: string;
|
||||
/**
|
||||
* Explicit callback URL override (e.g. behind a reverse proxy).
|
||||
* If not set, auto-derived from baseUrl + gateway port + callbackPath.
|
||||
*/
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
export type MattermostCommandSpec = {
|
||||
trigger: string;
|
||||
description: string;
|
||||
autoComplete: boolean;
|
||||
autoCompleteHint?: string;
|
||||
/** Original command name (for skill commands that start with oc_) */
|
||||
originalName?: string;
|
||||
};
|
||||
|
||||
export type MattermostRegisteredCommand = {
|
||||
id: string;
|
||||
trigger: string;
|
||||
teamId: string;
|
||||
token: string;
|
||||
url: string;
|
||||
/** True when this process created the command and should delete it on shutdown. */
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload sent by Mattermost when a slash command is invoked.
|
||||
* Can arrive as application/x-www-form-urlencoded or application/json.
|
||||
*/
|
||||
export type MattermostSlashCommandPayload = {
|
||||
token: string;
|
||||
team_id: string;
|
||||
team_domain?: string;
|
||||
channel_id: string;
|
||||
channel_name?: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
command: string; // e.g. "/status"
|
||||
text: string; // args after the trigger word
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response format for Mattermost slash command callbacks.
|
||||
*/
|
||||
export type MattermostSlashCommandResponse = {
|
||||
response_type?: "ephemeral" | "in_channel";
|
||||
text: string;
|
||||
username?: string;
|
||||
icon_url?: string;
|
||||
goto_location?: string;
|
||||
attachments?: unknown[];
|
||||
};
|
||||
|
||||
// ─── MM API types ────────────────────────────────────────────────────────────
|
||||
|
||||
type MattermostCommandCreate = {
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: typeof MATTERMOST_SLASH_POST_METHOD | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
token?: string;
|
||||
creator_id?: string;
|
||||
};
|
||||
|
||||
type MattermostCommandUpdate = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: typeof MATTERMOST_SLASH_POST_METHOD | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
};
|
||||
|
||||
export type MattermostCommandResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: string;
|
||||
url: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
creator_id?: string;
|
||||
create_at?: number;
|
||||
update_at?: number;
|
||||
delete_at?: number;
|
||||
};
|
||||
|
||||
// ─── Default commands ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Built-in OpenClaw commands to register as native slash commands.
|
||||
* These mirror the text-based commands already handled by the gateway.
|
||||
*/
|
||||
export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
originalName: "status",
|
||||
description: "Show session status (model, usage, uptime)",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_model",
|
||||
originalName: "model",
|
||||
description: "View or change the current model",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[model-name] [--runtime runtime]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_models",
|
||||
originalName: "models",
|
||||
description: "Browse available models",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[provider]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_new",
|
||||
originalName: "new",
|
||||
description: "Start a new conversation session",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_help",
|
||||
originalName: "help",
|
||||
description: "Show available commands",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_think",
|
||||
originalName: "think",
|
||||
description: "Set thinking/reasoning level",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[off|low|medium|high]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_reasoning",
|
||||
originalName: "reasoning",
|
||||
description: "Toggle reasoning mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_verbose",
|
||||
originalName: "verbose",
|
||||
description: "Toggle verbose mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_queue",
|
||||
originalName: "queue",
|
||||
description: "Adjust active-run queue behavior",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Command registration ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List existing custom slash commands for a team.
|
||||
*/
|
||||
export async function listMattermostCommands(
|
||||
client: MattermostClient,
|
||||
teamId: string,
|
||||
init?: Pick<RequestInit, "signal">,
|
||||
): Promise<MattermostCommandResponse[]> {
|
||||
return await client.request<MattermostCommandResponse[]>(
|
||||
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
|
||||
init,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a custom slash command by id.
|
||||
*/
|
||||
export async function getMattermostCommand(
|
||||
client: MattermostClient,
|
||||
commandId: string,
|
||||
init?: Pick<RequestInit, "signal">,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>(
|
||||
`/commands/${encodeURIComponent(commandId)}`,
|
||||
init,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom slash command on a Mattermost team.
|
||||
*/
|
||||
async function createMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandCreate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>("/commands", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom slash command.
|
||||
*/
|
||||
async function deleteMattermostCommand(client: MattermostClient, commandId: string): Promise<void> {
|
||||
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom slash command.
|
||||
*/
|
||||
async function updateMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandUpdate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>(
|
||||
`/commands/${encodeURIComponent(params.id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all OpenClaw slash commands for a given team.
|
||||
* Skips commands that are already registered with the same trigger + callback URL.
|
||||
* Returns the list of newly created command IDs.
|
||||
*/
|
||||
export async function registerSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
teamId: string;
|
||||
creatorUserId: string;
|
||||
callbackUrl: string;
|
||||
commands: MattermostCommandSpec[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<MattermostRegisteredCommand[]> {
|
||||
const { client, teamId, creatorUserId, callbackUrl, commands, log } = params;
|
||||
const normalizedCreatorUserId = creatorUserId.trim();
|
||||
if (!normalizedCreatorUserId) {
|
||||
throw new Error("creatorUserId is required for slash command reconciliation");
|
||||
}
|
||||
|
||||
// Fetch existing commands to avoid duplicates
|
||||
let existing: MattermostCommandResponse[];
|
||||
try {
|
||||
existing = await listMattermostCommands(client, teamId);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to list existing commands: ${String(err)}`);
|
||||
// Fail closed: if we can't list existing commands, we should not attempt to
|
||||
// create/update anything because we may create duplicates and end up with an
|
||||
// empty/partial token set (causing callbacks to be rejected until restart).
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existingByTrigger = new Map<string, MattermostCommandResponse[]>();
|
||||
for (const cmd of existing) {
|
||||
const list = existingByTrigger.get(cmd.trigger) ?? [];
|
||||
list.push(cmd);
|
||||
existingByTrigger.set(cmd.trigger, list);
|
||||
}
|
||||
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
|
||||
for (const spec of commands) {
|
||||
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
|
||||
const ownedCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
|
||||
);
|
||||
const foreignCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId,
|
||||
);
|
||||
|
||||
if (ownedCommands.length === 0 && foreignCommands.length > 0) {
|
||||
log?.(
|
||||
`mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownedCommands.length > 1) {
|
||||
log?.(
|
||||
`mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingCmd = ownedCommands[0];
|
||||
|
||||
const existingNeedsUpdate = existingCmd
|
||||
? existingCmd.url !== callbackUrl || existingCmd.method !== MATTERMOST_SLASH_POST_METHOD
|
||||
: false;
|
||||
|
||||
// Already registered with the correct callback URL and method.
|
||||
if (existingCmd && !existingNeedsUpdate) {
|
||||
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
|
||||
registered.push({
|
||||
id: existingCmd.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: existingCmd.token,
|
||||
url: callbackUrl,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exists but has drifted critical callback fields: attempt to reconcile by
|
||||
// updating (useful during callback URL migrations or method drift).
|
||||
if (existingCmd && existingNeedsUpdate) {
|
||||
log?.(
|
||||
`mattermost: command /${spec.trigger} exists with different callback settings; updating (id=${existingCmd.id})`,
|
||||
);
|
||||
try {
|
||||
const updated = await updateMattermostCommand(client, {
|
||||
id: existingCmd.id,
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
registered.push({
|
||||
id: updated.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: updated.token,
|
||||
url: callbackUrl,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
} catch (err) {
|
||||
log?.(
|
||||
`mattermost: failed to update command /${spec.trigger} (id=${existingCmd.id}): ${String(err)}`,
|
||||
);
|
||||
// Fallback: try delete+recreate for commands owned by this bot user.
|
||||
try {
|
||||
await deleteMattermostCommand(client, existingCmd.id);
|
||||
log?.(`mattermost: deleted stale command /${spec.trigger} (id=${existingCmd.id})`);
|
||||
} catch (deleteErr) {
|
||||
log?.(
|
||||
`mattermost: failed to delete stale command /${spec.trigger} (id=${existingCmd.id}): ${String(deleteErr)}`,
|
||||
);
|
||||
// Can't reconcile; skip this command.
|
||||
continue;
|
||||
}
|
||||
// Continue on to create below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createMattermostCommand(client, {
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);
|
||||
registered.push({
|
||||
id: created.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: created.token,
|
||||
url: callbackUrl,
|
||||
managed: true,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to register command /${spec.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all registered slash commands.
|
||||
*/
|
||||
export async function cleanupSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
commands: MattermostRegisteredCommand[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<void> {
|
||||
const { client, commands, log } = params;
|
||||
for (const cmd of commands) {
|
||||
if (!cmd.managed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await deleteMattermostCommand(client, cmd.id);
|
||||
log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Callback parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Mattermost slash command callback payload from a URL-encoded or JSON body.
|
||||
*/
|
||||
export function parseSlashCommandPayload(
|
||||
body: string,
|
||||
contentType?: string,
|
||||
): MattermostSlashCommandPayload | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (contentType?.includes("application/json")) {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
|
||||
// Validate required fields (same checks as the form-encoded branch)
|
||||
const token = typeof parsed.token === "string" ? parsed.token : "";
|
||||
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "";
|
||||
const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : "";
|
||||
const userId = typeof parsed.user_id === "string" ? parsed.user_id : "";
|
||||
const command = typeof parsed.command === "string" ? parsed.command : "";
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined,
|
||||
user_id: userId,
|
||||
user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined,
|
||||
command,
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined,
|
||||
response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: application/x-www-form-urlencoded
|
||||
const params = new URLSearchParams(body);
|
||||
const token = params.get("token");
|
||||
const teamId = params.get("team_id");
|
||||
const channelId = params.get("channel_id");
|
||||
const userId = params.get("user_id");
|
||||
const command = params.get("command");
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: params.get("team_domain") ?? undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: params.get("channel_name") ?? undefined,
|
||||
user_id: userId,
|
||||
user_name: params.get("user_name") ?? undefined,
|
||||
command,
|
||||
text: params.get("text") ?? "",
|
||||
trigger_id: params.get("trigger_id") ?? undefined,
|
||||
response_url: params.get("response_url") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the trigger word back to the original OpenClaw command name.
|
||||
* e.g. "oc_status" -> "/status", "oc_model" -> "/model"
|
||||
*/
|
||||
export function resolveCommandText(
|
||||
trigger: string,
|
||||
text: string,
|
||||
triggerMap?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
// Use the trigger map if available for accurate name resolution
|
||||
const commandName =
|
||||
triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger);
|
||||
const args = text.trim();
|
||||
return args ? `/${commandName} ${args}` : `/${commandName}`;
|
||||
}
|
||||
|
||||
export function normalizeSlashCommandTrigger(command: string): string {
|
||||
return command.replace(/^\//, "").trim();
|
||||
}
|
||||
|
||||
// ─── Config resolution ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
/**
|
||||
* Ensure the callback path starts with a leading `/` to prevent
|
||||
* malformed URLs like `http://host:portapi/...`.
|
||||
*/
|
||||
function normalizeCallbackPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function resolveSlashCommandConfig(
|
||||
raw?: Partial<MattermostSlashCommandConfig>,
|
||||
): MattermostSlashCommandConfig {
|
||||
return {
|
||||
native: raw?.native ?? "auto",
|
||||
nativeSkills: raw?.nativeSkills ?? "auto",
|
||||
callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH),
|
||||
callbackUrl: normalizeOptionalString(raw?.callbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): boolean {
|
||||
if (config.native === true) {
|
||||
return true;
|
||||
}
|
||||
if (config.native === false) {
|
||||
return false;
|
||||
}
|
||||
// "auto" defaults to false for mattermost (opt-in)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the callback URL that Mattermost will POST to when a command is invoked.
|
||||
*/
|
||||
export function resolveCallbackUrl(params: {
|
||||
config: MattermostSlashCommandConfig;
|
||||
gatewayPort: number;
|
||||
gatewayHost?: string;
|
||||
}): string {
|
||||
if (params.config.callbackUrl) {
|
||||
return params.config.callbackUrl;
|
||||
}
|
||||
|
||||
const isWildcardBindHost = (rawHost: string): boolean => {
|
||||
const trimmed = rawHost.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
|
||||
// NOTE: Wildcard listen hosts are valid bind addresses but are not routable callback
|
||||
// destinations. Don't emit callback URLs like http://0.0.0.0:3015/... or http://[::]:3015/...
|
||||
// when an operator sets gateway.customBindHost.
|
||||
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||
};
|
||||
|
||||
let host =
|
||||
params.gatewayHost && !isWildcardBindHost(params.gatewayHost)
|
||||
? params.gatewayHost
|
||||
: "localhost";
|
||||
const path = normalizeCallbackPath(params.config.callbackPath);
|
||||
|
||||
// Bracket IPv6 literals so the URL is valid: http://[::1]:3015/...
|
||||
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
return `http://${host}:${params.gatewayPort}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Mattermost tests cover slash http.send config plugin behavior.
|
||||
import { ServerResponse, type IncomingMessage } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
readRequestBodyWithLimit: vi.fn(async () => "token=valid-token"),
|
||||
parseSlashCommandPayload: vi.fn(() => ({
|
||||
token: "valid-token",
|
||||
command: "/oc_models",
|
||||
text: "models",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
team_id: "team-1",
|
||||
})),
|
||||
resolveCommandText: vi.fn((_trigger: string, text: string) => text),
|
||||
buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })),
|
||||
resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })),
|
||||
authorizeMattermostCommandInvocation: vi.fn(() => ({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
channelInfo: { id: "chan-1", type: "O", name: "town-square", display_name: "Town Square" },
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "town-square",
|
||||
channelDisplay: "Town Square",
|
||||
roomLabel: "#town-square",
|
||||
})),
|
||||
createMattermostClient: vi.fn(() => ({})),
|
||||
fetchMattermostChannel: vi.fn(async () => ({
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "town-square",
|
||||
display_name: "Town Square",
|
||||
})),
|
||||
sendMessageMattermost: vi.fn(async () => ({ messageId: "post-1", channelId: "chan-1" })),
|
||||
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
|
||||
getMattermostCommand: vi.fn(async () => ({
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "team-1",
|
||||
trigger: "oc_models",
|
||||
method: "P",
|
||||
url: "https://gateway.example.com/slash",
|
||||
delete_at: 0,
|
||||
})),
|
||||
listMattermostCommands: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => {
|
||||
return {
|
||||
buildModelsProviderData: mockState.buildModelsProviderData,
|
||||
createChannelMessageReplyPipeline: vi.fn(() => ({
|
||||
onModelSelected: vi.fn(),
|
||||
typingCallbacks: {},
|
||||
})),
|
||||
createDedupeCache: vi.fn(() => ({
|
||||
check: () => false,
|
||||
})),
|
||||
createReplyPrefixOptions: vi.fn(() => ({})),
|
||||
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
logTypingFailure: vi.fn(),
|
||||
formatInboundFromLabel: vi.fn(() => ""),
|
||||
rawDataToString: vi.fn((value: unknown) => (typeof value === "string" ? value : "")),
|
||||
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
|
||||
resolveThreadSessionKeys: vi.fn((params: { baseSessionKey: string }) => ({
|
||||
sessionKey: params.baseSessionKey,
|
||||
parentSessionKey: undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMattermostRuntime: () => ({
|
||||
channel: {
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => true,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "mattermost:session:1",
|
||||
accountId: "default",
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||
return {
|
||||
...actual,
|
||||
createMattermostClient: mockState.createMattermostClient,
|
||||
fetchMattermostChannel: mockState.fetchMattermostChannel,
|
||||
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
|
||||
sendMattermostTyping: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./model-picker.js", () => ({
|
||||
renderMattermostModelSummaryView: vi.fn(),
|
||||
renderMattermostModelsPickerView: vi.fn(),
|
||||
renderMattermostProviderPickerView: vi.fn(),
|
||||
resolveMattermostModelPickerCurrentModel: vi.fn(),
|
||||
resolveMattermostModelPickerEntry: mockState.resolveMattermostModelPickerEntry,
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-auth.js", () => ({
|
||||
authorizeMattermostCommandInvocation: mockState.authorizeMattermostCommandInvocation,
|
||||
normalizeMattermostAllowList: mockState.normalizeMattermostAllowList,
|
||||
}));
|
||||
|
||||
vi.mock("./reply-delivery.js", () => ({
|
||||
deliverMattermostReplyPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageMattermost: mockState.sendMessageMattermost,
|
||||
}));
|
||||
|
||||
vi.mock("./slash-commands.js", () => ({
|
||||
MATTERMOST_SLASH_POST_METHOD: "P",
|
||||
getMattermostCommand: mockState.getMattermostCommand,
|
||||
listMattermostCommands: mockState.listMattermostCommands,
|
||||
normalizeSlashCommandTrigger: (command: string) => command.replace(/^\//, "").trim(),
|
||||
parseSlashCommandPayload: mockState.parseSlashCommandPayload,
|
||||
resolveCommandText: mockState.resolveCommandText,
|
||||
}));
|
||||
|
||||
let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler;
|
||||
const callbackUrlFixture = "https://gateway.example.com/slash";
|
||||
|
||||
function createRequest(body = "token=valid-token"): IncomingMessage {
|
||||
const req = new PassThrough();
|
||||
const incoming = req as PassThrough & IncomingMessage;
|
||||
incoming.method = "POST";
|
||||
incoming.url = "/slash";
|
||||
incoming.headers = {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
};
|
||||
process.nextTick(() => {
|
||||
req.end(body);
|
||||
});
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
getBody: () => string;
|
||||
} {
|
||||
let body = "";
|
||||
class TestServerResponse extends ServerResponse {
|
||||
override setHeader() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override end(): this;
|
||||
override end(cb: () => void): this;
|
||||
override end(chunk: string | Buffer | Uint8Array, cb?: () => void): this;
|
||||
override end(
|
||||
chunk: string | Buffer | Uint8Array,
|
||||
encoding: BufferEncoding,
|
||||
cb?: () => void,
|
||||
): this;
|
||||
override end(
|
||||
chunkOrCb?: string | Buffer | Uint8Array | (() => void),
|
||||
encodingOrCb?: BufferEncoding | (() => void),
|
||||
cb?: () => void,
|
||||
): this {
|
||||
const chunk = typeof chunkOrCb === "function" ? undefined : chunkOrCb;
|
||||
const callback =
|
||||
typeof chunkOrCb === "function"
|
||||
? chunkOrCb
|
||||
: typeof encodingOrCb === "function"
|
||||
? encodingOrCb
|
||||
: cb;
|
||||
body = chunk ? String(chunk) : "";
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const res = new TestServerResponse(createRequest(""));
|
||||
return {
|
||||
res,
|
||||
getBody: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
streamingMode: "partial",
|
||||
config: {},
|
||||
};
|
||||
|
||||
describe("slash-http cfg threading", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockState.readRequestBodyWithLimit.mockClear();
|
||||
mockState.parseSlashCommandPayload.mockClear();
|
||||
mockState.resolveCommandText.mockClear();
|
||||
mockState.buildModelsProviderData.mockClear();
|
||||
mockState.resolveMattermostModelPickerEntry.mockClear();
|
||||
mockState.authorizeMattermostCommandInvocation.mockClear();
|
||||
mockState.createMattermostClient.mockClear();
|
||||
mockState.fetchMattermostChannel.mockClear();
|
||||
mockState.sendMessageMattermost.mockClear();
|
||||
mockState.normalizeMattermostAllowList.mockClear();
|
||||
mockState.getMattermostCommand.mockClear();
|
||||
mockState.listMattermostCommands.mockClear();
|
||||
({ createSlashCommandHttpHandler } = await import("./slash-http.js"));
|
||||
});
|
||||
|
||||
it("passes cfg through the no-models slash reply send path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "exec:secret-ref",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [
|
||||
{
|
||||
id: "cmd-1",
|
||||
teamId: "team-1",
|
||||
trigger: "oc_models",
|
||||
token: "valid-token",
|
||||
url: callbackUrlFixture,
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest(), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.getBody()).toContain("Processing");
|
||||
expect(mockState.sendMessageMattermost).toHaveBeenCalledWith(
|
||||
"channel:chan-1",
|
||||
"No models available.",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a callback when Mattermost reports a different current command token", async () => {
|
||||
mockState.parseSlashCommandPayload.mockReturnValueOnce({
|
||||
token: "old-token",
|
||||
command: "/oc_models",
|
||||
text: "models",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
team_id: "team-1",
|
||||
});
|
||||
mockState.getMattermostCommand.mockResolvedValueOnce({
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "team-1",
|
||||
trigger: "oc_models",
|
||||
method: "P",
|
||||
url: callbackUrlFixture,
|
||||
delete_at: 0,
|
||||
});
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [
|
||||
{
|
||||
id: "cmd-1",
|
||||
teamId: "team-1",
|
||||
trigger: "oc_models",
|
||||
token: "old-token",
|
||||
url: callbackUrlFixture,
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest("token=old-token"), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
|
||||
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unknown tokens before calling Mattermost", async () => {
|
||||
mockState.parseSlashCommandPayload.mockReturnValueOnce({
|
||||
token: "unknown-token",
|
||||
command: "/oc_models",
|
||||
text: "models",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
team_id: "team-1",
|
||||
});
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [
|
||||
{
|
||||
id: "cmd-1",
|
||||
teamId: "team-1",
|
||||
trigger: "oc_models",
|
||||
token: "valid-token",
|
||||
url: callbackUrlFixture,
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest("token=unknown-token"), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
|
||||
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
|
||||
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a refreshed callback token before Mattermost lookup until local state updates", async () => {
|
||||
mockState.parseSlashCommandPayload.mockReturnValueOnce({
|
||||
token: "new-token",
|
||||
command: "/oc_models",
|
||||
text: "models",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
team_id: "team-1",
|
||||
});
|
||||
mockState.getMattermostCommand.mockResolvedValueOnce({
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "team-1",
|
||||
trigger: "oc_models",
|
||||
method: "P",
|
||||
url: callbackUrlFixture,
|
||||
delete_at: 0,
|
||||
});
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [
|
||||
{
|
||||
id: "cmd-1",
|
||||
teamId: "team-1",
|
||||
trigger: "oc_models",
|
||||
token: "old-token",
|
||||
url: callbackUrlFixture,
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest("token=new-token"), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
|
||||
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
|
||||
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
926
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
926
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
@@ -0,0 +1,926 @@
|
||||
// Mattermost tests cover slash http plugin behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import {
|
||||
MATTERMOST_SLASH_POST_METHOD,
|
||||
type MattermostCommandResponse,
|
||||
type MattermostRegisteredCommand,
|
||||
} from "./slash-commands.js";
|
||||
import {
|
||||
createSlashCommandHttpHandler,
|
||||
resetMattermostSlashCommandValidationCacheForTests,
|
||||
validateMattermostSlashCommandToken,
|
||||
} from "./slash-http.js";
|
||||
|
||||
function createRequest(params: {
|
||||
method?: string;
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
autoEnd?: boolean;
|
||||
}): IncomingMessage {
|
||||
const req = new PassThrough();
|
||||
const incoming = req as PassThrough & IncomingMessage;
|
||||
incoming.method = params.method ?? "POST";
|
||||
incoming.headers = {
|
||||
"content-type": params.contentType ?? "application/x-www-form-urlencoded",
|
||||
};
|
||||
process.nextTick(() => {
|
||||
if (params.body) {
|
||||
req.write(params.body);
|
||||
}
|
||||
if (params.autoEnd !== false) {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
getBody: () => string;
|
||||
getHeaders: () => Map<string, string>;
|
||||
} {
|
||||
let body = "";
|
||||
const headers = new Map<string, string>();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
body = chunk ? String(chunk) : "";
|
||||
},
|
||||
} as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
getBody: () => body,
|
||||
getHeaders: () => headers,
|
||||
};
|
||||
}
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
streamingMode: "partial",
|
||||
config: {},
|
||||
};
|
||||
|
||||
function createRegisteredCommand(params?: {
|
||||
token?: string;
|
||||
teamId?: string;
|
||||
trigger?: string;
|
||||
url?: string;
|
||||
}): MattermostRegisteredCommand {
|
||||
return {
|
||||
id: "cmd-1",
|
||||
teamId: params?.teamId ?? "t1",
|
||||
trigger: params?.trigger ?? "oc_status",
|
||||
token: params?.token ?? "valid-token",
|
||||
url: params?.url ?? "https://gateway.example.com/slash",
|
||||
managed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createCommandLookupClient(params: {
|
||||
command?: MattermostCommandResponse | null | (() => MattermostCommandResponse | null);
|
||||
commandLookupError?: Error;
|
||||
listLookupError?: Error;
|
||||
listCommands?: MattermostCommandResponse[];
|
||||
}): MattermostClient & { requests: string[] } {
|
||||
const requests: string[] = [];
|
||||
return {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "bot-token",
|
||||
request: async <T>(path: string) => {
|
||||
requests.push(path);
|
||||
if (path === "/commands/cmd-1") {
|
||||
if (params.commandLookupError) {
|
||||
throw params.commandLookupError;
|
||||
}
|
||||
const command = typeof params.command === "function" ? params.command() : params.command;
|
||||
if (command) {
|
||||
return command as T;
|
||||
}
|
||||
throw new Error("not found");
|
||||
}
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
if (params.listLookupError) {
|
||||
throw params.listLookupError;
|
||||
}
|
||||
const command = typeof params.command === "function" ? params.command() : params.command;
|
||||
return (params.listCommands ?? (command ? [command] : [])) as T;
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
},
|
||||
fetchImpl: vi.fn<typeof fetch>(),
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSlashRequest(params: {
|
||||
registeredCommands?: MattermostRegisteredCommand[];
|
||||
body: string;
|
||||
method?: string;
|
||||
}) {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: params.registeredCommands ?? [],
|
||||
});
|
||||
const req = createRequest({ method: params.method, body: params.body });
|
||||
const response = createResponse();
|
||||
await handler(req, response.res);
|
||||
return response;
|
||||
}
|
||||
|
||||
function firstLogMessage(log: ReturnType<typeof vi.fn>): string {
|
||||
const message = log.mock.calls[0]?.[0];
|
||||
return typeof message === "string" ? message : "";
|
||||
}
|
||||
|
||||
describe("slash-http", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostSlashCommandValidationCacheForTests();
|
||||
});
|
||||
|
||||
it("rejects non-POST methods", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [createRegisteredCommand()],
|
||||
});
|
||||
const req = createRequest({ method: "GET", body: "" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(405);
|
||||
expect(response.getBody()).toBe("Method Not Allowed");
|
||||
expect(response.getHeaders().get("allow")).toBe("POST");
|
||||
});
|
||||
|
||||
it("rejects malformed payloads", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [createRegisteredCommand()],
|
||||
});
|
||||
const req = createRequest({ body: "token=abc&command=%2Foc_status" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(400);
|
||||
expect(response.getBody()).toContain("Invalid slash command payload");
|
||||
});
|
||||
|
||||
it("fails closed when no commands are registered", async () => {
|
||||
const response = await runSlashRequest({
|
||||
registeredCommands: [],
|
||||
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
|
||||
});
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("rejects unknown slash commands before upstream validation", async () => {
|
||||
const response = await runSlashRequest({
|
||||
registeredCommands: [createRegisteredCommand({ token: "known-token" })],
|
||||
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_unknown&text=",
|
||||
});
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("rejects a token valid for one command when used against another command", async () => {
|
||||
// Cross-command spray DoS guard: a payload pointing at command B with the
|
||||
// token for command A must fail at the per-command startup gate, before
|
||||
// upstream validation runs and could poison the failure cache for B.
|
||||
const response = await runSlashRequest({
|
||||
registeredCommands: [
|
||||
createRegisteredCommand({ token: "token-status", trigger: "oc_status" }),
|
||||
{
|
||||
id: "cmd-2",
|
||||
teamId: "t1",
|
||||
trigger: "oc_help",
|
||||
token: "token-help",
|
||||
url: "https://gateway.example.com/slash",
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
body: "token=token-status&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_help&text=",
|
||||
});
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("returns 408 when the request body stalls", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
registeredCommands: [createRegisteredCommand()],
|
||||
bodyTimeoutMs: 1,
|
||||
});
|
||||
const req = createRequest({ autoEnd: false });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(408);
|
||||
expect(response.getBody()).toBe("Request body timeout");
|
||||
});
|
||||
|
||||
it("rejects the startup token when Mattermost has rotated the current command token", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ token: "old-token" });
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "old-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(registeredCommand.token).toBe("old-token");
|
||||
});
|
||||
|
||||
it("accepts the startup token while the current Mattermost command still matches", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rate-limits sequential current-command lookups without caching successes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
|
||||
try {
|
||||
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
|
||||
const command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({ command });
|
||||
const payload = {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
const log = vi.fn();
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
log,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
log,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(client.requests).toHaveLength(20);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"mattermost: slash command validation lookup rate-limited for /oc_status",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rechecks matching current commands so startup tokens are not accepted after rotation", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
|
||||
let command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({
|
||||
command: () => command,
|
||||
});
|
||||
const payload = {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
command = {
|
||||
...command,
|
||||
token: "new-token",
|
||||
};
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
|
||||
});
|
||||
|
||||
it("briefly caches failed current command validation without accepting stale tokens", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ token: "old-token" });
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
token: "old-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(client.requests).toEqual(["/commands/cmd-1"]);
|
||||
});
|
||||
|
||||
it("does not cache failed command validation when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const registeredCommand = createRegisteredCommand({ token: "old-token" });
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
token: "old-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops exhausted validation lookup buckets when the current clock is invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
|
||||
try {
|
||||
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
|
||||
const command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({ command });
|
||||
const payload = {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(client.requests).toHaveLength(21);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes validation cache entries by account", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const clientA = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "token-a",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
const clientB = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "token-b",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "a1",
|
||||
client: clientA,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "token-a",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "a2",
|
||||
client: clientB,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "token-b",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(clientA.requests).toEqual(["/commands/cmd-1"]);
|
||||
expect(clientB.requests).toEqual(["/commands/cmd-1"]);
|
||||
});
|
||||
|
||||
it("rejects a command that Mattermost reports as deleted", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 123,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a regenerated command when the current command id changed", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ token: "old-token" });
|
||||
const oldDeletedCommand = {
|
||||
id: "cmd-1",
|
||||
token: "old-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 123,
|
||||
};
|
||||
const newCommand = {
|
||||
id: "cmd-2",
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({
|
||||
command: oldDeletedCommand,
|
||||
listCommands: [oldDeletedCommand, newCommand],
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
|
||||
});
|
||||
|
||||
it("logs when command lookup by id returns a deleted command before fallback", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const command = {
|
||||
id: "cmd-1\r\nspoofed",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 123,
|
||||
};
|
||||
const client = createCommandLookupClient({
|
||||
command,
|
||||
listCommands: [],
|
||||
});
|
||||
const log = vi.fn();
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
log,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
const message = firstLogMessage(log);
|
||||
expect(message).not.toMatch(/[\r\n\t]/u);
|
||||
expect(message).toContain("deleted command cmd-1 spoofed");
|
||||
expect(message).toContain("using team list fallback");
|
||||
});
|
||||
|
||||
it("rejects current commands with a mismatched method or callback URL", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
|
||||
for (const command of [
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: "G",
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/other",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
]) {
|
||||
resetMattermostSlashCommandValidationCacheForTests();
|
||||
const client = createCommandLookupClient({ command });
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the team command list when command lookup is unavailable", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({
|
||||
commandLookupError: new Error("not implemented"),
|
||||
listCommands: [command],
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
|
||||
});
|
||||
|
||||
it("logs sanitized command lookup failures when falling back to the team command list", async () => {
|
||||
const registeredCommand = createRegisteredCommand({ trigger: "oc_status\r\nspoofed" });
|
||||
const command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status\r\nspoofed",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({
|
||||
commandLookupError: new Error(
|
||||
"primary\ntoken=secret-token https://user:pass@chat.example.com/api?access_token=secret-access&client_secret=secret-client",
|
||||
),
|
||||
listCommands: [command],
|
||||
});
|
||||
const log = vi.fn();
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
log,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
const message = firstLogMessage(log);
|
||||
expect(message).not.toMatch(/[\r\n\t]/u);
|
||||
expect(message).toContain("/oc_status spoofed");
|
||||
expect(message).toContain("primary token=[redacted]");
|
||||
expect(message).toContain("https://redacted:redacted@chat.example.com/api");
|
||||
expect(message).not.toContain("secret-token");
|
||||
expect(message).not.toContain("secret-access");
|
||||
expect(message).not.toContain("secret-client");
|
||||
expect(message).not.toContain("user:pass");
|
||||
});
|
||||
|
||||
it("sanitizes upstream lookup errors before logging fallback failures", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const client = createCommandLookupClient({
|
||||
commandLookupError: new Error('primary\ntoken=secret-token refresh_token="secret-refresh"'),
|
||||
listLookupError: new Error(
|
||||
"fallback\r\nsecond-line botToken: secret-bot https://user:pass@chat.example.com/hooks?token=secret-query",
|
||||
),
|
||||
});
|
||||
const log = vi.fn();
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload: {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
},
|
||||
log,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
const message = firstLogMessage(log);
|
||||
expect(message).not.toMatch(/[\r\n\t]/u);
|
||||
expect(message).toContain("fallback second-line");
|
||||
expect(message).toContain("botToken: [redacted]");
|
||||
expect(message).toContain("https://redacted:redacted@chat.example.com/hooks");
|
||||
expect(message).toContain("primary token=[redacted]");
|
||||
expect(message).not.toContain("secret-token");
|
||||
expect(message).not.toContain("secret-refresh");
|
||||
expect(message).not.toContain("secret-bot");
|
||||
expect(message).not.toContain("secret-query");
|
||||
expect(message).not.toContain("user:pass");
|
||||
});
|
||||
});
|
||||
947
extensions/mattermost/src/mattermost/slash-http.ts
Normal file
947
extensions/mattermost/src/mattermost/slash-http.ts
Normal file
@@ -0,0 +1,947 @@
|
||||
/**
|
||||
* HTTP callback handler for Mattermost slash commands.
|
||||
*
|
||||
* Receives POST requests from Mattermost when a slash command is invoked,
|
||||
* validates the token, and routes the command through the standard inbound pipeline.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
} from "./client.js";
|
||||
import {
|
||||
renderMattermostModelSummaryView,
|
||||
renderMattermostModelsPickerView,
|
||||
renderMattermostProviderPickerView,
|
||||
resolveMattermostModelPickerCurrentModel,
|
||||
resolveMattermostModelPickerEntry,
|
||||
} from "./model-picker.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
normalizeMattermostAllowList,
|
||||
} from "./monitor-auth.js";
|
||||
import {
|
||||
createMattermostReplyDeliveryBarrier,
|
||||
deliverMattermostReplyPayload,
|
||||
} from "./reply-delivery.js";
|
||||
import {
|
||||
buildModelsProviderData,
|
||||
createChannelMessageReplyPipeline,
|
||||
isRequestBodyLimitError,
|
||||
logTypingFailure,
|
||||
readRequestBodyWithLimit,
|
||||
type OpenClawConfig,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import {
|
||||
MATTERMOST_SLASH_POST_METHOD,
|
||||
getMattermostCommand,
|
||||
listMattermostCommands,
|
||||
normalizeSlashCommandTrigger,
|
||||
parseSlashCommandPayload,
|
||||
resolveCommandText,
|
||||
type MattermostRegisteredCommand,
|
||||
type MattermostCommandResponse,
|
||||
type MattermostSlashCommandResponse,
|
||||
type MattermostSlashCommandPayload,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
type SlashHttpHandlerParams = {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
/** Commands registered or reconciled during monitor startup. */
|
||||
registeredCommands: readonly MattermostRegisteredCommand[];
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap?: ReadonlyMap<string, string>;
|
||||
log?: (msg: string) => void;
|
||||
bodyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const BODY_READ_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_LOOKUP_TIMEOUT_MS = 1_000;
|
||||
const COMMAND_VALIDATION_FAILURE_CACHE_MS = 5_000;
|
||||
const COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS = 2_000;
|
||||
const COMMAND_VALIDATION_LOOKUP_BURST = 20;
|
||||
const COMMAND_VALIDATION_LOOKUP_REFILL_MS = 500;
|
||||
const COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS = 5_000;
|
||||
const COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS = 2_000;
|
||||
type CommandLookupInflightEntry = {
|
||||
accountId: string;
|
||||
promise: Promise<MattermostCommandResponse | null>;
|
||||
};
|
||||
type CommandValidationRateLimitEntry = {
|
||||
accountId: string;
|
||||
tokens: number;
|
||||
updatedAt: number;
|
||||
lastLimitedLogAt: number;
|
||||
};
|
||||
const commandLookupInflight = new Map<string, CommandLookupInflightEntry>();
|
||||
const commandValidationFailureCache = new Map<string, { accountId: string; expiresAt: number }>();
|
||||
const commandValidationLookupRateLimit = new Map<string, CommandValidationRateLimitEntry>();
|
||||
const SECRET_LOG_KEYS = new Set([
|
||||
"access_token",
|
||||
"authorization",
|
||||
"bottoken",
|
||||
"client_secret",
|
||||
"refresh_token",
|
||||
"token",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Read the full request body as a string.
|
||||
*/
|
||||
function readBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
timeoutMs = BODY_READ_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function sendJsonResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
body: MattermostSlashCommandResponse,
|
||||
) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function findRegisteredCommandForPayload(params: {
|
||||
registeredCommands: readonly MattermostRegisteredCommand[];
|
||||
payload: MattermostSlashCommandPayload;
|
||||
}): MattermostRegisteredCommand | undefined {
|
||||
const trigger = normalizeSlashCommandTrigger(params.payload.command);
|
||||
return params.registeredCommands.find(
|
||||
(cmd) => cmd.teamId === params.payload.team_id && cmd.trigger === trigger,
|
||||
);
|
||||
}
|
||||
|
||||
function isDeletedMattermostCommand(command: { delete_at?: number }): boolean {
|
||||
return typeof command.delete_at === "number" && command.delete_at > 0;
|
||||
}
|
||||
|
||||
function sanitizeCommandLookupError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
return raw
|
||||
.replace(/[\r\n\t]/gu, " ")
|
||||
.replace(/https?:\/\/[^\s)\]}]+/giu, (urlText) => {
|
||||
try {
|
||||
const url = new URL(urlText);
|
||||
if (url.username || url.password) {
|
||||
url.username = "redacted";
|
||||
url.password = "redacted";
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (SECRET_LOG_KEYS.has(key.toLowerCase())) {
|
||||
url.searchParams.set(key, "redacted");
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return urlText;
|
||||
}
|
||||
})
|
||||
.replace(/(^|[^\w-])(Bearer|Token)\s+[A-Za-z0-9._~+/=-]+/giu, "$1$2 [redacted]")
|
||||
.replace(
|
||||
/\b(token|authorization|access_token|refresh_token|client_secret|botToken)\b(\s*["']?\s*(?:=|:)\s*["']?)[^"',\s;}]+/giu,
|
||||
"$1$2[redacted]",
|
||||
)
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
function sanitizeMattermostLogValue(value: string): string {
|
||||
return value.replace(/[\r\n\t]/gu, " ").slice(0, 200);
|
||||
}
|
||||
|
||||
async function withCommandLookupTimeout<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), COMMAND_LOOKUP_TIMEOUT_MS);
|
||||
try {
|
||||
return await task(controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function commandLookupKey(
|
||||
client: ReturnType<typeof createMattermostClient>,
|
||||
registered: MattermostRegisteredCommand,
|
||||
accountId: string,
|
||||
): string {
|
||||
return `${client.apiBaseUrl}:${accountId}:${registered.teamId}:${registered.id}`;
|
||||
}
|
||||
|
||||
export function resetMattermostSlashCommandValidationCacheForTests(): void {
|
||||
commandLookupInflight.clear();
|
||||
commandValidationFailureCache.clear();
|
||||
commandValidationLookupRateLimit.clear();
|
||||
}
|
||||
|
||||
export function clearMattermostSlashCommandValidationCacheForAccount(accountId: string): void {
|
||||
for (const [key, entry] of commandValidationFailureCache) {
|
||||
if (entry.accountId === accountId) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, entry] of commandLookupInflight) {
|
||||
if (entry.accountId === accountId) {
|
||||
commandLookupInflight.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, entry] of commandValidationLookupRateLimit) {
|
||||
if (entry.accountId === accountId) {
|
||||
commandValidationLookupRateLimit.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sweepCommandValidationFailureCache(now = Date.now()): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
commandValidationFailureCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of commandValidationFailureCache) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
}
|
||||
}
|
||||
while (commandValidationFailureCache.size > COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS) {
|
||||
const oldestKey = commandValidationFailureCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
commandValidationFailureCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
|
||||
sweepCommandValidationFailureCache(now);
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
return false;
|
||||
}
|
||||
const cached = commandValidationFailureCache.get(key);
|
||||
if (!cached) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (expiresAt !== undefined && expiresAt > validNow) {
|
||||
return true;
|
||||
}
|
||||
commandValidationFailureCache.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
function cacheCommandValidationFailure(key: string, accountId: string): void {
|
||||
const now = Date.now();
|
||||
sweepCommandValidationFailureCache(now);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt === undefined) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
return;
|
||||
}
|
||||
commandValidationFailureCache.set(key, {
|
||||
accountId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
commandValidationLookupRateLimit.clear();
|
||||
return;
|
||||
}
|
||||
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
|
||||
for (const [key, entry] of commandValidationLookupRateLimit) {
|
||||
const updatedAt = asDateTimestampMs(entry.updatedAt);
|
||||
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
|
||||
commandValidationLookupRateLimit.delete(key);
|
||||
}
|
||||
}
|
||||
while (commandValidationLookupRateLimit.size > COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS) {
|
||||
const oldestKey = commandValidationLookupRateLimit.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
commandValidationLookupRateLimit.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function reserveCommandValidationLookup(params: {
|
||||
key: string;
|
||||
accountId: string;
|
||||
now?: number;
|
||||
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
|
||||
const rawNow = params.now ?? Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
commandValidationLookupRateLimit.clear();
|
||||
return { allowed: true };
|
||||
}
|
||||
sweepCommandValidationLookupRateLimit(now);
|
||||
const existing = commandValidationLookupRateLimit.get(params.key);
|
||||
if (!existing) {
|
||||
commandValidationLookupRateLimit.set(params.key, {
|
||||
accountId: params.accountId,
|
||||
tokens: COMMAND_VALIDATION_LOOKUP_BURST - 1,
|
||||
updatedAt: now,
|
||||
lastLimitedLogAt: 0,
|
||||
});
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const refill = Math.floor((now - existing.updatedAt) / COMMAND_VALIDATION_LOOKUP_REFILL_MS);
|
||||
if (refill > 0) {
|
||||
existing.tokens = Math.min(COMMAND_VALIDATION_LOOKUP_BURST, existing.tokens + refill);
|
||||
existing.updatedAt += refill * COMMAND_VALIDATION_LOOKUP_REFILL_MS;
|
||||
}
|
||||
if (existing.tokens <= 0) {
|
||||
const shouldLog = now - existing.lastLimitedLogAt >= COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS;
|
||||
if (shouldLog) {
|
||||
existing.lastLimitedLogAt = now;
|
||||
}
|
||||
return { allowed: false, shouldLog };
|
||||
}
|
||||
existing.tokens -= 1;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
async function fetchCurrentMattermostCommandUncached(params: {
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
registered: MattermostRegisteredCommand;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<MattermostCommandResponse | null> {
|
||||
let commandLookupResult: MattermostCommandResponse | null = null;
|
||||
let commandLookupError: unknown;
|
||||
let commandLookupFallbackDetail: string | undefined;
|
||||
try {
|
||||
commandLookupResult = await withCommandLookupTimeout((signal) =>
|
||||
getMattermostCommand(params.client, params.registered.id, { signal }),
|
||||
);
|
||||
if (!isDeletedMattermostCommand(commandLookupResult)) {
|
||||
return commandLookupResult;
|
||||
}
|
||||
commandLookupFallbackDetail = `command lookup by id returned deleted command ${sanitizeMattermostLogValue(commandLookupResult.id)}`;
|
||||
} catch (err) {
|
||||
commandLookupError = err;
|
||||
// Older Mattermost servers may not expose GET /commands/{id}; fall back to
|
||||
// the team command list, which registration already requires.
|
||||
}
|
||||
|
||||
try {
|
||||
const currentCommands = await withCommandLookupTimeout((signal) =>
|
||||
listMattermostCommands(params.client, params.registered.teamId, { signal }),
|
||||
);
|
||||
if (commandLookupError) {
|
||||
params.log?.(
|
||||
`mattermost: slash command lookup by id failed for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback: ${sanitizeCommandLookupError(commandLookupError)}`,
|
||||
);
|
||||
} else if (commandLookupFallbackDetail) {
|
||||
params.log?.(
|
||||
`mattermost: slash ${commandLookupFallbackDetail} for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback`,
|
||||
);
|
||||
}
|
||||
return currentCommands.find((cmd) => cmd.id === params.registered.id) ?? commandLookupResult;
|
||||
} catch (err) {
|
||||
const primaryDetail = commandLookupError
|
||||
? `; command lookup: ${sanitizeCommandLookupError(commandLookupError)}`
|
||||
: commandLookupFallbackDetail
|
||||
? `; command lookup: ${commandLookupFallbackDetail}`
|
||||
: "";
|
||||
params.log?.(
|
||||
`mattermost: slash command registration check failed for /${sanitizeMattermostLogValue(params.registered.trigger)}: ${sanitizeCommandLookupError(err)}${primaryDetail}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentMattermostCommand(params: {
|
||||
accountId: string;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
registered: MattermostRegisteredCommand;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<MattermostCommandResponse | null> {
|
||||
const key = commandLookupKey(params.client, params.registered, params.accountId);
|
||||
const existing = commandLookupInflight.get(key);
|
||||
if (existing) {
|
||||
return await existing.promise;
|
||||
}
|
||||
|
||||
const lookup = fetchCurrentMattermostCommandUncached(params).finally(() => {
|
||||
commandLookupInflight.delete(key);
|
||||
});
|
||||
commandLookupInflight.set(key, { accountId: params.accountId, promise: lookup });
|
||||
return await lookup;
|
||||
}
|
||||
|
||||
export async function validateMattermostSlashCommandToken(params: {
|
||||
accountId: string;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
registeredCommand: MattermostRegisteredCommand;
|
||||
payload: MattermostSlashCommandPayload;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<boolean> {
|
||||
const lookupKey = commandLookupKey(params.client, params.registeredCommand, params.accountId);
|
||||
if (hasCachedCommandValidationFailure(lookupKey)) {
|
||||
return false;
|
||||
}
|
||||
if (!commandLookupInflight.has(lookupKey)) {
|
||||
const reservation = reserveCommandValidationLookup({
|
||||
key: lookupKey,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!reservation.allowed) {
|
||||
if (reservation.shouldLog) {
|
||||
params.log?.(
|
||||
`mattermost: slash command validation lookup rate-limited for /${sanitizeMattermostLogValue(params.registeredCommand.trigger)}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const current = await fetchCurrentMattermostCommand({
|
||||
accountId: params.accountId,
|
||||
client: params.client,
|
||||
registered: params.registeredCommand,
|
||||
log: params.log,
|
||||
});
|
||||
if (!current || isDeletedMattermostCommand(current)) {
|
||||
cacheCommandValidationFailure(lookupKey, params.accountId);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
current.id !== params.registeredCommand.id ||
|
||||
current.team_id !== params.registeredCommand.teamId ||
|
||||
current.trigger !== params.registeredCommand.trigger ||
|
||||
current.method !== MATTERMOST_SLASH_POST_METHOD ||
|
||||
current.url !== params.registeredCommand.url
|
||||
) {
|
||||
cacheCommandValidationFailure(lookupKey, params.accountId);
|
||||
return false;
|
||||
}
|
||||
if (!current.token || !safeEqualSecret(params.payload.token, current.token)) {
|
||||
cacheCommandValidationFailure(lookupKey, params.accountId);
|
||||
return false;
|
||||
}
|
||||
commandValidationFailureCache.delete(lookupKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
type SlashInvocationAuth = {
|
||||
ok: boolean;
|
||||
denyResponse?: MattermostSlashCommandResponse;
|
||||
commandAuthorized: boolean;
|
||||
channelInfo: MattermostChannel | null;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
async function authorizeSlashInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<SlashInvocationAuth> {
|
||||
const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
// Resolve channel info so we can enforce DM vs group/channel policies.
|
||||
let channelInfo: MattermostChannel | null = null;
|
||||
try {
|
||||
channelInfo = await fetchMattermostChannel(client, channelId);
|
||||
} catch (err) {
|
||||
log?.(
|
||||
`mattermost: slash channel lookup failed for ${sanitizeMattermostLogValue(channelId)}: ${sanitizeCommandLookupError(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!channelInfo) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Temporary error: unable to determine channel type. Please try again.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo: null,
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: `#${channelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await core.channel.pairing
|
||||
.readAllowFromStore({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.catch(() => []),
|
||||
);
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
|
||||
if (!decision.ok) {
|
||||
if (decision.denyReason === "dm-pairing") {
|
||||
const { code } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "mattermost",
|
||||
idLine: `Your Mattermost user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const denyText =
|
||||
decision.denyReason === "unknown-channel"
|
||||
? "Temporary error: unable to determine channel type. Please try again."
|
||||
: decision.denyReason === "dm-disabled"
|
||||
? "This bot is not accepting direct messages."
|
||||
: decision.denyReason === "channels-disabled"
|
||||
? "Slash commands are disabled in channels."
|
||||
: decision.denyReason === "channel-no-allowlist"
|
||||
? "Slash commands are not configured for this channel (no allowlist)."
|
||||
: "Unauthorized.";
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: denyText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the HTTP request handler for Mattermost slash command callbacks.
|
||||
*
|
||||
* This handler is registered as a plugin HTTP route and receives POSTs
|
||||
* from the Mattermost server when a user invokes a registered slash command.
|
||||
*/
|
||||
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
|
||||
const { account, cfg, runtime, registeredCommands, triggerMap, log, bodyTimeoutMs } = params;
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await readBody(req, MAX_BODY_BYTES, bodyTimeoutMs);
|
||||
} catch (error) {
|
||||
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.end("Request body timeout");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 413;
|
||||
res.end("Payload Too Large");
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"] ?? "";
|
||||
const payload = parseSlashCommandPayload(body, contentType);
|
||||
if (!payload) {
|
||||
sendJsonResponse(res, 400, {
|
||||
response_type: "ephemeral",
|
||||
text: "Invalid slash command payload.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const registeredCommand = findRegisteredCommandForPayload({ registeredCommands, payload });
|
||||
|
||||
// Fail closed when no commands are registered, the payload doesn't map to
|
||||
// a registered (team, trigger), or the payload token doesn't equal the
|
||||
// resolved command's startup token. Comparing against the resolved
|
||||
// command's token (rather than any token in the account) prevents a token
|
||||
// valid for command A from advancing to upstream validation for command B,
|
||||
// which would otherwise let an attacker poison the per-command failure
|
||||
// cache and DoS legitimate invocations of command B.
|
||||
if (
|
||||
registeredCommands.length === 0 ||
|
||||
!registeredCommand ||
|
||||
!safeEqualSecret(payload.token, registeredCommand.token)
|
||||
) {
|
||||
sendJsonResponse(res, 401, {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract command info
|
||||
const client = createMattermostClient({
|
||||
baseUrl: account.baseUrl ?? "",
|
||||
botToken: account.botToken ?? "",
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
|
||||
const tokenIsCurrent = await validateMattermostSlashCommandToken({
|
||||
accountId: account.accountId,
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
log,
|
||||
});
|
||||
if (!tokenIsCurrent) {
|
||||
sendJsonResponse(res, 401, {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract command info
|
||||
const trigger = normalizeSlashCommandTrigger(payload.command);
|
||||
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
|
||||
const channelId = payload.channel_id;
|
||||
const senderId = payload.user_id;
|
||||
const senderName = payload.user_name ?? senderId;
|
||||
|
||||
const auth = await authorizeSlashInvocation({
|
||||
account,
|
||||
cfg,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
log,
|
||||
});
|
||||
|
||||
if (!auth.ok) {
|
||||
sendJsonResponse(
|
||||
res,
|
||||
200,
|
||||
auth.denyResponse ?? { response_type: "ephemeral", text: "Unauthorized." },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(
|
||||
`mattermost: slash command /${sanitizeMattermostLogValue(trigger)} from ${sanitizeMattermostLogValue(senderName)} in ${sanitizeMattermostLogValue(channelId)}`,
|
||||
);
|
||||
|
||||
// Acknowledge immediately — we'll send the actual reply asynchronously
|
||||
sendJsonResponse(res, 200, {
|
||||
response_type: "ephemeral",
|
||||
text: "Processing...",
|
||||
});
|
||||
|
||||
// Now handle the command asynchronously (post reply as a message)
|
||||
try {
|
||||
await handleSlashCommandAsync({
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId: payload.team_id,
|
||||
triggerId: payload.trigger_id,
|
||||
kind: auth.kind,
|
||||
chatType: auth.chatType,
|
||||
channelName: auth.channelName,
|
||||
channelDisplay: auth.channelDisplay,
|
||||
roomLabel: auth.roomLabel,
|
||||
commandAuthorized: auth.commandAuthorized,
|
||||
log,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: slash command handler error: ${sanitizeCommandLookupError(err)}`);
|
||||
try {
|
||||
const to = `channel:${channelId}`;
|
||||
await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch {
|
||||
// best-effort error reply
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSlashCommandAsync(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
teamId: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
commandAuthorized: boolean;
|
||||
triggerId?: string;
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId,
|
||||
kind,
|
||||
chatType,
|
||||
channelName: _channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
commandAuthorized,
|
||||
triggerId,
|
||||
log,
|
||||
} = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "direct" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel =
|
||||
kind === "direct"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
|
||||
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const pickerEntry = resolveMattermostModelPickerEntry(commandText);
|
||||
if (pickerEntry) {
|
||||
const data = await buildModelsProviderData(cfg, route.agentId);
|
||||
if (data.providers.length === 0) {
|
||||
await sendMessageMattermost(to, "No models available.", {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route,
|
||||
data,
|
||||
});
|
||||
const view =
|
||||
pickerEntry.kind === "summary"
|
||||
? renderMattermostModelSummaryView({
|
||||
ownerUserId: senderId,
|
||||
currentModel,
|
||||
})
|
||||
: pickerEntry.kind === "providers"
|
||||
? renderMattermostProviderPickerView({
|
||||
ownerUserId: senderId,
|
||||
data,
|
||||
currentModel,
|
||||
})
|
||||
: renderMattermostModelsPickerView({
|
||||
ownerUserId: senderId,
|
||||
data,
|
||||
provider: pickerEntry.provider,
|
||||
page: 1,
|
||||
currentModel,
|
||||
});
|
||||
|
||||
await sendMessageMattermost(to, view.text, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
buttons: view.buttons,
|
||||
});
|
||||
runtime.log?.(`delivered model picker to ${to}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build inbound context — the command text is the body
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: commandText,
|
||||
BodyForAgent: commandText,
|
||||
RawBody: commandText,
|
||||
CommandBody: commandText,
|
||||
From:
|
||||
kind === "direct"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: triggerId ?? `slash-${Date.now()}`,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
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, typingCallbacks, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
typing: {
|
||||
start: () => sendMattermostTyping(client, { channelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log?.(message),
|
||||
channel: "mattermost",
|
||||
target: channelId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
|
||||
isDirect: kind === "direct",
|
||||
dmRetryOptions: account.config.dmChannelRetry,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload,
|
||||
to,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
textLimit,
|
||||
tableMode,
|
||||
sendMessage: sendMessageMattermost,
|
||||
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
||||
});
|
||||
runtime.log?.(`delivered slash reply to ${to}`);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
|
||||
);
|
||||
},
|
||||
onReplyStart: typingCallbacks?.onReplyStart,
|
||||
});
|
||||
|
||||
await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
188
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal file
188
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Mattermost tests cover slash state plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostRegisteredCommand } from "./slash-commands.js";
|
||||
import {
|
||||
activateSlashCommands,
|
||||
deactivateSlashCommands,
|
||||
resolveSlashHandlerForCommand,
|
||||
resolveSlashHandlerForToken,
|
||||
} from "./slash-state.js";
|
||||
|
||||
function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount {
|
||||
return {
|
||||
accountId,
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
streamingMode: "partial",
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createRegisteredCommand(params?: {
|
||||
id?: string;
|
||||
teamId?: string;
|
||||
trigger?: string;
|
||||
}): MattermostRegisteredCommand {
|
||||
return {
|
||||
id: params?.id ?? "cmd-1",
|
||||
teamId: params?.teamId ?? "team-1",
|
||||
trigger: params?.trigger ?? "oc_status",
|
||||
token: "token-1",
|
||||
url: "https://gateway.example.com/slash",
|
||||
managed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const slashApi = {
|
||||
cfg: {},
|
||||
runtime: {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: () => {},
|
||||
},
|
||||
} satisfies {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
|
||||
|
||||
describe("slash-state global singleton", () => {
|
||||
afterEach(() => {
|
||||
deactivateSlashCommands();
|
||||
});
|
||||
|
||||
it("anchors accountStates on globalThis", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-a"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const map = globalStore[ACCOUNT_STATES_KEY];
|
||||
expect(map).toBeInstanceOf(Map);
|
||||
expect((map as Map<string, unknown>).has("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves slash state across module reloads", async () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-reload"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("./slash-state.js");
|
||||
const match = reloaded.resolveSlashHandlerForToken("tok-reload");
|
||||
|
||||
expect(match.kind).toBe("single");
|
||||
if (match.kind !== "single") {
|
||||
throw new Error("expected single match after module reload");
|
||||
}
|
||||
expect(match.accountIds).toEqual(["a1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slash-state token routing", () => {
|
||||
it("returns single match when token belongs to one account", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-a"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-a");
|
||||
expect(match.kind).toBe("single");
|
||||
if (match.kind !== "single") {
|
||||
throw new Error("expected single match");
|
||||
}
|
||||
expect(match.source).toBe("token");
|
||||
expect(match.accountIds).toEqual(["a1"]);
|
||||
expect(typeof match.handler).toBe("function");
|
||||
});
|
||||
|
||||
it("returns ambiguous when same token exists in multiple accounts", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a2"),
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-shared");
|
||||
expect(match.kind).toBe("ambiguous");
|
||||
if (match.kind !== "ambiguous") {
|
||||
throw new Error("expected ambiguous match");
|
||||
}
|
||||
expect(match.source).toBe("token");
|
||||
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
|
||||
});
|
||||
|
||||
it("routes by registered team and command when token lookup misses", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["old-token"],
|
||||
registeredCommands: [createRegisteredCommand()],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForCommand({
|
||||
teamId: "team-1",
|
||||
command: "/oc_status",
|
||||
});
|
||||
|
||||
expect(match.kind).toBe("single");
|
||||
if (match.kind !== "single") {
|
||||
throw new Error("expected single match");
|
||||
}
|
||||
expect(match.source).toBe("command");
|
||||
expect(match.accountIds).toEqual(["a1"]);
|
||||
expect(typeof match.handler).toBe("function");
|
||||
});
|
||||
|
||||
it("returns ambiguous when registered team and command match multiple accounts", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-a"],
|
||||
registeredCommands: [createRegisteredCommand({ id: "cmd-a" })],
|
||||
api: slashApi,
|
||||
});
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a2"),
|
||||
commandTokens: ["tok-b"],
|
||||
registeredCommands: [createRegisteredCommand({ id: "cmd-b" })],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForCommand({
|
||||
teamId: "team-1",
|
||||
command: "/oc_status",
|
||||
});
|
||||
|
||||
expect(match.kind).toBe("ambiguous");
|
||||
if (match.kind !== "ambiguous") {
|
||||
throw new Error("expected ambiguous match");
|
||||
}
|
||||
expect(match.source).toBe("command");
|
||||
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
|
||||
});
|
||||
});
|
||||
423
extensions/mattermost/src/mattermost/slash-state.ts
Normal file
423
extensions/mattermost/src/mattermost/slash-state.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Shared state for Mattermost slash commands.
|
||||
*
|
||||
* Bridges the plugin registration phase (HTTP route) with the monitor phase
|
||||
* (command registration with MM API). The HTTP handler needs to know which
|
||||
* tokens are known for fast-path routing, and the monitor needs to store
|
||||
* registered command IDs.
|
||||
*
|
||||
* State is kept per-account so that multi-account deployments don't
|
||||
* overwrite each other's tokens, registered commands, or handlers.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import type { MattermostConfig } from "../types.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
type OpenClawPluginApi,
|
||||
} from "./runtime-api.js";
|
||||
import {
|
||||
normalizeSlashCommandTrigger,
|
||||
parseSlashCommandPayload,
|
||||
resolveSlashCommandConfig,
|
||||
type MattermostRegisteredCommand,
|
||||
} from "./slash-commands.js";
|
||||
import {
|
||||
clearMattermostSlashCommandValidationCacheForAccount,
|
||||
createSlashCommandHttpHandler,
|
||||
} from "./slash-http.js";
|
||||
|
||||
const MULTI_ACCOUNT_BODY_MAX_BYTES = 64 * 1024;
|
||||
const MULTI_ACCOUNT_BODY_TIMEOUT_MS = 5_000;
|
||||
type SlashHandlerMatchSource = "token" | "command";
|
||||
type SlashHandlerMatch =
|
||||
| { kind: "none" }
|
||||
| {
|
||||
kind: "single";
|
||||
source: SlashHandlerMatchSource;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
accountIds: string[];
|
||||
}
|
||||
| {
|
||||
kind: "ambiguous";
|
||||
source: SlashHandlerMatchSource;
|
||||
accountIds: string[];
|
||||
};
|
||||
|
||||
// ─── Per-account state ───────────────────────────────────────────────────────
|
||||
|
||||
type SlashCommandAccountState = {
|
||||
/** Tokens from registered/current commands, used for fast-path routing. */
|
||||
commandTokens: Set<string>;
|
||||
/** Registered command IDs for cleanup on shutdown. */
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
/** Current HTTP handler for this account. */
|
||||
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | null;
|
||||
/** The account that activated slash commands. */
|
||||
account: ResolvedMattermostAccount;
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap: Map<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map from accountId → per-account slash command state.
|
||||
*
|
||||
* Anchored to globalThis so that jiti-loaded (route registration) and
|
||||
* native-ESM-loaded (monitor/activation) module instances share the
|
||||
* same Map. Without this, each module loader creates its own copy of
|
||||
* the module-level variable and the HTTP handler never sees the tokens
|
||||
* populated by the monitor.
|
||||
*/
|
||||
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
|
||||
|
||||
function getSlashAccountStates(): Map<string, SlashCommandAccountState> {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = globalStore[ACCOUNT_STATES_KEY];
|
||||
if (existing instanceof Map) {
|
||||
return existing as Map<string, SlashCommandAccountState>;
|
||||
}
|
||||
const accountStates = new Map<string, SlashCommandAccountState>();
|
||||
globalStore[ACCOUNT_STATES_KEY] = accountStates;
|
||||
return accountStates;
|
||||
}
|
||||
|
||||
const accountStates = getSlashAccountStates();
|
||||
|
||||
export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch {
|
||||
const matches: Array<{
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
}> = [];
|
||||
|
||||
for (const [accountId, state] of accountStates) {
|
||||
if (state.commandTokens.has(token) && state.handler) {
|
||||
matches.push({ accountId, handler: state.handler });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return {
|
||||
kind: "single",
|
||||
source: "token",
|
||||
handler: matches[0].handler,
|
||||
accountIds: [matches[0].accountId],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
source: "token",
|
||||
accountIds: matches.map((entry) => entry.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlashHandlerForCommand(params: {
|
||||
teamId: string;
|
||||
command: string;
|
||||
}): SlashHandlerMatch {
|
||||
const trigger = normalizeSlashCommandTrigger(params.command);
|
||||
if (!trigger) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
const matches: Array<{
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
}> = [];
|
||||
|
||||
for (const [accountId, state] of accountStates) {
|
||||
if (
|
||||
state.handler &&
|
||||
state.registeredCommands.some(
|
||||
(cmd) => cmd.teamId === params.teamId && cmd.trigger === trigger,
|
||||
)
|
||||
) {
|
||||
matches.push({ accountId, handler: state.handler });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return {
|
||||
kind: "single",
|
||||
source: "command",
|
||||
handler: matches[0].handler,
|
||||
accountIds: [matches[0].accountId],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
source: "command",
|
||||
accountIds: matches.map((entry) => entry.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slash command state for a specific account, or null if not activated.
|
||||
*/
|
||||
export function getSlashCommandState(accountId: string): SlashCommandAccountState | null {
|
||||
return accountStates.get(accountId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate slash commands for a specific account.
|
||||
* Called from the monitor after bot connects.
|
||||
*/
|
||||
export function activateSlashCommands(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
commandTokens: string[];
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
triggerMap?: Map<string, string>;
|
||||
api: {
|
||||
cfg: import("./runtime-api.js").OpenClawConfig;
|
||||
runtime: import("./runtime-api.js").RuntimeEnv;
|
||||
};
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const { account, commandTokens, registeredCommands, triggerMap, api, log } = params;
|
||||
const accountId = account.accountId;
|
||||
|
||||
const tokenSet = new Set(commandTokens);
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account,
|
||||
cfg: api.cfg,
|
||||
runtime: api.runtime,
|
||||
registeredCommands,
|
||||
triggerMap,
|
||||
log,
|
||||
});
|
||||
|
||||
accountStates.set(accountId, {
|
||||
commandTokens: tokenSet,
|
||||
registeredCommands,
|
||||
handler,
|
||||
account,
|
||||
triggerMap: triggerMap ?? new Map(),
|
||||
});
|
||||
|
||||
log?.(
|
||||
`mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate slash commands for a specific account (on shutdown/disconnect).
|
||||
*/
|
||||
export function deactivateSlashCommands(accountId?: string) {
|
||||
if (accountId) {
|
||||
const state = accountStates.get(accountId);
|
||||
if (state) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
clearMattermostSlashCommandValidationCacheForAccount(accountId);
|
||||
accountStates.delete(accountId);
|
||||
}
|
||||
} else {
|
||||
// Deactivate all accounts (full shutdown)
|
||||
for (const [stateAccountId, state] of accountStates) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
clearMattermostSlashCommandValidationCacheForAccount(stateAccountId);
|
||||
}
|
||||
accountStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the HTTP route for slash command callbacks.
|
||||
* Called during plugin registration.
|
||||
*
|
||||
* The single HTTP route dispatches to the correct per-account handler by
|
||||
* matching the inbound token against each account's known tokens, falling back
|
||||
* to registered team/trigger ownership so upstream validation can accept a
|
||||
* rotated Mattermost token.
|
||||
*/
|
||||
export function registerSlashCommandRoute(api: OpenClawPluginApi) {
|
||||
const mmConfig = api.config.channels?.mattermost as MattermostConfig | undefined;
|
||||
|
||||
// Collect callback paths from both top-level and per-account config.
|
||||
// Command registration uses account.config.commands, so the HTTP route
|
||||
// registration must include any account-specific callbackPath overrides.
|
||||
// Also extract the pathname from an explicit callbackUrl when it differs
|
||||
// from callbackPath, so that Mattermost callbacks hit a registered route.
|
||||
const callbackPaths = new Set<string>();
|
||||
|
||||
const addCallbackPaths = (
|
||||
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | undefined,
|
||||
) => {
|
||||
const resolved = resolveSlashCommandConfig(raw);
|
||||
callbackPaths.add(resolved.callbackPath);
|
||||
if (resolved.callbackUrl) {
|
||||
try {
|
||||
const urlPath = new URL(resolved.callbackUrl).pathname;
|
||||
if (urlPath && urlPath !== resolved.callbackPath) {
|
||||
callbackPaths.add(urlPath);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — ignore, will be caught during registration
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const commandsRaw = mmConfig?.commands as
|
||||
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
addCallbackPaths(commandsRaw);
|
||||
|
||||
const accountsRaw = mmConfig?.accounts ?? {};
|
||||
for (const accountId of Object.keys(accountsRaw)) {
|
||||
const accountCommandsRaw = accountsRaw[accountId]?.commands;
|
||||
addCallbackPaths(accountCommandsRaw);
|
||||
}
|
||||
|
||||
const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (accountStates.size === 0) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to peek at the body to route to the right account handler. Each
|
||||
// account handler still performs upstream token validation before running a
|
||||
// command.
|
||||
|
||||
// If there's only one active account (common case), route directly.
|
||||
if (accountStates.size === 1) {
|
||||
const [, state] = [...accountStates.entries()][0];
|
||||
if (!state.handler) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await state.handler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-account: buffer the body, find the matching account by token or
|
||||
// registered team/trigger, then replay the request to the correct handler.
|
||||
// Use the bounded helper so a slow/never-finishing client cannot tie up the
|
||||
// routing handler indefinitely (Slowloris).
|
||||
let bodyStr: string;
|
||||
try {
|
||||
bodyStr = await readRequestBodyWithLimit(req, {
|
||||
maxBytes: MULTI_ACCOUNT_BODY_MAX_BYTES,
|
||||
timeoutMs: MULTI_ACCOUNT_BODY_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.end("Request body timeout");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 413;
|
||||
res.end("Payload Too Large");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the token for the fast path; if it misses, parse the full slash
|
||||
// payload so rotated tokens can still route by registered team/trigger.
|
||||
let token: string | null = null;
|
||||
const ct = req.headers["content-type"] ?? "";
|
||||
try {
|
||||
if (ct.includes("application/json")) {
|
||||
token = (JSON.parse(bodyStr) as { token?: string }).token ?? null;
|
||||
} else {
|
||||
token = new URLSearchParams(bodyStr).get("token");
|
||||
}
|
||||
} catch {
|
||||
// parse failed — will be caught by handler
|
||||
}
|
||||
|
||||
let match: SlashHandlerMatch = token ? resolveSlashHandlerForToken(token) : { kind: "none" };
|
||||
if (match.kind === "none") {
|
||||
const payload = parseSlashCommandPayload(bodyStr, ct);
|
||||
if (payload) {
|
||||
match = resolveSlashHandlerForCommand({
|
||||
teamId: payload.team_id,
|
||||
command: payload.command,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (match.kind === "none") {
|
||||
// No matching account — reject
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.kind === "ambiguous") {
|
||||
api.logger.warn?.(
|
||||
`mattermost: slash callback matched multiple accounts via ${match.source} (${match.accountIds.join(", ")})`,
|
||||
);
|
||||
const conflictText =
|
||||
match.source === "token"
|
||||
? "Conflict: command token is not unique across accounts."
|
||||
: "Conflict: slash command is not unique across accounts.";
|
||||
res.statusCode = 409;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: conflictText,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedHandler = match.handler;
|
||||
|
||||
// Replay: create a synthetic readable that re-emits the buffered body
|
||||
const syntheticReq = new Readable({
|
||||
read() {
|
||||
this.push(Buffer.from(bodyStr, "utf8"));
|
||||
this.push(null);
|
||||
},
|
||||
}) as IncomingMessage;
|
||||
|
||||
// Copy necessary IncomingMessage properties
|
||||
syntheticReq.method = req.method;
|
||||
syntheticReq.url = req.url;
|
||||
syntheticReq.headers = req.headers;
|
||||
|
||||
await matchedHandler(syntheticReq, res);
|
||||
};
|
||||
|
||||
for (const callbackPath of callbackPaths) {
|
||||
api.registerHttpRoute({
|
||||
path: callbackPath,
|
||||
auth: "plugin",
|
||||
handler: routeHandler,
|
||||
});
|
||||
}
|
||||
}
|
||||
129
extensions/mattermost/src/mattermost/target-resolution.test.ts
Normal file
129
extensions/mattermost/src/mattermost/target-resolution.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Mattermost tests cover target resolution plugin behavior.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveMattermostAccount = vi.fn();
|
||||
const createMattermostClient = vi.fn();
|
||||
const fetchMattermostUser = vi.fn();
|
||||
const normalizeMattermostBaseUrl = vi.fn((value: string | undefined) => value?.trim());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveMattermostAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMattermostClient,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
}));
|
||||
|
||||
describe("mattermost target resolution", () => {
|
||||
let isExplicitMattermostTarget: typeof import("./target-resolution.js").isExplicitMattermostTarget;
|
||||
let isMattermostId: typeof import("./target-resolution.js").isMattermostId;
|
||||
let parseMattermostApiStatus: typeof import("./target-resolution.js").parseMattermostApiStatus;
|
||||
let resolveMattermostOpaqueTarget: typeof import("./target-resolution.js").resolveMattermostOpaqueTarget;
|
||||
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
isExplicitMattermostTarget,
|
||||
isMattermostId,
|
||||
parseMattermostApiStatus,
|
||||
resolveMattermostOpaqueTarget,
|
||||
resetMattermostOpaqueTargetCacheForTests,
|
||||
} = await import("./target-resolution.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resolveMattermostAccount.mockReset();
|
||||
createMattermostClient.mockReset();
|
||||
fetchMattermostUser.mockReset();
|
||||
normalizeMattermostBaseUrl.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetMattermostOpaqueTargetCacheForTests();
|
||||
});
|
||||
|
||||
it("recognizes explicit targets and ID-shaped values", () => {
|
||||
expect(isExplicitMattermostTarget("@alice")).toBe(true);
|
||||
expect(isExplicitMattermostTarget("#town-square")).toBe(true);
|
||||
expect(isExplicitMattermostTarget("mattermost:chan")).toBe(true);
|
||||
expect(isExplicitMattermostTarget(" plain ")).toBe(false);
|
||||
expect(isMattermostId("abcd1234abcd1234abcd1234ab")).toBe(true);
|
||||
expect(isMattermostId("short")).toBe(false);
|
||||
expect(parseMattermostApiStatus(new Error("Mattermost API 404 Not Found"))).toBe(404);
|
||||
expect(parseMattermostApiStatus(new Error("other error"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves opaque ids as users and caches the result", async () => {
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "abcd1234abcd1234abcd1234ab" });
|
||||
const input = "abcd1234abcd1234abcd1234ab";
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "user",
|
||||
id: input,
|
||||
to: `user:${input}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "user",
|
||||
id: input,
|
||||
to: `user:${input}`,
|
||||
});
|
||||
|
||||
expect(createMattermostClient).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to channel targets on 404 lookups", async () => {
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockRejectedValue(new Error("Mattermost API 404 Not Found"));
|
||||
const input = "bcde1234abcd1234abcd1234ab";
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "channel",
|
||||
id: input,
|
||||
to: `channel:${input}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses account resolution when token/base url are not passed", async () => {
|
||||
resolveMattermostAccount.mockReturnValue({
|
||||
baseUrl: "https://mm.example.com",
|
||||
botToken: "token",
|
||||
});
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "cdef1234abcd1234abcd1234ab" });
|
||||
const input = "cdef1234abcd1234abcd1234ab";
|
||||
|
||||
await resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
cfg: { channels: { mattermost: {} } },
|
||||
accountId: "acct-1",
|
||||
});
|
||||
|
||||
expect(resolveMattermostAccount).toHaveBeenCalledWith({
|
||||
cfg: { channels: { mattermost: {} } },
|
||||
accountId: "acct-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
104
extensions/mattermost/src/mattermost/target-resolution.ts
Normal file
104
extensions/mattermost/src/mattermost/target-resolution.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Mattermost plugin module implements target resolution behavior.
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
} from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export type MattermostOpaqueTargetResolution = {
|
||||
kind: "user" | "channel";
|
||||
id: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const mattermostOpaqueTargetCache = new Map<string, boolean>();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string, id: string): string {
|
||||
return `${baseUrl}::${token}::${id}`;
|
||||
}
|
||||
|
||||
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
|
||||
export function isMattermostId(value: string): boolean {
|
||||
return /^[a-z0-9]{26}$/.test(value);
|
||||
}
|
||||
|
||||
export function isExplicitMattermostTarget(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/^(channel|user|mattermost):/i.test(trimmed) ||
|
||||
trimmed.startsWith("@") ||
|
||||
trimmed.startsWith("#")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMattermostApiStatus(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const msg = "message" in err && typeof err.message === "string" ? err.message : "";
|
||||
const match = /Mattermost API (\d{3})\b/.exec(msg);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const code = Number(match[1]);
|
||||
return Number.isFinite(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export async function resolveMattermostOpaqueTarget(params: {
|
||||
input: string;
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
token?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<MattermostOpaqueTargetResolution | null> {
|
||||
const input = params.input.trim();
|
||||
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account =
|
||||
params.cfg && (!params.token || !params.baseUrl)
|
||||
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
|
||||
: null;
|
||||
const token = normalizeOptionalString(params.token) ?? normalizeOptionalString(account?.botToken);
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl ?? account?.baseUrl);
|
||||
if (!token || !baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = cacheKey(baseUrl, token, input);
|
||||
const cached = mattermostOpaqueTargetCache.get(key);
|
||||
if (cached === true) {
|
||||
return { kind: "user", id: input, to: `user:${input}` };
|
||||
}
|
||||
if (cached === false) {
|
||||
return { kind: "channel", id: input, to: `channel:${input}` };
|
||||
}
|
||||
|
||||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken: token,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account?.config),
|
||||
});
|
||||
try {
|
||||
await fetchMattermostUser(client, input);
|
||||
mattermostOpaqueTargetCache.set(key, true);
|
||||
return { kind: "user", id: input, to: `user:${input}` };
|
||||
} catch (err) {
|
||||
if (parseMattermostApiStatus(err) === 404) {
|
||||
mattermostOpaqueTargetCache.set(key, false);
|
||||
}
|
||||
return { kind: "channel", id: input, to: `channel:${input}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMattermostOpaqueTargetCacheForTests(): void {
|
||||
mattermostOpaqueTargetCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Mattermost tests cover thread participation cache plugin behavior.
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setMattermostRuntime } from "../runtime.js";
|
||||
import {
|
||||
clearMattermostThreadParticipationCache,
|
||||
hasMattermostThreadParticipationWithPersistence,
|
||||
recordMattermostThreadParticipation,
|
||||
} from "./thread-participation.js";
|
||||
|
||||
// Drain microtasks + the immediate queue so the fire-and-forget persistent write
|
||||
// in recordMattermostThreadParticipation has settled before we assert on it.
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
function setRuntime(openKeyedStore: (options: OpenKeyedStoreOptions) => unknown): void {
|
||||
setMattermostRuntime({
|
||||
state: { openKeyedStore },
|
||||
logging: { getChildLogger: () => ({ warn() {} }) },
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
function setPersistentRuntime(): void {
|
||||
setRuntime((options) => createPluginStateKeyedStoreForTests("mattermost", options));
|
||||
}
|
||||
|
||||
describe("mattermost thread participation", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
clearMattermostThreadParticipationCache();
|
||||
setPersistentRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMattermostThreadParticipationCache();
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
it("remembers a thread the bot replied in", async () => {
|
||||
recordMattermostThreadParticipation("acct", "chan", "root-1");
|
||||
await expect(
|
||||
hasMattermostThreadParticipationWithPersistence({
|
||||
accountId: "acct",
|
||||
channelId: "chan",
|
||||
threadRootId: "root-1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("isolates participation by account, channel, and thread", async () => {
|
||||
recordMattermostThreadParticipation("acct", "chan", "root-1");
|
||||
await flush();
|
||||
for (const probe of [
|
||||
{ accountId: "other", channelId: "chan", threadRootId: "root-1" },
|
||||
{ accountId: "acct", channelId: "other", threadRootId: "root-1" },
|
||||
{ accountId: "acct", channelId: "chan", threadRootId: "root-2" },
|
||||
]) {
|
||||
await expect(hasMattermostThreadParticipationWithPersistence(probe)).resolves.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores empty identifiers", async () => {
|
||||
recordMattermostThreadParticipation("", "chan", "root-1");
|
||||
await expect(
|
||||
hasMattermostThreadParticipationWithPersistence({
|
||||
accountId: "",
|
||||
channelId: "chan",
|
||||
threadRootId: "root-1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
|
||||
recordMattermostThreadParticipation("acct", "chan", "root-1");
|
||||
await flush();
|
||||
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
|
||||
clearMattermostThreadParticipationCache();
|
||||
await expect(
|
||||
hasMattermostThreadParticipationWithPersistence({
|
||||
accountId: "acct",
|
||||
channelId: "chan",
|
||||
threadRootId: "root-1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("degrades to in-memory only when the persistent store fails", async () => {
|
||||
setRuntime(() => {
|
||||
throw new Error("sqlite unavailable");
|
||||
});
|
||||
// record + read must not throw; the in-memory cache still answers.
|
||||
recordMattermostThreadParticipation("acct", "chan", "root-1");
|
||||
await expect(
|
||||
hasMattermostThreadParticipationWithPersistence({
|
||||
accountId: "acct",
|
||||
channelId: "chan",
|
||||
threadRootId: "root-1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
hasMattermostThreadParticipationWithPersistence({
|
||||
accountId: "acct",
|
||||
channelId: "chan",
|
||||
threadRootId: "missing",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
82
extensions/mattermost/src/mattermost/thread-participation.ts
Normal file
82
extensions/mattermost/src/mattermost/thread-participation.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Mattermost plugin module implements thread participation cache behavior.
|
||||
import { createPersistentDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
|
||||
import { getOptionalMattermostRuntime } from "../runtime.js";
|
||||
|
||||
/**
|
||||
* Cache of Mattermost threads the bot has replied in. Lets the bot auto-respond
|
||||
* to thread follow-ups without a re-mention after its first visible reply.
|
||||
*/
|
||||
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const MAX_ENTRIES = 5000;
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const PERSISTENT_NAMESPACE = "mattermost.thread-participation";
|
||||
|
||||
type MattermostThreadParticipationRecord = {
|
||||
agentId?: string;
|
||||
repliedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep thread participation shared across bundled chunks so thread auto-reply
|
||||
* gating does not diverge between the inbound-gate and reply-dispatch paths.
|
||||
*/
|
||||
const MATTERMOST_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.mattermostThreadParticipation");
|
||||
const threadParticipation = createPersistentDedupeCache<MattermostThreadParticipationRecord>({
|
||||
globalKey: MATTERMOST_THREAD_PARTICIPATION_KEY,
|
||||
ttlMs: TTL_MS,
|
||||
maxSize: MAX_ENTRIES,
|
||||
persistent: {
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
openStore: (options) => getOptionalMattermostRuntime()?.state.openKeyedStore(options),
|
||||
logError: (error) => {
|
||||
try {
|
||||
getOptionalMattermostRuntime()
|
||||
?.logging.getChildLogger({ plugin: "mattermost", feature: "thread-participation-state" })
|
||||
.warn("Mattermost persistent thread participation state failed", {
|
||||
error: String(error),
|
||||
});
|
||||
} catch {
|
||||
// Best effort only: persistent state must never break Mattermost message handling.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function makeKey(accountId: string, channelId: string, threadRootId: string): string {
|
||||
return `${accountId}:${channelId}:${threadRootId}`;
|
||||
}
|
||||
|
||||
export function recordMattermostThreadParticipation(
|
||||
accountId: string,
|
||||
channelId: string,
|
||||
threadRootId: string,
|
||||
opts?: { agentId?: string },
|
||||
): void {
|
||||
if (!accountId || !channelId || !threadRootId) {
|
||||
return;
|
||||
}
|
||||
void threadParticipation.register(makeKey(accountId, channelId, threadRootId), {
|
||||
// Stored for future per-agent thread routing; current reads only need presence.
|
||||
...(opts?.agentId ? { agentId: opts.agentId } : {}),
|
||||
repliedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasMattermostThreadParticipationWithPersistence(params: {
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadRootId: string;
|
||||
}): Promise<boolean> {
|
||||
if (!params.accountId || !params.channelId || !params.threadRootId) {
|
||||
return false;
|
||||
}
|
||||
return await threadParticipation.lookup(
|
||||
makeKey(params.accountId, params.channelId, params.threadRootId),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMattermostThreadParticipationCache(): void {
|
||||
threadParticipation.clearForTest();
|
||||
}
|
||||
97
extensions/mattermost/src/normalize.test.ts
Normal file
97
extensions/mattermost/src/normalize.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Mattermost tests cover normalize plugin behavior.
|
||||
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); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("recognizes DM channel format (26__26)", () => {
|
||||
expect(
|
||||
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret
|
||||
).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); // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
53
extensions/mattermost/src/normalize.ts
Normal file
53
extensions/mattermost/src/normalize.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Mattermost helper module supports normalize behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `@${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
// Strip # prefix and fall through to directory lookup (same as bare names).
|
||||
// The core's resolveMessagingTarget will use the directory adapter to
|
||||
// resolve the channel name to its Mattermost ID.
|
||||
return undefined;
|
||||
}
|
||||
// Bare name without prefix — return undefined to allow directory lookup
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function looksLikeMattermostTargetId(raw: string, _normalized?: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
return true;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
66
extensions/mattermost/src/runtime-api.ts
Normal file
66
extensions/mattermost/src/runtime-api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Legacy map-helper exports in this facade stay for older plugin consumers.
|
||||
// New message-turn code should use createChannelHistoryWindow.
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
type BaseProbeResult,
|
||||
type BlockStreamingCoalesceConfig,
|
||||
buildAgentMediaPayload,
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildModelsProviderData,
|
||||
buildPendingHistoryContextFromMap,
|
||||
type ChannelAccountSnapshot,
|
||||
type ChannelDirectoryEntry,
|
||||
type ChannelGroupContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ChatType,
|
||||
chunkTextForOutbound,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
createChannelHistoryWindow,
|
||||
createAccountStatusSink,
|
||||
createChannelPairingController,
|
||||
createChannelMessageReplyPipeline,
|
||||
createDedupeCache,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type DmPolicy,
|
||||
formatInboundFromLabel,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
type GroupPolicy,
|
||||
type HistoryEntry,
|
||||
isDangerousNameMatchingEnabled,
|
||||
isRequestBodyLimitError,
|
||||
isTrustedProxyAddress,
|
||||
listSkillCommandsForAgents,
|
||||
loadOutboundMediaFromUrl,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
type ModelsProviderData,
|
||||
normalizeAccountId,
|
||||
normalizeProviderId,
|
||||
type OpenClawConfig,
|
||||
type OpenClawPluginApi,
|
||||
parseStrictPositiveInteger,
|
||||
type PluginRuntime,
|
||||
rawDataToString,
|
||||
readRequestBodyWithLimit,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
registerPluginHttpRoute,
|
||||
type ReplyPayload,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveClientIp,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveStoredModelOverride,
|
||||
resolveStorePath,
|
||||
resolveThreadSessionKeys,
|
||||
type RuntimeEnv,
|
||||
setMattermostRuntime,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../runtime-api.js";
|
||||
13
extensions/mattermost/src/runtime.ts
Normal file
13
extensions/mattermost/src/runtime.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Mattermost plugin module implements runtime behavior.
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const {
|
||||
setRuntime: setMattermostRuntime,
|
||||
getRuntime: getMattermostRuntime,
|
||||
tryGetRuntime: getOptionalMattermostRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "mattermost",
|
||||
errorMessage: "Mattermost runtime not initialized",
|
||||
});
|
||||
export { getMattermostRuntime, getOptionalMattermostRuntime, setMattermostRuntime };
|
||||
60
extensions/mattermost/src/secret-contract.ts
Normal file
60
extensions/mattermost/src/secret-contract.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Mattermost plugin module implements secret contract behavior.
|
||||
import {
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
|
||||
{
|
||||
id: "channels.mattermost.accounts.*.botToken",
|
||||
targetType: "channels.mattermost.accounts.*.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.accounts.*.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.mattermost.botToken",
|
||||
targetType: "channels.mattermost.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "mattermost");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: mattermost, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "mattermost",
|
||||
field: "botToken",
|
||||
channel: mattermost,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.",
|
||||
accountInactiveReason: "Mattermost account is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
export const channelSecrets = {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
};
|
||||
8
extensions/mattermost/src/secret-input.ts
Normal file
8
extensions/mattermost/src/secret-input.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Mattermost plugin module implements secret input behavior.
|
||||
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
105
extensions/mattermost/src/session-route.test.ts
Normal file
105
extensions/mattermost/src/session-route.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Mattermost tests cover session route plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
|
||||
|
||||
function expectRoute(route: ReturnType<typeof resolveMattermostOutboundSessionRoute>) {
|
||||
if (!route) {
|
||||
throw new Error("Expected Mattermost route");
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
describe("mattermost session route", () => {
|
||||
it("builds direct-message routes for user targets", () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "@user123",
|
||||
});
|
||||
|
||||
const directRoute = expectRoute(route);
|
||||
expect(directRoute.peer.kind).toBe("direct");
|
||||
expect(directRoute.peer.id).toBe("user123");
|
||||
expect(directRoute.from).toBe("mattermost:user123");
|
||||
expect(directRoute.to).toBe("user:user123");
|
||||
});
|
||||
|
||||
it("builds threaded channel routes for channel targets", () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "mattermost:channel:chan123",
|
||||
threadId: "thread456",
|
||||
});
|
||||
|
||||
const channelRoute = expectRoute(route);
|
||||
expect(channelRoute.peer.kind).toBe("channel");
|
||||
expect(channelRoute.peer.id).toBe("chan123");
|
||||
expect(channelRoute.from).toBe("mattermost:channel:chan123");
|
||||
expect(channelRoute.to).toBe("channel:chan123");
|
||||
expect(channelRoute.threadId).toBe("thread456");
|
||||
expect(channelRoute.sessionKey).toContain("thread456");
|
||||
});
|
||||
|
||||
it("recovers channel thread routes from currentSessionKey", () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "mattermost:channel:chan123",
|
||||
currentSessionKey: "agent:main:mattermost:channel:chan123:thread:root-post",
|
||||
});
|
||||
|
||||
const recoveredRoute = expectRoute(route);
|
||||
expect(recoveredRoute.sessionKey).toBe(
|
||||
"agent:main:mattermost:channel:chan123:thread:root-post",
|
||||
);
|
||||
expect(recoveredRoute.baseSessionKey).toBe("agent:main:mattermost:channel:chan123");
|
||||
expect(recoveredRoute.threadId).toBe("root-post");
|
||||
});
|
||||
|
||||
it("keeps explicit replyToId ahead of recovered currentSessionKey thread", () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "mattermost:channel:chan123",
|
||||
replyToId: "explicit-root",
|
||||
currentSessionKey: "agent:main:mattermost:channel:chan123:thread:root-post",
|
||||
});
|
||||
|
||||
const replyRoute = expectRoute(route);
|
||||
expect(replyRoute.sessionKey).toBe(
|
||||
"agent:main:mattermost:channel:chan123:thread:explicit-root",
|
||||
);
|
||||
expect(replyRoute.threadId).toBe("explicit-root");
|
||||
});
|
||||
|
||||
it('does not recover currentSessionKey threads for shared dmScope "main" DMs', () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "@user123",
|
||||
currentSessionKey: "agent:main:main:thread:root-post",
|
||||
});
|
||||
|
||||
const dmRoute = expectRoute(route);
|
||||
expect(dmRoute.sessionKey).toBe("agent:main:main");
|
||||
expect(dmRoute.baseSessionKey).toBe("agent:main:main");
|
||||
expect(dmRoute.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when the target is empty after normalization", () => {
|
||||
expect(
|
||||
resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "mattermost:",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user