From 2072259fb6d937c51bf06bbb34ed2177cb74d213 Mon Sep 17 00:00:00 2001 From: Karti Date: Fri, 10 Apr 2026 21:57:29 -0700 Subject: [PATCH] Initial commit: OpenClaw Mattermost Extension --- .github/workflows/ci.yml | 149 + .gitignore | 43 + .npmignore | 50 + .openclawignore | 5 + CHANGELOG.md | 71 + CONTRIBUTING.md | 564 +++ LICENSE | 201 + README.md | 507 +++ docs/API.md | 864 ++++ docs/MIGRATION.md | 562 +++ eslint.config.js | 29 + extensions/mattermost/api.ts | 3 + extensions/mattermost/contract-api.ts | 6 + extensions/mattermost/contract-surfaces.ts | 6 + extensions/mattermost/index.ts | 20 + extensions/mattermost/openclaw.plugin.json | 9 + extensions/mattermost/package.json | 41 + extensions/mattermost/runtime-api.ts | 88 + extensions/mattermost/setup-entry.ts | 4 + .../mattermost/src/approval-auth.test.ts | 28 + extensions/mattermost/src/approval-auth.ts | 29 + extensions/mattermost/src/channel-api.ts | 8 + extensions/mattermost/src/channel.test.ts | 618 +++ extensions/mattermost/src/channel.ts | 730 ++++ extensions/mattermost/src/config-runtime.ts | 7 + .../mattermost/src/config-schema-core.ts | 134 + .../mattermost/src/config-schema.test.ts | 82 + extensions/mattermost/src/config-schema.ts | 135 + extensions/mattermost/src/config-surface.ts | 5 + extensions/mattermost/src/doctor.ts | 322 ++ extensions/mattermost/src/errors.test.ts | 448 ++ extensions/mattermost/src/errors.ts | 855 ++++ .../mattermost/src/group-mentions.test.ts | 46 + extensions/mattermost/src/group-mentions.ts | 23 + .../src/interactive-replies.test.ts | 271 ++ .../mattermost/src/interactive-replies.ts | 317 ++ .../src/mattermost/accounts.test.ts | 138 + .../mattermost/src/mattermost/accounts.ts | 159 + .../mattermost/src/mattermost/actions.ts | 551 +++ .../src/mattermost/client.retry.test.ts | 512 +++ .../mattermost/src/mattermost/client.test.ts | 291 ++ .../mattermost/src/mattermost/client.ts | 496 +++ .../src/mattermost/directory.test.ts | 172 + .../mattermost/src/mattermost/directory.ts | 172 + extensions/mattermost/src/mattermost/index.ts | 9 + .../src/mattermost/interactions.test.ts | 887 ++++ .../mattermost/src/mattermost/interactions.ts | 687 ++++ .../src/mattermost/model-picker.test.ts | 175 + .../mattermost/src/mattermost/model-picker.ts | 382 ++ .../src/mattermost/monitor-auth.test.ts | 165 + .../mattermost/src/mattermost/monitor-auth.ts | 315 ++ .../src/mattermost/monitor-gating.test.ts | 84 + .../src/mattermost/monitor-gating.ts | 99 + .../src/mattermost/monitor-helpers.test.ts | 82 + .../src/mattermost/monitor-helpers.ts | 110 + .../src/mattermost/monitor-onchar.test.ts | 32 + .../src/mattermost/monitor-onchar.ts | 25 + .../src/mattermost/monitor-resources.test.ts | 155 + .../src/mattermost/monitor-resources.ts | 183 + .../src/mattermost/monitor-slash.test.ts | 183 + .../src/mattermost/monitor-slash.ts | 211 + .../src/mattermost/monitor-websocket.test.ts | 405 ++ .../src/mattermost/monitor-websocket.ts | 329 ++ .../src/mattermost/monitor.authz.test.ts | 123 + .../mattermost/monitor.channel-kind.test.ts | 20 + .../mattermost/src/mattermost/monitor.test.ts | 300 ++ .../mattermost/src/mattermost/monitor.ts | 1743 ++++++++ .../mattermost/src/mattermost/probe.test.ts | 136 + extensions/mattermost/src/mattermost/probe.ts | 72 + .../src/mattermost/reactions.test-helpers.ts | 88 + .../src/mattermost/reactions.test.ts | 106 + .../mattermost/src/mattermost/reactions.ts | 130 + .../src/mattermost/reconnect.test.ts | 198 + .../mattermost/src/mattermost/reconnect.ts | 103 + .../src/mattermost/reply-delivery.test.ts | 9 + .../src/mattermost/reply-delivery.ts | 95 + .../mattermost/src/mattermost/runtime-api.ts | 1 + .../mattermost/src/mattermost/send.test.ts | 642 +++ extensions/mattermost/src/mattermost/send.ts | 471 +++ .../src/mattermost/slash-commands.test.ts | 164 + .../src/mattermost/slash-commands.ts | 588 +++ .../mattermost/slash-http.send-config.test.ts | 265 ++ .../src/mattermost/slash-http.test.ts | 158 + .../mattermost/src/mattermost/slash-http.ts | 546 +++ .../src/mattermost/slash-state.test.ts | 66 + .../mattermost/src/mattermost/slash-state.ts | 311 ++ .../src/mattermost/target-resolution.test.ts | 128 + .../src/mattermost/target-resolution.ts | 101 + extensions/mattermost/src/normalize.test.ts | 96 + extensions/mattermost/src/normalize.ts | 50 + .../src/plugin-sdk/account-helpers.ts | 14 + .../mattermost/src/plugin-sdk/account-id.ts | 5 + .../src/plugin-sdk/account-resolution.ts | 14 + .../src/plugin-sdk/approval-auth-runtime.ts | 7 + .../src/plugin-sdk/channel-policy.ts | 3 + .../src/plugin-sdk/config-runtime.ts | 39 + .../src/plugin-sdk/conversation-runtime.ts | 7 + extensions/mattermost/src/plugin-sdk/core.ts | 7 + .../src/plugin-sdk/error-runtime.ts | 10 + extensions/mattermost/src/plugin-sdk/index.ts | 22 + .../src/plugin-sdk/reply-runtime.ts | 9 + .../src/plugin-sdk/retry-runtime.ts | 43 + .../mattermost/src/plugin-sdk/secret-input.ts | 13 + extensions/mattermost/src/plugin-sdk/setup.ts | 1 + .../mattermost/src/plugin-sdk/ssrf-runtime.ts | 15 + extensions/mattermost/src/plugin-sdk/zod.ts | 19 + extensions/mattermost/src/runtime-api.ts | 1 + extensions/mattermost/src/runtime.ts | 6 + extensions/mattermost/src/secret-contract.ts | 54 + extensions/mattermost/src/secret-input.ts | 7 + extensions/mattermost/src/security-audit.ts | 385 ++ .../mattermost/src/session-route.test.ts | 54 + extensions/mattermost/src/session-route.ts | 52 + extensions/mattermost/src/setup-core.ts | 95 + extensions/mattermost/src/setup-surface.ts | 128 + .../mattermost/src/setup.accounts.runtime.ts | 5 + .../mattermost/src/setup.client.runtime.ts | 1 + .../src/setup.secret-input.runtime.ts | 1 + extensions/mattermost/src/setup.test.ts | 356 ++ extensions/mattermost/src/types.ts | 111 + openclaw.plugin.json | 49 + package-lock.json | 3590 +++++++++++++++++ package.json | 78 + playwright.config.ts | 105 + tsconfig.json | 26 + vitest.config.ts | 72 + 126 files changed, 26403 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .openclawignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/API.md create mode 100644 docs/MIGRATION.md create mode 100644 eslint.config.js create mode 100644 extensions/mattermost/api.ts create mode 100644 extensions/mattermost/contract-api.ts create mode 100644 extensions/mattermost/contract-surfaces.ts create mode 100644 extensions/mattermost/index.ts create mode 100644 extensions/mattermost/openclaw.plugin.json create mode 100644 extensions/mattermost/package.json create mode 100644 extensions/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/setup-entry.ts create mode 100644 extensions/mattermost/src/approval-auth.test.ts create mode 100644 extensions/mattermost/src/approval-auth.ts create mode 100644 extensions/mattermost/src/channel-api.ts create mode 100644 extensions/mattermost/src/channel.test.ts create mode 100644 extensions/mattermost/src/channel.ts create mode 100644 extensions/mattermost/src/config-runtime.ts create mode 100644 extensions/mattermost/src/config-schema-core.ts create mode 100644 extensions/mattermost/src/config-schema.test.ts create mode 100644 extensions/mattermost/src/config-schema.ts create mode 100644 extensions/mattermost/src/config-surface.ts create mode 100644 extensions/mattermost/src/doctor.ts create mode 100644 extensions/mattermost/src/errors.test.ts create mode 100644 extensions/mattermost/src/errors.ts create mode 100644 extensions/mattermost/src/group-mentions.test.ts create mode 100644 extensions/mattermost/src/group-mentions.ts create mode 100644 extensions/mattermost/src/interactive-replies.test.ts create mode 100644 extensions/mattermost/src/interactive-replies.ts create mode 100644 extensions/mattermost/src/mattermost/accounts.test.ts create mode 100644 extensions/mattermost/src/mattermost/accounts.ts create mode 100644 extensions/mattermost/src/mattermost/actions.ts create mode 100644 extensions/mattermost/src/mattermost/client.retry.test.ts create mode 100644 extensions/mattermost/src/mattermost/client.test.ts create mode 100644 extensions/mattermost/src/mattermost/client.ts create mode 100644 extensions/mattermost/src/mattermost/directory.test.ts create mode 100644 extensions/mattermost/src/mattermost/directory.ts create mode 100644 extensions/mattermost/src/mattermost/index.ts create mode 100644 extensions/mattermost/src/mattermost/interactions.test.ts create mode 100644 extensions/mattermost/src/mattermost/interactions.ts create mode 100644 extensions/mattermost/src/mattermost/model-picker.test.ts create mode 100644 extensions/mattermost/src/mattermost/model-picker.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-auth.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-auth.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-gating.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-gating.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-helpers.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-helpers.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-onchar.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-onchar.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-resources.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-resources.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-slash.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-slash.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-websocket.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-websocket.ts create mode 100644 extensions/mattermost/src/mattermost/monitor.authz.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor.channel-kind.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor.test.ts create mode 100644 extensions/mattermost/src/mattermost/monitor.ts create mode 100644 extensions/mattermost/src/mattermost/probe.test.ts create mode 100644 extensions/mattermost/src/mattermost/probe.ts create mode 100644 extensions/mattermost/src/mattermost/reactions.test-helpers.ts create mode 100644 extensions/mattermost/src/mattermost/reactions.test.ts create mode 100644 extensions/mattermost/src/mattermost/reactions.ts create mode 100644 extensions/mattermost/src/mattermost/reconnect.test.ts create mode 100644 extensions/mattermost/src/mattermost/reconnect.ts create mode 100644 extensions/mattermost/src/mattermost/reply-delivery.test.ts create mode 100644 extensions/mattermost/src/mattermost/reply-delivery.ts create mode 100644 extensions/mattermost/src/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/src/mattermost/send.test.ts create mode 100644 extensions/mattermost/src/mattermost/send.ts create mode 100644 extensions/mattermost/src/mattermost/slash-commands.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-commands.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.send-config.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.ts create mode 100644 extensions/mattermost/src/mattermost/slash-state.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-state.ts create mode 100644 extensions/mattermost/src/mattermost/target-resolution.test.ts create mode 100644 extensions/mattermost/src/mattermost/target-resolution.ts create mode 100644 extensions/mattermost/src/normalize.test.ts create mode 100644 extensions/mattermost/src/normalize.ts create mode 100644 extensions/mattermost/src/plugin-sdk/account-helpers.ts create mode 100644 extensions/mattermost/src/plugin-sdk/account-id.ts create mode 100644 extensions/mattermost/src/plugin-sdk/account-resolution.ts create mode 100644 extensions/mattermost/src/plugin-sdk/approval-auth-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/channel-policy.ts create mode 100644 extensions/mattermost/src/plugin-sdk/config-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/conversation-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/core.ts create mode 100644 extensions/mattermost/src/plugin-sdk/error-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/index.ts create mode 100644 extensions/mattermost/src/plugin-sdk/reply-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/retry-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/secret-input.ts create mode 100644 extensions/mattermost/src/plugin-sdk/setup.ts create mode 100644 extensions/mattermost/src/plugin-sdk/ssrf-runtime.ts create mode 100644 extensions/mattermost/src/plugin-sdk/zod.ts create mode 100644 extensions/mattermost/src/runtime-api.ts create mode 100644 extensions/mattermost/src/runtime.ts create mode 100644 extensions/mattermost/src/secret-contract.ts create mode 100644 extensions/mattermost/src/secret-input.ts create mode 100644 extensions/mattermost/src/security-audit.ts create mode 100644 extensions/mattermost/src/session-route.test.ts create mode 100644 extensions/mattermost/src/session-route.ts create mode 100644 extensions/mattermost/src/setup-core.ts create mode 100644 extensions/mattermost/src/setup-surface.ts create mode 100644 extensions/mattermost/src/setup.accounts.runtime.ts create mode 100644 extensions/mattermost/src/setup.client.runtime.ts create mode 100644 extensions/mattermost/src/setup.secret-input.runtime.ts create mode 100644 extensions/mattermost/src/setup.test.ts create mode 100644 extensions/mattermost/src/types.ts create mode 100644 openclaw.plugin.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e23fd09 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,149 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + inputs: + publish: + description: 'Publish to npm' + required: true + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + test: + name: Test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['22'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Upload coverage to Codecov + if: matrix.node-version == '22' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + fail_ci_if_error: false + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-files + path: dist/ + retention-days: 7 + + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + run: npm audit --audit-level=moderate + + - name: Run Snyk security scan + if: env.SNYK_TOKEN != '' + uses: snyk/actions/node@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + publish: + name: Publish to npm + runs-on: ubuntu-latest + needs: [build] + if: github.event.inputs.publish == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f309d68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Testing +coverage/ +.nyc_output/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log + +# Temporary files +tmp/ +temp/ +*.tmp +.sisyphus/ +.sisyphus/ +**/.sisyphus/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..7960c06 --- /dev/null +++ b/.npmignore @@ -0,0 +1,50 @@ +# Source files (only dist/ should be published) +extensions/ + +# Test files +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +*.test.js +*.test.jsx +*.spec.js +*.spec.jsx + +# Test directories +tests/ +test/ +__tests__/ +coverage/ + +# Development files +.sisyphus/ +.github/ +.vscode/ +.idea/ + +# Config files (not needed in production) +playwright.config.ts +vitest.config.ts +vitest.config.* +tsconfig.json +eslint.config.js +.eslintrc* +.prettierrc* + +# Build artifacts (keep dist/ but not these) +*.tsbuildinfo + +# Documentation (keep README, but not these) +docs/ + +# CI/CD +.github/workflows/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/.openclawignore b/.openclawignore new file mode 100644 index 0000000..8ef536a --- /dev/null +++ b/.openclawignore @@ -0,0 +1,5 @@ +tests/ +test-results/ +.sisyphus/ +src/ +extensions/mattermost/src/**/*.test.ts \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e7b4a53 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +## [1.0.0] - 2026-04-10 + +### Added + +- **PAT (Personal Access Token) Support**: Dual token authentication with bot token for reads and PAT for writes +- **Message Editing**: Edit bot messages with permission controls via `editMessage()` action +- **Message Deletion**: Delete bot messages with safety checks via `deleteMessage()` action +- **File Download**: Secure file download with MIME type validation, executable blocking, and size limits +- **Interactive Directives**: Support for `[[mattermost_buttons: ...]]` and `[[mattermost_select: ...]]` syntax +- **Security Audit**: Automated security configuration scanning with `collectMattermostSecurityAuditFindings()` +- **Structured Error Handling**: Comprehensive error classes with context, retry logic, and user-friendly messages +- **Automatic Retry Logic**: Exponential backoff with jitter for transient failures +- **Rate Limit Handling**: Full support for Mattermost rate limiting with `Retry-After` header parsing +- **SSRF Protection**: Guarded outbound requests with `fetchWithSsrFGuard` +- **Multi-Account Support**: Configure multiple Mattermost accounts with `accounts` and `defaultAccount` +- **Advanced Slash Commands**: Native and skill-based commands with callback configuration +- **DM Channel Retry**: Configurable retry logic for DM channel creation +- **Error Boundary**: Global error handling with `globalErrorBoundary` +- **Group-Specific Settings**: Per-channel configuration via `groups` object + +### Security + +- HTTPS enforcement warnings for insecure configurations +- IP allowlisting for interaction callbacks +- Private network access controls +- Hardcoded token detection in security audit +- Mutable allowlist entry detection +- Executable file blocking in downloads +- Input validation for all user-provided parameters + +### Changed + +- Improved error messages with context and remediation hints +- Enhanced configuration validation with Zod schemas +- Better separation of concerns in client architecture +- More comprehensive logging without exposing secrets + +## [0.1.0] - 2026-04-10 + +### Added + +- Initial release of open-source +- TypeScript support +- Basic filtering functionality for OpenCode +- Mattermost integration support +- Development tooling (Vitest, TypeScript) + +[Unreleased]: https://github.com/lumbridgecorp/openclaw-extentions-mattermost/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/lumbridgecorp/openclaw-extentions-mattermost/releases/tag/v1.0.0 +[0.1.0]: https://github.com/lumbridgecorp/openclaw-extentions-mattermost/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb7d33a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,564 @@ +# Contributing to @lumbridgecorp/openclaw-mattermost + +Thank you for your interest in contributing! This document provides guidelines for setting up your development environment, running tests, and submitting contributions. + +## Table of Contents + +- [Development Setup](#development-setup) +- [Running Tests](#running-tests) +- [Test Structure](#test-structure) +- [Writing Tests](#writing-tests) +- [Code Style](#code-style) +- [Pull Request Process](#pull-request-process) +- [Release Process](#release-process) + +## Development Setup + +### Prerequisites + +- **Node.js 22+** (required for native fetch and WebSocket support) +- **pnpm** (preferred) or npm/yarn +- **Git** +- **Mattermost server** (for integration tests) + +### Local Setup + +1. **Fork the repository** on GitHub + +2. **Clone your fork:** + ```bash + git clone https://github.com/YOUR_USERNAME/openclaw-extentions-mattermost.git + cd openclaw-extentions-mattermost + ``` + +3. **Install dependencies:** + ```bash + pnpm install + # or + npm install + ``` + +4. **Build the project:** + ```bash + pnpm build + # or + npm run build + ``` + +5. **Verify the build:** + ```bash + pnpm typecheck + ``` + +## Running Tests + +### Quick Test Commands + +```bash +# Run all tests +pnpm test + +# Run tests in watch mode (for development) +pnpm test -- --watch + +# Run tests with coverage +pnpm test -- --coverage + +# Run a specific test file +pnpm test src/mattermost/client.test.ts + +# Run tests matching a pattern +pnpm test -- -t "should create client" + +# Run tests with verbose output +pnpm test -- --verbose +``` + +### Test Categories + +#### Unit Tests + +Fast, isolated tests that don't require external services: + +```bash +# Run only unit tests (no integration) +pnpm test -- --testPathIgnorePatterns="integration|e2e" +``` + +Unit tests cover: +- Error handling classes +- Configuration validation +- Utility functions +- Retry logic +- Security checks + +#### Integration Tests with Docker Compose + +Integration tests use Docker Compose to spin up a complete Mattermost instance with PostgreSQL. This provides isolated, reproducible tests against a real Mattermost API. + +**Setup and Run Integration Tests:** + +```bash +# Start the test infrastructure +docker-compose -f tests/integration/docker-compose.test.yml up -d + +# Wait for Mattermost to be ready (first startup may take 1-2 minutes) +docker-compose -f tests/integration/docker-compose.test.yml ps + +# Run integration tests +pnpm test tests/integration/ + +# Run specific integration test file +pnpm test tests/integration/authentication.test.ts + +# Run integration tests with verbose output +pnpm test tests/integration/ --verbose + +# Clean up after tests +docker-compose -f tests/integration/docker-compose.test.yml down -v +``` + +**Integration Test Coverage:** + +| Test File | Coverage | +|-----------|----------| +| `authentication.test.ts` | Bot token auth, PAT auth, error scenarios | +| `send-message.test.ts` | Send to channel/DM, threads, props, error cases | +| `edit-message.test.ts` | Edit own messages, props, permission errors | +| `delete-message.test.ts` | Delete messages, thread handling, cleanup | +| `download-file.test.ts` | File download, security restrictions, MIME validation | +| `interactive-buttons.test.ts` | Button rendering, directives, end-to-end flows | + +**Environment Variables for Integration Tests:** + +```bash +# Optional: Override defaults (only if not using Docker Compose) +export MATTERMOST_URL="http://localhost:8065" +export MATTERMOST_SYSADMIN_USER="sysadmin" +export MATTERMOST_SYSADMIN_PASSWORD="Sys@dmin123" +export MATTERMOST_TEST_USER="testuser" +export MATTERMOST_TEST_USER_PASSWORD="Test@user123" +export MATTERMOST_BOT_USERNAME="testbot" +``` + +**Test Data Cleanup:** + +Integration tests automatically clean up: +- Created posts after each test suite +- Uploaded files +- Test channels and teams (via Docker volume reset) + +Run `docker-compose -f tests/integration/docker-compose.test.yml down -v` to completely reset the test environment between runs. + +**Using a Custom Mattermost Instance:** + +If you prefer to use an existing Mattermost instance for integration tests: + +```bash +# Set up environment variables +export MATTERMOST_URL="https://chat-test.example.com" +export MATTERMOST_SYSADMIN_TOKEN="your-sysadmin-token" +export MATTERMOST_BOT_TOKEN="your-bot-token" + +# Run integration tests (skip Docker setup) +export SKIP_DOCKER_SETUP=true +pnpm test tests/integration/ +``` + +⚠️ **Important:** Never run integration tests against production Mattermost instances. Tests create and delete data automatically. + +#### Client Tests + +Tests for the Mattermost API client: + +```bash +pnpm test src/mattermost/client.test.ts +pnpm test src/mattermost/client.retry.test.ts +``` + +#### WebSocket Tests + +Tests for real-time message monitoring: + +```bash +pnpm test src/mattermost/monitor-websocket.test.ts +``` + +### Test Environment Setup + +#### Using Environment Variables + +Create a `.env.test` file (not committed to git): + +```bash +# Test Mattermost instance +MATTERMOST_TEST_URL=https://chat-test.example.com +MATTERMOST_TEST_TOKEN=your-test-bot-token +MATTERMOST_TEST_PAT=your-test-pat-optional + +# Test configuration +TEST_TIMEOUT=30000 +TEST_RETRY_COUNT=3 +``` + +#### Using Test Fixtures + +Some tests use mock fixtures instead of real connections: + +```typescript +// Example: Using mock client +import { createMockMattermostClient } from './test-helpers.js'; + +const mockClient = createMockMattermostClient({ + baseUrl: 'https://mock.example.com', + botToken: 'mock-token' +}); +``` + +### Coverage Reports + +Generate and view coverage: + +```bash +# Generate coverage report +pnpm test -- --coverage + +# View HTML report +open coverage/lcov-report/index.html + +# Coverage thresholds (enforced in CI) +# Statements: 80% +# Branches: 75% +# Functions: 80% +# Lines: 80% +``` + +## Test Structure + +### Test File Organization + +``` +extensions/mattermost/src/ +├── mattermost/ +│ ├── client.test.ts # Client unit tests +│ ├── client.retry.test.ts # Retry logic tests +│ ├── monitor-websocket.test.ts # WebSocket tests +│ ├── interactions.test.ts # Interactive components tests +│ └── ... +├── config-schema.test.ts # Configuration validation +├── security-audit.test.ts # Security audit tests +├── errors.test.ts # Error handling tests +└── ... +``` + +### Test Naming Conventions + +- **File naming**: `*.test.ts` for unit tests, `*.integration.test.ts` for integration tests +- **Describe blocks**: Use the name of the function/module being tested +- **Test names**: Start with "should" and describe the expected behavior + +Example: + +```typescript +describe('editMessage', () => { + it('should successfully edit a bot message', async () => { + // test code + }); + + it('should fail when editing another users message', async () => { + // test code + }); + + it('should return PERMISSION_DENIED for system messages', async () => { + // test code + }); +}); +``` + +## Writing Tests + +### Unit Test Template + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { editMessage } from './actions.js'; + +describe('editMessage', () => { + it('should edit a message successfully', async () => { + // Arrange + const mockClient = createMockClient(); + const params = { + cfg: mockConfig, + postId: 'post123', + channelId: 'channel123', + message: 'Updated' + }; + + // Act + const result = await editMessage(params); + + // Assert + expect(result.ok).toBe(true); + expect(result.postId).toBe('post123'); + }); + + it('should handle errors gracefully', async () => { + // Arrange + const params = { /* invalid params */ }; + + // Act + const result = await editMessage(params); + + // Assert + expect(result.ok).toBe(false); + expect(result.errorCode).toBe('CONFIG_ERROR'); + }); +}); +``` + +### Testing Error Handling + +```typescript +import { MattermostError, ErrorCodes } from './errors.js'; + +describe('error handling', () => { + it('should create structured error with context', () => { + const error = new MattermostError({ + code: ErrorCodes.AUTHENTICATION_FAILED, + message: 'Invalid token', + context: { + operation: 'test', + timestamp: new Date().toISOString() + } + }); + + expect(error.code).toBe(ErrorCodes.AUTHENTICATION_FAILED); + expect(error.retryable).toBe(false); + expect(error.toJSON()).toMatchObject({ + code: ErrorCodes.AUTHENTICATION_FAILED + }); + }); +}); +``` + +### Testing Retries + +```typescript +import { withRetry } from './errors.js'; + +describe('withRetry', () => { + it('should retry on retryable errors', async () => { + let attempts = 0; + const operation = async () => { + attempts++; + if (attempts < 3) { + throw new NetworkError('Connection failed', context); + } + return 'success'; + }; + + const result = await withRetry('test-op', operation, { + maxRetries: 3, + initialDelayMs: 10 + }); + + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); +}); +``` + +### Testing Security Features + +```typescript +import { collectMattermostSecurityAuditFindings } from './security-audit.js'; + +describe('security audit', () => { + it('should detect hardcoded tokens', async () => { + const findings = await collectMattermostSecurityAuditFindings({ + cfg: mockConfig, + accountId: 'test', + account: { + ...mockAccount, + botToken: 'hardcoded-token-123456789', + botTokenSource: 'config' + } + }); + + const hardcodedFinding = findings.find( + f => f.checkId === 'channels.mattermost.pat.hardcoded_token' + ); + + expect(hardcodedFinding).toBeDefined(); + expect(hardcodedFinding?.severity).toBe('critical'); + }); +}); +``` + +### Mocking the Mattermost Client + +```typescript +import { vi } from 'vitest'; + +// Create mock client +const createMockClient = () => ({ + request: vi.fn(), + fetchImpl: vi.fn(), + baseUrl: 'https://mock.example.com', + apiBaseUrl: 'https://mock.example.com/api/v4', + token: 'mock-token' +}); + +// Mock module +vi.mock('./client.js', () => ({ + createMattermostClient: vi.fn(() => createMockClient()) +})); +``` + +## Code Style + +### TypeScript Guidelines + +- Use strict TypeScript (`strict: true` in tsconfig) +- Explicit return types on public functions +- Avoid `any` - use `unknown` with type guards +- Use `readonly` for immutable properties + +### Code Formatting + +```bash +# Check code style +pnpm lint + +# Fix auto-fixable issues +pnpm lint --fix +``` + +### Import Order + +1. External dependencies (e.g., `openclaw/plugin-sdk`) +2. Internal modules (relative imports) +3. Types only imports + +Example: + +```typescript +// 1. External +import { z } from 'openclaw/plugin-sdk/zod'; + +// 2. Internal +import { createMattermostClient } from './client.js'; +import type { MattermostAccountConfig } from './types.js'; +``` + +## Pull Request Process + +### Before Submitting + +1. **Run all tests:** + ```bash + pnpm test + ``` + +2. **Check type safety:** + ```bash + pnpm typecheck + ``` + +3. **Run linter:** + ```bash + pnpm lint + ``` + +4. **Build project:** + ```bash + pnpm build + ``` + +5. **Check test coverage:** + ```bash + pnpm test -- --coverage + ``` + +### PR Requirements + +- **Clear description** of changes +- **Link to related issues** +- **Tests included** for new features +- **Documentation updated** if needed +- **No breaking changes** (or clearly marked) +- **CI checks passing** + +### PR Template + +```markdown +## Description +Brief description of the change + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] All tests passing + +## Checklist +- [ ] Code follows style guidelines +- [ ] Self-review completed +- [ ] Documentation updated +- [ ] No new warnings +``` + +### Review Process + +1. Automated checks must pass (CI, coverage) +2. At least one maintainer review required +3. Address review feedback promptly +4. Squash commits before merge (if requested) + +## Release Process + +### Version Bump + +```bash +# Update version in package.json +npm version patch # or minor, major +``` + +### Changelog Update + +Add entry to `CHANGELOG.md` following Keep a Changelog format: + +```markdown +## [1.1.0] - 2026-04-15 + +### Added +- New feature description + +### Fixed +- Bug fix description +``` + +### Release Steps + +1. Update version in `package.json` +2. Update `CHANGELOG.md` +3. Create PR with version bump +4. After merge, tag the release: + ```bash + git tag -a v1.1.0 -m "Release v1.1.0" + git push origin v1.1.0 + ``` +5. CI will automatically publish to npm + +## Questions? + +- Open an issue on GitHub +- Check existing issues and discussions +- Review the [API Reference](docs/API.md) + +Thank you for contributing! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1a313f --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d8c982 --- /dev/null +++ b/README.md @@ -0,0 +1,507 @@ +# @lumbridgecorp/openclaw-mattermost + +

+ npm version + Latest Release + CI Status + Apache 2.0 License + Open Issues +

+ +

+ Enhanced Mattermost Extension for OpenClaw
+ A powerful, secure, and feature-rich self-hosted Slack-style chat integration +

+ +## Overview + +This enhanced Mattermost extension brings advanced capabilities to OpenClaw, enabling seamless integration with your self-hosted Mattermost server. It supports both bot tokens and Personal Access Tokens (PAT), interactive message components, file operations, and comprehensive security auditing. + +## Features + +### Authentication +- **Dual Token Support**: Use bot tokens, Personal Access Tokens (PAT), or both +- **PAT for Enhanced Security**: Separate read and write permissions with token selection strategy +- **Environment Variable Support**: Secure token management without hardcoding + +### Message Operations +- **Send Messages**: Post to channels and DMs with rich formatting +- **Edit Messages**: Update bot messages with full permission controls +- **Delete Messages**: Remove messages with safety checks +- **Reactions**: Add emoji reactions to messages + +### Interactive Components +- **Button Directives**: `[[mattermost_buttons: Label:value:style, ...]]` +- **Select/Dropdown Directives**: `[[mattermost_select: Placeholder | Label:value, ...]]` +- **Style Support**: default, primary, danger button styles + +### File Operations +- **Download Files**: Secure file downloads with: + - MIME type validation + - Executable file blocking + - Size limits (default 100MB) + - Streaming download for large files + +### Security +- **Security Audit**: Automated security configuration scanning +- **SSRF Protection**: Guarded outbound requests +- **HTTPS Enforcement**: Warnings for insecure connections +- **IP Allowlisting**: Restrict interaction callbacks +- **Private Network Support**: For LAN/VPN deployments + +### Error Handling +- **Structured Errors**: Typed error classes with context +- **Automatic Retry**: Exponential backoff with jitter +- **Rate Limit Handling**: Respects Mattermost rate limits +- **User-Friendly Messages**: Clear error descriptions + +## Installation + +### Prerequisites + +- Node.js 22 or higher +- Mattermost server (self-hosted or cloud) +- OpenClaw 1.0.0 or higher + +### NPM Installation + +```bash +npm install @lumbridgecorp/openclaw-mattermost +``` + +### PNPM Installation + +```bash +pnpm add @lumbridgecorp/openclaw-mattermost +``` + +### Yarn Installation + +```bash +yarn add @lumbridgecorp/openclaw-mattermost +``` + +## Quick Start + +### 1. Create a Bot Account in Mattermost + +1. Go to **System Console > Integrations > Bot Accounts** +2. Click **Add Bot Account** +3. Set username (e.g., `openclaw-bot`) +4. Choose role (usually **System Admin** for full access, or **Member** for restricted) +5. Save the generated **Bot Token** + +### 2. Configure Environment Variables + +```bash +export MATTERMOST_URL="https://chat.yourcompany.com" +export MATTERMOST_BOT_TOKEN="your-bot-token-here" +``` + +### 3. Basic Configuration + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.yourcompany.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "dmPolicy": "pairing", + "allowFrom": ["your-mattermost-user-id"] + } + } +} +``` + +### 4. Using PAT (Optional but Recommended) + +For enhanced security with separate read/write permissions: + +```bash +export MATTERMOST_PAT="your-personal-access-token" +``` + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.yourcompany.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "pat": "${MATTERMOST_PAT}", + "dmPolicy": "pairing" + } + } +} +``` + +## Configuration Reference + +### Core Settings + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `baseUrl` | string | Yes | - | Mattermost server URL (HTTPS recommended) | +| `botToken` | string | Yes | - | Bot account token | +| `pat` | string | No | - | Personal Access Token for write operations | +| `enabled` | boolean | No | true | Enable/disable this account | +| `name` | string | No | - | Display name for the account | + +### Access Control + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `dmPolicy` | enum | No | `pairing` | DM access: `open`, `allowlist`, or `pairing` | +| `allowFrom` | array | No | [] | User IDs allowed to DM the bot | +| `groupPolicy` | enum | No | `allowlist` | Group channel access: `open` or `allowlist` | +| `groupAllowFrom` | array | No | [] | User IDs allowed in group channels | +| `requireMention` | boolean | No | false | Require @mention to trigger bot | + +### Chat Modes + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `chatmode` | enum | No | `onmessage` | Trigger mode: `oncall`, `onmessage`, `onchar` | +| `oncharPrefixes` | array | No | [] | Prefixes for onchar mode (e.g., `["!", "/"`) | + +### Message Settings + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `textChunkLimit` | number | No | - | Max characters per message chunk | +| `chunkMode` | enum | No | `length` | Chunking: `length` or `newline` | +| `blockStreaming` | boolean | No | false | Enable streaming responses | +| `replyToMode` | enum | No | `off` | Reply threading: `off`, `first`, `all` | +| `responsePrefix` | string | No | - | Text prepended to all responses | + +### Slash Commands + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `commands.native` | boolean\|"auto" | No | `auto` | Enable native slash commands | +| `commands.nativeSkills` | boolean\|"auto" | No | `auto` | Enable skill-based commands | +| `commands.callbackPath` | string | No | - | Custom callback endpoint path | +| `commands.callbackUrl` | string | No | - | Explicit callback URL | + +### Interactions + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `interactions.callbackBaseUrl` | string | No | - | HTTPS URL for button callbacks | +| `interactions.allowedSourceIps` | array | No | [] | IP allowlist for callbacks | + +### Security Settings + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `allowPrivateNetwork` | boolean | No | false | Allow private IP connections | +| `configWrites` | boolean | No | false | Allow bot to write config | +| `dangerouslyAllowNameMatching` | boolean | No | false | Match users by name (not ID) | + +### Retry Configuration + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `dmChannelRetry.maxRetries` | number | No | 3 | Max DM creation retries | +| `dmChannelRetry.initialDelayMs` | number | No | 1000 | Initial retry delay | +| `dmChannelRetry.maxDelayMs` | number | No | 10000 | Maximum retry delay | +| `dmChannelRetry.timeoutMs` | number | No | 30000 | Request timeout | + +### Action Controls + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `actions.reactions` | boolean | No | true | Enable emoji reactions | +| `actions.downloadFile` | boolean | No | true | Enable file downloads | +| `actions.delete` | boolean | No | true | Enable message deletion | + +### Group-Specific Settings + +```json +{ + "channels": { + "mattermost": { + "groups": { + "channel-id-1": { + "requireMention": true + }, + "*": { + "requireMention": false + } + } + } + } +} +``` + +## Personal Access Token (PAT) Setup Guide + +### Why Use PAT? + +Personal Access Tokens provide several advantages: +- **Separate Permissions**: Use bot token for reads, PAT for writes +- **User Context**: Actions appear as the user, not a bot +- **Audit Trail**: Better tracking in Mattermost logs +- **Granular Control**: Revoke without affecting bot + +### Creating a PAT in Mattermost + +1. **Log in to Mattermost** as the user who will own the PAT +2. Go to **Account Settings > Security > Personal Access Tokens** +3. Click **Create New Token** +4. Enter a description (e.g., "OpenClaw Integration") +5. Click **Save** +6. **Copy the token immediately** (it won't be shown again) + +### Required Permissions + +The user creating the PAT needs these permissions: +- **Create Posts** - to send messages +- **Edit Own Posts** - to edit bot messages +- **Delete Own Posts** - to delete bot messages +- **Upload Files** - to upload attachments +- **Create Direct Channels** - for DM support + +### Token Selection Strategy + +The extension automatically selects the right token: + +| Operation Type | Token Used | +|----------------|------------| +| Read (fetch user, channel info) | Bot Token | +| Write (send message) | PAT (if set) or Bot Token | +| Edit | PAT (if set) or Bot Token | +| Delete | PAT (if set) or Bot Token | +| File Download | Bot Token | + +## Feature Comparison: Enhanced vs Stock Extension + +| Feature | Stock Extension | Enhanced Extension | +|---------|--------------|-------------------| +| Bot Token Only | ✅ | ✅ | +| Personal Access Token (PAT) | ❌ | ✅ | +| Message Editing | ❌ | ✅ | +| Message Deletion | ❌ | ✅ | +| File Download | ❌ | ✅ | +| Interactive Buttons | ❌ | ✅ | +| Interactive Selects | ❌ | ✅ | +| Security Audit | ❌ | ✅ | +| Structured Error Handling | Basic | ✅ Advanced | +| Automatic Retry Logic | ❌ | ✅ | +| Rate Limit Handling | Basic | ✅ Full | +| SSRF Protection | ❌ | ✅ | +| Multi-Account Support | ❌ | ✅ | +| Slash Commands | Basic | ✅ Advanced | + +## Usage Examples + +### Sending Messages + +```typescript +import { sendMessage } from '@lumbridgecorp/openclaw-mattermost'; + +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel-id-here', + message: 'Hello from OpenClaw!' +}); +``` + +### Interactive Buttons + +```typescript +const message = ` +Please select an action: +[[mattermost_buttons: Approve:approve:primary, Reject:reject:danger, Review Later:review]] +`; + +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel-id', + message +}); +``` + +### Interactive Select/Dropdown + +```typescript +const message = ` +Choose a priority: +[[mattermost_select: Select Priority | Low:low, Medium:medium, High:high, Critical:critical]] +`; + +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel-id', + message +}); +``` + +### Editing Messages + +```typescript +import { editMessage } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await editMessage({ + cfg: openclawConfig, + postId: 'post-id-to-edit', + channelId: 'channel-id', + message: 'Updated message content' +}); + +if (!result.ok) { + console.error('Edit failed:', result.error); +} +``` + +### Deleting Messages + +```typescript +import { deleteMessage } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await deleteMessage({ + cfg: openclawConfig, + postId: 'post-id-to-delete', + channelId: 'channel-id' +}); + +if (result.ok) { + console.log('Message deleted'); +} +``` + +### Downloading Files + +```typescript +import { downloadFile } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await downloadFile({ + cfg: openclawConfig, + fileId: 'file-id-from-mattermost', + destinationPath: '/tmp/downloads/report.pdf', + maxSize: 50 * 1024 * 1024 // 50MB limit +}); + +if (result.ok) { + console.log('Downloaded to:', result.filePath); + console.log('File metadata:', result.metadata); +} +``` + +### Running Security Audit + +```typescript +import { collectMattermostSecurityAuditFindings } from '@lumbridgecorp/openclaw-mattermost'; + +const findings = await collectMattermostSecurityAuditFindings({ + cfg: openclawConfig, + accountId: 'default', + account: resolvedAccount +}); + +for (const finding of findings) { + console.log(`[${finding.severity}] ${finding.title}`); + console.log(` ${finding.detail}`); + if (finding.remediation) { + console.log(` Fix: ${finding.remediation}`); + } +} +``` + +## Troubleshooting + +### Connection Issues + +**Problem**: Cannot connect to Mattermost server + +**Solutions**: +1. Verify `baseUrl` uses HTTPS (not HTTP) +2. Check the server is accessible: `curl https://your-server/api/v4/system/ping` +3. For self-hosted servers, set `allowPrivateNetwork: true` +4. Check firewall rules + +### Authentication Errors + +**Problem**: 401 Unauthorized errors + +**Solutions**: +1. Verify the bot token is correct +2. Check the bot account is enabled in Mattermost +3. Ensure the bot has required permissions +4. For PAT, verify the user account is active + +### Rate Limiting + +**Problem**: 429 Too Many Requests + +**Solutions**: +1. The extension has built-in retry logic +2. Increase `dmChannelRetry.maxDelayMs` for longer waits +3. Contact your Mattermost admin to increase rate limits +4. Reduce message frequency + +### File Download Failures + +**Problem**: Cannot download files + +**Solutions**: +1. Check file size against `maxSize` limit (default 100MB) +2. Verify file type is allowed (not executable) +3. Ensure bot has file access permissions +4. Check disk space at destination + +### Interactive Components Not Working + +**Problem**: Buttons/selects don't appear + +**Solutions**: +1. Verify `interactions.callbackBaseUrl` is set with HTTPS +2. Check `allowedSourceIps` includes your Mattermost server +3. Ensure the directive syntax is correct +4. Check browser console for JavaScript errors + +### Message Edit/Delete Failures + +**Problem**: Cannot edit or delete messages + +**Solutions**: +1. Can only edit/delete bot's own messages +2. System messages cannot be modified +3. Check PAT permissions if using PAT +4. Verify the post ID and channel ID are correct + +### Security Audit Warnings + +**Problem**: Security audit shows warnings + +**Solutions**: +1. Move hardcoded tokens to environment variables +2. Switch to HTTPS if using HTTP +3. Configure `allowFrom` for restricted access +4. Review `dmPolicy` and `groupPolicy` settings + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines. + +## API Reference + +See [docs/API.md](docs/API.md) for detailed API documentation. + +## Migration Guide + +If you're migrating from the stock Mattermost extension, see [docs/MIGRATION.md](docs/MIGRATION.md). + +## License + +This project is licensed under the Apache License 2.0. See [LICENSE](LICENSE) for details. + +## Support + +- **Issues**: [GitHub Issues](https://github.com/lumbridgecorp/openclaw-extentions-mattermost/issues) +- **Discussions**: [GitHub Discussions](https://github.com/lumbridgecorp/openclaw-extentions-mattermost/discussions) + +--- + +

+ Built with ❤️ by Lumbridge Corp +

diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9fb56f7 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,864 @@ +# API Reference + +Complete reference for the Enhanced Mattermost Extension API. + +## Table of Contents + +- [Actions](#actions) +- [Interactive Directives](#interactive-directives) +- [Configuration Types](#configuration-types) +- [Error Handling](#error-handling) +- [Security Audit](#security-audit) +- [Client API](#client-api) + +## Actions + +### editMessage + +Edit an existing message posted by the bot. + +```typescript +import { editMessage } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await editMessage({ + cfg: OpenClawConfig, + postId: string, + channelId: string, + message?: string, + props?: Record, + accountId?: string | null, + fetchImpl?: MattermostFetch +}); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `cfg` | `OpenClawConfig` | Yes | OpenClaw configuration object | +| `postId` | `string` | Yes | ID of the post to edit | +| `channelId` | `string` | Yes | Channel ID containing the post | +| `message` | `string` | No | New message content | +| `props` | `Record` | No | Additional post properties | +| `accountId` | `string \| null` | No | Specific account to use | +| `fetchImpl` | `MattermostFetch` | No | Custom fetch implementation | + +**Returns:** + +```typescript +type EditMessageResult = + | { ok: true; postId: string; channelId: string } + | { ok: false; error: string; errorCode?: string }; +``` + +**Error Codes:** +- `PERMISSION_DENIED` - Cannot edit other users' messages or system messages +- `POST_NOT_FOUND` - Post was deleted or doesn't exist +- `RATE_LIMITED` - Too many edit requests +- `AUTH_FAILED` - Invalid or expired token + +**Example:** + +```typescript +const result = await editMessage({ + cfg: openclawConfig, + postId: 'abc123def456ghi789jkl012', + channelId: 'channel123', + message: 'Updated message content' +}); + +if (result.ok) { + console.log(`Edited post ${result.postId}`); +} else { + console.error(`Edit failed: ${result.error} (${result.errorCode})`); +} +``` + +--- + +### deleteMessage + +Delete a message posted by the bot. + +```typescript +import { deleteMessage } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await deleteMessage({ + cfg: OpenClawConfig, + postId: string, + channelId?: string, + accountId?: string | null, + fetchImpl?: MattermostFetch +}); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `cfg` | `OpenClawConfig` | Yes | OpenClaw configuration object | +| `postId` | `string` | Yes | ID of the post to delete | +| `channelId` | `string` | No | Channel ID (for validation) | +| `accountId` | `string \| null` | No | Specific account to use | +| `fetchImpl` | `MattermostFetch` | No | Custom fetch implementation | + +**Returns:** + +```typescript +type DeleteMessageResult = { ok: true } | { ok: false; error: string }; +``` + +**Notes:** +- Returns `ok: true` if post is already deleted (idempotent) +- Cannot delete system messages +- Can only delete bot's own messages + +**Example:** + +```typescript +const result = await deleteMessage({ + cfg: openclawConfig, + postId: 'abc123def456ghi789jkl012' +}); + +if (result.ok) { + console.log('Message deleted successfully'); +} else { + console.error(`Delete failed: ${result.error}`); +} +``` + +--- + +### downloadFile + +Download a file from Mattermost with security validations. + +```typescript +import { downloadFile } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await downloadFile({ + cfg: OpenClawConfig, + fileId: string, + destinationPath: string, + maxSize?: number, + accountId?: string | null, + fetchImpl?: MattermostFetch, + allowedMimePrefixes?: string[] +}); +``` + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `cfg` | `OpenClawConfig` | Yes | - | OpenClaw configuration | +| `fileId` | `string` | Yes | - | Mattermost file ID | +| `destinationPath` | `string` | Yes | - | Local path to save file | +| `maxSize` | `number` | No | 100MB | Maximum file size in bytes | +| `accountId` | `string \| null` | No | - | Specific account to use | +| `fetchImpl` | `MattermostFetch` | No | - | Custom fetch implementation | +| `allowedMimePrefixes` | `string[]` | No | Built-in list | Allowed MIME types | + +**Returns:** + +```typescript +type DownloadFileResult = + | { ok: true; filePath: string; fileId: string; metadata: FileMetadata } + | { ok: false; error: string; errorCode?: string }; + +type FileMetadata = { + id: string; + name: string; + mimeType: string; + size: number; + extension: string; +}; +``` + +**Error Codes:** +- `CONFIG_ERROR` - Missing botToken or baseUrl +- `INVALID_PARAMS` - Missing fileId or destinationPath +- `FILE_TOO_LARGE` - File exceeds maxSize +- `EMPTY_FILE` - File has no content +- `BLOCKED_FILE_TYPE` - Executable file extension +- `BLOCKED_MIME_TYPE` - MIME type not in allowlist +- `FILE_NOT_FOUND` - File doesn't exist +- `PERMISSION_DENIED` - No access to file +- `AUTH_FAILED` - Invalid token +- `DOWNLOAD_FAILED` - Network or server error + +**Security Restrictions:** + +Blocked file extensions: +``` +.exe, .sh, .bat, .cmd, .com, .msi, .dll, .so, .dylib, +.app, .dmg, .pkg, .deb, .rpm, .apk, .ipa, .jar, .war, +.ear, .py, .rb, .pl, .php, .js, .ts, .vbs, .ps1, .psm1, +.scr, .hta, .bin, .run, .out, .elf +``` + +Default allowed MIME types: +``` +image/*, video/*, audio/*, text/* +application/pdf, application/json, application/xml +application/csv, application/zip, application/gzip, application/tar +``` + +**Example:** + +```typescript +const result = await downloadFile({ + cfg: openclawConfig, + fileId: 'file123abc', + destinationPath: '/tmp/report.pdf', + maxSize: 50 * 1024 * 1024 // 50MB +}); + +if (result.ok) { + console.log(`Downloaded: ${result.filePath}`); + console.log(`Size: ${result.metadata.size} bytes`); + console.log(`Type: ${result.metadata.mimeType}`); +} +``` + +--- + +### sendMessage + +Send a message to a Mattermost channel. + +```typescript +import { sendMessage } from '@lumbridgecorp/openclaw-mattermost'; + +await sendMessage({ + cfg: OpenClawConfig, + channelId: string, + message: string, + rootId?: string, + fileIds?: string[], + props?: Record, + accountId?: string | null +}); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `cfg` | `OpenClawConfig` | Yes | OpenClaw configuration | +| `channelId` | `string` | Yes | Target channel ID | +| `message` | `string` | Yes | Message content (supports Markdown) | +| `rootId` | `string` | No | Parent post ID for threaded replies | +| `fileIds` | `string[]` | No | Array of uploaded file IDs to attach | +| `props` | `Record` | No | Additional post properties | +| `accountId` | `string \| null` | No | Specific account to use | + +**Example:** + +```typescript +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel123', + message: 'Hello **World**!', // Markdown supported + rootId: 'parent-post-id' // Thread reply +}); +``` + +--- + +### addReaction + +Add an emoji reaction to a message. + +```typescript +import { addReaction } from '@lumbridgecorp/openclaw-mattermost'; + +await addReaction({ + cfg: OpenClawConfig, + postId: string, + emoji: string, // e.g., 'thumbsup', 'white_check_mark' + accountId?: string | null +}); +``` + +**Example:** + +```typescript +await addReaction({ + cfg: openclawConfig, + postId: 'post123', + emoji: 'thumbsup' +}); +``` + +--- + +### uploadFile + +Upload a file to Mattermost. + +```typescript +import { uploadFile } from '@lumbridgecorp/openclaw-mattermost'; + +const fileInfo = await uploadFile({ + cfg: OpenClawConfig, + channelId: string, + buffer: Buffer, + fileName: string, + contentType?: string, + accountId?: string | null +}); +``` + +**Returns:** `MattermostFileInfo` object with `id`, `name`, `mime_type`, `size` + +**Example:** + +```typescript +import { readFileSync } from 'fs'; + +const buffer = readFileSync('./document.pdf'); +const fileInfo = await uploadFile({ + cfg: openclawConfig, + channelId: 'channel123', + buffer, + fileName: 'document.pdf', + contentType: 'application/pdf' +}); + +// Use the file ID when sending a message +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel123', + message: 'Here is the document:', + fileIds: [fileInfo.id] +}); +``` + +--- + +## Interactive Directives + +Interactive directives allow you to embed buttons and dropdowns in messages using a special syntax. These are parsed and converted to Mattermost interactive components. + +### Button Directive + +Syntax: +``` +[[mattermost_buttons: Label1:value1:style1, Label2:value2:style2, ...]] +``` + +**Format:** +- `Label`: Display text on the button (max 30 chars) +- `value`: Value sent when button is clicked +- `style`: Button style - `default`, `primary`, or `danger` (optional, defaults to `default`) + +**Maximum:** 5 buttons per directive + +**Example:** + +```typescript +const message = ` +Please review this request: + +[[mattermost_buttons: + Approve:approve_request:primary, + Reject:reject_request:danger, + Review Later:review_later:default +]] +`; + +await sendMessage({ cfg: openclawConfig, channelId: 'channel123', message }); +``` + +--- + +### Select/Dropdown Directive + +Syntax: +``` +[[mattermost_select: Placeholder | Label1:value1, Label2:value2, ...]] +``` + +**Format:** +- `Placeholder`: Text shown when nothing selected (optional, defaults to "Choose an option") +- `Label`: Display text for the option +- `value`: Value sent when option is selected + +**Maximum:** 100 options per directive + +**Example:** + +```typescript +const message = ` +Select a priority level: + +[[mattermost_select: + Select Priority | + Low:low, + Medium:medium, + High:high, + Critical:critical +]] +`; + +await sendMessage({ cfg: openclawConfig, channelId: 'channel123', message }); +``` + +--- + +### Multiple Directives + +You can combine multiple directives in a single message: + +```typescript +const message = ` +Deployment ready for production. + +[[mattermost_buttons: Deploy:deploy:primary, Cancel:cancel:danger]] + +Select deployment region: +[[mattermost_select: Choose Region | US East:us-east, US West:us-west, EU:eu, APAC:apac]] +`; +``` + +--- + +### Handling Interactions + +When a user interacts with buttons or selects, Mattermost sends a callback to your configured `interactions.callbackBaseUrl`: + +```json +{ + "type": "button" | "select", + "value": "the_button_or_option_value", + "user_id": "mattermost_user_id", + "channel_id": "channel_id", + "post_id": "original_post_id", + "context": { ... } +} +``` + +**Configuration:** + +```json +{ + "channels": { + "mattermost": { + "interactions": { + "callbackBaseUrl": "https://your-bot.example.com/callbacks", + "allowedSourceIps": ["10.0.0.0/8", "192.168.1.0/24"] + } + } + } +} +``` + +--- + +## Configuration Types + +### MattermostAccountConfig + +```typescript +interface MattermostAccountConfig { + // Core settings + baseUrl?: string; + botToken?: SecretInput; + pat?: SecretInput; + enabled?: boolean; + name?: string; + + // Access control + dmPolicy?: 'open' | 'allowlist' | 'pairing'; + allowFrom?: Array; + groupPolicy?: 'open' | 'allowlist'; + groupAllowFrom?: Array; + requireMention?: boolean; + + // Chat modes + chatmode?: 'oncall' | 'onmessage' | 'onchar'; + oncharPrefixes?: string[]; + + // Message settings + textChunkLimit?: number; + chunkMode?: 'length' | 'newline'; + blockStreaming?: boolean; + blockStreamingCoalesce?: BlockStreamingConfig; + replyToMode?: 'off' | 'first' | 'all'; + responsePrefix?: string; + + // Slash commands + commands?: { + native?: boolean | 'auto'; + nativeSkills?: boolean | 'auto'; + callbackPath?: string; + callbackUrl?: string; + }; + + // Interactions + interactions?: { + callbackBaseUrl?: string; + allowedSourceIps?: string[]; + }; + + // Actions + actions?: { + reactions?: boolean; + downloadFile?: boolean; + delete?: boolean; + }; + + // Security + allowPrivateNetwork?: boolean; + configWrites?: boolean; + dangerouslyAllowNameMatching?: boolean; + + // Retry + dmChannelRetry?: { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + timeoutMs?: number; + }; + + // Group-specific settings + groups?: Record; + + // Capabilities + capabilities?: string[] | { interactiveReplies?: boolean }; + markdown?: MarkdownConfig; +} +``` + +### SecretInput + +Secret values can be provided in multiple formats: + +```typescript +type SecretInput = + | string // Raw value (not recommended for production) + | { $env: string } // Environment variable reference + | { $file: string } // File path containing secret + | { $secretRef: string }; // Secret manager reference +``` + +**Examples:** + +```json +{ + "botToken": "${MATTERMOST_BOT_TOKEN}", + "botToken": { "$env": "MATTERMOST_BOT_TOKEN" }, + "botToken": { "$file": "/run/secrets/bot_token" } +} +``` + +--- + +## Error Handling + +### Error Classes + +All errors extend `MattermostError` with structured information: + +```typescript +class MattermostError extends Error { + readonly code: ErrorCode; + readonly context: ErrorContext; + readonly retryable: boolean; + readonly userMessage: string; + readonly retryAfterMs?: number; + + toJSON(): Record; + toLogString(): string; +} +``` + +### Error Codes + +| Code | Description | Retryable | +|------|-------------|-----------| +| `MATTERMOST_API_ERROR` | General API error | Depends on status | +| `MATTERMOST_API_TIMEOUT` | Request timeout | Yes | +| `MATTERMOST_API_RATE_LIMIT` | Rate limited | Yes | +| `AUTHENTICATION_FAILED` | Invalid credentials | No | +| `TOKEN_INVALID` | Token revoked | No | +| `TOKEN_EXPIRED` | Token expired | No | +| `PERMISSION_DENIED` | Access denied | No | +| `POST_EDIT_DENIED` | Cannot edit post | No | +| `CHANNEL_ACCESS_DENIED` | Channel access denied | No | +| `RESOURCE_NOT_FOUND` | Resource not found | No | +| `VALIDATION_ERROR` | Invalid input | No | +| `CONFIGURATION_ERROR` | Bad configuration | No | +| `MISSING_BOT_TOKEN` | No bot token | No | +| `MISSING_BASE_URL` | No base URL | No | +| `NETWORK_ERROR` | Network failure | Yes | +| `CONNECTION_ERROR` | Cannot connect | Yes | +| `TIMEOUT_ERROR` | Operation timed out | Yes | +| `FILE_TOO_LARGE` | File exceeds limit | No | +| `BLOCKED_FILE_TYPE` | Executable file | No | +| `DOWNLOAD_FAILED` | Download error | Yes | + +### Using Error Boundaries + +```typescript +import { globalErrorBoundary, MattermostError } from '@lumbridgecorp/openclaw-mattermost'; + +// Register error handler +const unsubscribe = globalErrorBoundary.onError((error) => { + if (error instanceof MattermostError) { + console.error(`[${error.code}] ${error.toLogString()}`); + + if (error.retryable) { + console.log(`Retry after: ${error.retryAfterMs}ms`); + } + } +}); + +// Wrap operations +const safeOperation = globalErrorBoundary.wrap( + riskyOperation, + 'my-operation', + { accountId: 'default' } +); + +// Later: unsubscribe +disunsubscribe(); +``` + +### Retry Configuration + +```typescript +import { withRetry } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await withRetry( + 'my-operation', + async () => { + // Your operation here + return await someApiCall(); + }, + { + maxRetries: 5, + initialDelayMs: 1000, + maxDelayMs: 30000, + timeoutMs: 60000, + onRetry: ({ attempt, maxRetries, delayMs, error }) => { + console.log(`Retry ${attempt}/${maxRetries} after ${delayMs}ms: ${error.message}`); + } + } +); +``` + +--- + +## Security Audit + +### collectMattermostSecurityAuditFindings + +Run a comprehensive security audit on your configuration: + +```typescript +import { collectMattermostSecurityAuditFindings } from '@lumbridgecorp/openclaw-mattermost'; + +const findings = await collectMattermostSecurityAuditFindings({ + cfg: OpenClawConfig, + accountId?: string | null, + account: ResolvedMattermostAccount +}); +``` + +**Returns:** Array of `SecurityAuditFinding`: + +```typescript +type SecurityAuditFinding = { + checkId: string; // Unique check identifier + severity: 'info' | 'warn' | 'critical'; + title: string; + detail: string; + remediation?: string; +}; +``` + +### Audit Checks + +| Check ID | Severity | Description | +|----------|----------|-------------| +| `channels.mattermost.pat.hardcoded_token` | Critical | Bot token hardcoded in config | +| `channels.mattermost.pat.short_token` | Warn | Token unusually short | +| `channels.mattermost.pat.missing_token` | Critical | No bot token configured | +| `channels.mattermost.https.insecure_url` | Critical | HTTP instead of HTTPS | +| `channels.mattermost.https.missing_url` | Critical | No base URL configured | +| `channels.mattermost.input.insecure_callback` | Critical | Callback URL uses HTTP | +| `channels.mattermost.input.no_source_ip_restriction` | Warn | No IP allowlist on callbacks | +| `channels.mattermost.network.private_access_enabled` | Warn | Private network access enabled | +| `channels.mattermost.commands.access_groups_disabled` | Critical | Commands bypass access groups | +| `channels.mattermost.commands.no_allowlist` | Warn | No allowlist for slash commands | +| `channels.mattermost.config.legacy_dm_policy` | Info | Legacy dm.policy config found | +| `channels.mattermost.config.legacy_allow_from` | Info | Legacy dm.allowFrom config found | +| `channels.mattermost.allowlist.mutable_entries` | Warn | Non-ID entries in allowlist | +| `channels.mattermost.actions.download_file_enabled` | Info | File download enabled | +| `channels.mattermost.actions.delete_enabled` | Info | Message delete enabled | +| `channels.mattermost.groups.open_policy` | Warn | Open group policy | +| `channels.mattermost.dm.open_policy` | Warn | Open DM policy | +| `channels.mattermost.dm.pairing_mode` | Info | DM pairing mode active | + +**Example:** + +```typescript +import { + collectMattermostSecurityAuditFindings, + collectAllMattermostSecurityAuditFindings +} from '@lumbridgecorp/openclaw-mattermost'; + +// Audit single account +const findings = await collectMattermostSecurityAuditFindings({ + cfg: openclawConfig, + accountId: 'default', + account: resolvedAccount +}); + +// Audit all accounts +const allFindings = await collectAllMattermostSecurityAuditFindings({ + cfg: openclawConfig, + listAccounts: (cfg) => resolveAllAccounts(cfg) +}); + +// Display findings by severity +const critical = findings.filter(f => f.severity === 'critical'); +const warnings = findings.filter(f => f.severity === 'warn'); + +console.log(`Critical issues: ${critical.length}`); +for (const finding of critical) { + console.log(`\n[!] ${finding.title}`); + console.log(` ${finding.detail}`); + console.log(` Fix: ${finding.remediation}`); +} +``` + +--- + +## Client API + +### createMattermostClient + +Create a low-level Mattermost API client: + +```typescript +import { createMattermostClient } from '@lumbridgecorp/openclaw-mattermost'; + +const client = createMattermostClient({ + baseUrl: string; + botToken: string; + pat?: string; + fetchImpl?: MattermostFetch; + allowPrivateNetwork?: boolean; + accountId?: string; +}); +``` + +**Client Methods:** + +| Method | Description | +|--------|-------------| +| `request(path, init?)` | Make authenticated API request | +| `fetchImpl(input, init?)` | Low-level fetch with guards | + +**Request Examples:** + +```typescript +// Get current user +const me = await client.request('/users/me'); + +// Get a channel +const channel = await client.request(`/channels/${channelId}`); + +// Create a post +const post = await client.request('/posts', { + method: 'POST', + body: JSON.stringify({ + channel_id: channelId, + message: 'Hello World' + }) +}); +``` + +### Helper Functions + +```typescript +// User operations +fetchMattermostMe(client) -> MattermostUser +fetchMattermostUser(client, userId) -> MattermostUser +fetchMattermostUserByUsername(client, username) -> MattermostUser + +// Channel operations +fetchMattermostChannel(client, channelId) -> MattermostChannel +fetchMattermostChannelByName(client, teamId, channelName) -> MattermostChannel +createMattermostDirectChannel(client, userIds) -> MattermostChannel +createMattermostDirectChannelWithRetry(client, userIds, options) -> MattermostChannel + +// Post operations +createMattermostPost(client, params) -> MattermostPost +updateMattermostPost(client, postId, params) -> MattermostPost + +// File operations +uploadMattermostFile(client, params) -> MattermostFileInfo + +// Other +sendMattermostTyping(client, { channelId, parentId }) -> void +fetchMattermostUserTeams(client, userId) -> MattermostTeam[] +``` + +--- + +## Type Exports + +```typescript +import { + // Config types + MattermostAccountConfig, + MattermostConfig, + SecretInput, + + // Client types + MattermostClient, + MattermostFetch, + MattermostUser, + MattermostChannel, + MattermostPost, + MattermostFileInfo, + MattermostTeam, + + // Action types + EditMessageParams, + EditMessageResult, + DeleteMessageParams, + DeleteMessageResult, + DownloadFileParams, + DownloadFileResult, + FileMetadata, + + // Error types + MattermostError, + MattermostErrorOptions, + ErrorContext, + ErrorCode, + MattermostAPIError, + AuthenticationError, + ValidationError, + PermissionError, + RateLimitError, + ResourceNotFoundError, + NetworkError, + ConfigurationError, + + // Security types + SecurityAuditFinding, + + // Retry types + RetryConfig, + RetryAttemptInfo +} from '@lumbridgecorp/openclaw-mattermost'; +``` diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..4593fcf --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,562 @@ +# Migration Guide + +Guide for migrating from the stock Mattermost extension to the Enhanced Mattermost Extension. + +## Table of Contents + +- [Overview](#overview) +- [Before You Begin](#before-you-begin) +- [Configuration Migration](#configuration-migration) +- [Breaking Changes](#breaking-changes) +- [Feature Upgrades](#feature-upgrades) +- [Code Migration](#code-migration) +- [Verification](#verification) +- [Rollback Plan](#rollback-plan) + +## Overview + +The Enhanced Mattermost Extension is a drop-in replacement for the stock extension with additional features and improved security. Most existing configurations will work without changes, but there are some differences to be aware of. + +### Key Differences + +| Aspect | Stock Extension | Enhanced Extension | +|--------|--------------|-------------------| +| Package name | `@modelcontextprotocol/mattermost` | `@lumbridgecorp/openclaw-mattermost` | +| Token support | Bot token only | Bot token + PAT | +| Message operations | Send only | Send, edit, delete | +| File operations | None | Download with security | +| Interactive components | None | Buttons and selects | +| Security audit | None | Built-in | +| Error handling | Basic | Structured with retry | +| Configuration | Simple | Extended but backward compatible | + +## Before You Begin + +### Prerequisites + +1. **Backup your configuration** before making any changes +2. **Review current usage** of Mattermost features in your code +3. **Check Node.js version** - requires Node.js 22+ (up from 18+) +4. **Plan for downtime** during the migration window + +### Compatibility Check + +Run this check to see if your current configuration is compatible: + +```bash +# Install the enhanced extension alongside your current one +npm install @lumbridgecorp/openclaw-mattermost + +# Run the security audit to check configuration +npx openclaw doctor --channel=mattermost +``` + +## Configuration Migration + +### Simple Migration (No Code Changes) + +If you're using basic features, simply update your package.json: + +```diff +{ + "dependencies": { +- "@modelcontextprotocol/mattermost": "^0.x.x" ++ "@lumbridgecorp/openclaw-mattermost": "^1.0.0" + } +} +``` + +Your existing configuration will continue to work: + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.example.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "team": "myteam" + } + } +} +``` + +### Enhanced Configuration (Recommended) + +Take advantage of new security features by updating your configuration: + +#### Before (Stock Extension) + +```json +{ + "channels": { + "mattermost": { + "url": "https://chat.example.com", + "token": "${MATTERMOST_TOKEN}", + "team": "engineering" + } + } +} +``` + +#### After (Enhanced Extension) + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.example.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "dmPolicy": "pairing", + "allowFrom": ["user1-id", "user2-id"], + "groupPolicy": "allowlist", + "groupAllowFrom": ["user1-id", "user2-id"], + "interactions": { + "callbackBaseUrl": "https://bot.example.com/callbacks", + "allowedSourceIps": ["10.0.0.0/8"] + }, + "actions": { + "reactions": true, + "downloadFile": true, + "delete": true + } + } + } +} +``` + +### Multi-Account Configuration + +The enhanced extension supports multiple Mattermost accounts: + +```json +{ + "channels": { + "mattermost": { + "accounts": { + "production": { + "baseUrl": "https://chat.company.com", + "botToken": "${PROD_BOT_TOKEN}", + "dmPolicy": "allowlist" + }, + "staging": { + "baseUrl": "https://chat-staging.company.com", + "botToken": "${STAGING_BOT_TOKEN}", + "dmPolicy": "open" + } + }, + "defaultAccount": "production" + } + } +} +``` + +## Breaking Changes + +### 1. Import Path Changes + +**Before:** +```typescript +import { sendMessage } from '@modelcontextprotocol/mattermost'; +``` + +**After:** +```typescript +import { sendMessage } from '@lumbridgecorp/openclaw-mattermost'; +``` + +### 2. Configuration Property Names + +Some property names have changed for clarity: + +| Old Name | New Name | Notes | +|----------|----------|-------| +| `url` | `baseUrl` | Both work, `baseUrl` preferred | +| `token` | `botToken` | Both work, `botToken` preferred | + +### 3. Environment Variable Names + +If you used the stock extension's environment variables: + +| Old Variable | New Variable | Notes | +|--------------|--------------|-------| +| `MATTERMOST_URL` | `MATTERMOST_URL` | No change | +| `MATTERMOST_TOKEN` | `MATTERMOST_BOT_TOKEN` | Recommended change | +| - | `MATTERMOST_PAT` | New optional PAT variable | + +Both old and new variable names are supported for backward compatibility. + +### 4. Type Changes + +Some TypeScript types have been renamed or extended: + +**Before:** +```typescript +import { MattermostConfig } from '@modelcontextprotocol/mattermost'; +``` + +**After:** +```typescript +import { MattermostAccountConfig, MattermostConfig } from '@lumbridgecorp/openclaw-mattermost'; +``` + +## Feature Upgrades + +### Adding PAT Support + +**Step 1:** Create a Personal Access Token in Mattermost + +1. Log in to Mattermost as the bot owner +2. Go to Account Settings > Security > Personal Access Tokens +3. Create a new token + +**Step 2:** Add PAT to your configuration + +```bash +export MATTERMOST_PAT="your-pat-here" +``` + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.example.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "pat": "${MATTERMOST_PAT}" + } + } +} +``` + +### Enabling Interactive Components + +Add interaction configuration to enable buttons and selects: + +```json +{ + "channels": { + "mattermost": { + "baseUrl": "https://chat.example.com", + "botToken": "${MATTERMOST_BOT_TOKEN}", + "interactions": { + "callbackBaseUrl": "https://your-bot.example.com/callbacks", + "allowedSourceIps": ["10.0.0.0/8"] + } + } + } +} +``` + +### Enabling File Downloads + +File downloads are enabled by default but can be controlled: + +```json +{ + "channels": { + "mattermost": { + "actions": { + "downloadFile": true + } + } + } +} +``` + +## Code Migration + +### Sending Messages (No Change Required) + +```typescript +// Both versions work the same +import { sendMessage } from '@lumbridgecorp/openclaw-mattermost'; + +await sendMessage({ + cfg: openclawConfig, + channelId: 'channel123', + message: 'Hello World' +}); +``` + +### Adding Reactions + +**Before:** Limited or no support + +**After:** Full support with type safety + +```typescript +import { addReaction } from '@lumbridgecorp/openclaw-mattermost'; + +await addReaction({ + cfg: openclawConfig, + postId: 'post123', + emoji: 'thumbsup' +}); +``` + +### Editing Messages (New Feature) + +```typescript +import { editMessage } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await editMessage({ + cfg: openclawConfig, + postId: 'post123', + channelId: 'channel123', + message: 'Updated content' +}); + +if (!result.ok) { + console.error('Edit failed:', result.error); +} +``` + +### Interactive Messages (New Feature) + +```typescript +const message = ` +Please confirm: +[[mattermost_buttons: Yes:confirm:primary, No:cancel:danger]] +`; + +await sendMessage({ cfg: openclawConfig, channelId: 'channel123', message }); +``` + +### File Downloads (New Feature) + +```typescript +import { downloadFile } from '@lumbridgecorp/openclaw-mattermost'; + +const result = await downloadFile({ + cfg: openclawConfig, + fileId: 'file123', + destinationPath: '/tmp/download.pdf', + maxSize: 50 * 1024 * 1024 +}); + +if (result.ok) { + console.log('Downloaded:', result.filePath); +} +``` + +### Error Handling Improvements + +**Before:** Basic error handling + +```typescript +try { + await sendMessage({ cfg, channelId, message }); +} catch (error) { + console.error('Failed:', error); +} +``` + +**After:** Structured error handling with retry + +```typescript +import { MattermostError, isRetryableError } from '@lumbridgecorp/openclaw-mattermost'; + +try { + await sendMessage({ cfg, channelId, message }); +} catch (error) { + if (error instanceof MattermostError) { + console.error(`[${error.code}] ${error.userMessage}`); + + if (error.retryable) { + console.log(`Can retry after ${error.retryAfterMs}ms`); + } + + if (error.code === 'AUTHENTICATION_FAILED') { + // Refresh token and retry + } + } +} +``` + +## Verification + +After migration, verify everything works: + +### 1. Run the Security Audit + +```typescript +import { collectMattermostSecurityAuditFindings } from '@lumbridgecorp/openclaw-mattermost'; + +const findings = await collectMattermostSecurityAuditFindings({ + cfg: openclawConfig, + accountId: 'default', + account: resolvedAccount +}); + +const critical = findings.filter(f => f.severity === 'critical'); +if (critical.length > 0) { + console.error('Critical security issues found:', critical); + process.exit(1); +} +``` + +### 2. Test Basic Operations + +```typescript +// Test connection +const me = await fetchMattermostMe(client); +console.log('Connected as:', me.username); + +// Test message sending +const post = await sendMessage({ cfg, channelId: testChannel, message: 'Test' }); +console.log('Sent message:', post.id); + +// Test message editing +const editResult = await editMessage({ + cfg, + postId: post.id, + channelId: testChannel, + message: 'Updated test' +}); +console.log('Edit result:', editResult.ok); + +// Test message deletion +const deleteResult = await deleteMessage({ + cfg, + postId: post.id, + channelId: testChannel +}); +console.log('Delete result:', deleteResult.ok); +``` + +### 3. Checklist + +- [ ] Messages send successfully +- [ ] Messages can be edited +- [ ] Messages can be deleted +- [ ] Reactions can be added +- [ ] Files can be downloaded (if enabled) +- [ ] Interactive components render (if configured) +- [ ] No critical security audit findings +- [ ] Error handling works as expected +- [ ] Rate limiting is handled gracefully + +## Rollback Plan + +If you need to rollback to the stock extension: + +### Step 1: Revert Package + +```bash +npm uninstall @lumbridgecorp/openclaw-mattermost +npm install @modelcontextprotocol/mattermost +``` + +### Step 2: Revert Configuration + +Remove any enhanced-specific configuration: + +```diff +{ + "channels": { + "mattermost": { + "url": "https://chat.example.com", +- "baseUrl": "https://chat.example.com", +- "botToken": "${MATTERMOST_BOT_TOKEN}", +- "pat": "${MATTERMOST_PAT}", +- "dmPolicy": "pairing", ++ "token": "${MATTERMOST_TOKEN}", +- "interactions": { ... }, +- "actions": { ... } + } + } +} +``` + +### Step 3: Revert Code Changes + +Change imports back: + +```diff +- import { sendMessage } from '@lumbridgecorp/openclaw-mattermost'; ++ import { sendMessage } from '@modelcontextprotocol/mattermost'; +``` + +Remove any code using enhanced-only features: +- Message editing +- Message deletion +- File downloads +- Interactive components +- Security audit calls + +## Troubleshooting Migration Issues + +### Issue: "Cannot find module" + +**Solution:** Ensure the package is installed: + +```bash +npm install @lumbridgecorp/openclaw-mattermost +``` + +### Issue: "Configuration validation failed" + +**Solution:** Check for legacy configuration properties: + +```typescript +import { MattermostConfigSchema } from '@lumbridgecorp/openclaw-mattermost'; + +const result = MattermostConfigSchema.safeParse(yourConfig); +if (!result.success) { + console.error('Config errors:', result.error.errors); +} +``` + +### Issue: "Authentication failed" + +**Solution:** Verify token format and permissions: + +```bash +# Test token with curl +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://chat.example.com/api/v4/users/me +``` + +### Issue: "Interactive components not working" + +**Solution:** Check interaction callback configuration: + +1. Verify `interactions.callbackBaseUrl` is HTTPS +2. Ensure `allowedSourceIps` includes your Mattermost server +3. Check that the callback endpoint is accessible +4. Verify Mattermost can reach your callback URL + +### Issue: "File download blocked" + +**Solution:** Check file restrictions: + +1. Verify file is not an executable type +2. Check file size against `maxSize` limit +3. Ensure bot has file access permissions +4. Check MIME type is in allowlist + +## Getting Help + +If you encounter issues during migration: + +1. **Check the security audit output** for configuration issues +2. **Review the [API Reference](API.md)** for correct usage +3. **Search [GitHub Issues](https://github.com/lumbridgecorp/openclaw-extentions-mattermost/issues)** for similar problems +4. **Create a new issue** with: + - Your migration step + - Error messages + - Configuration (with secrets redacted) + - Expected vs actual behavior + +## Migration Summary + +| Task | Effort | Notes | +|------|--------|-------| +| Package update | 5 min | Simple npm/pnpm/yarn command | +| Basic config migration | 10 min | Property name changes | +| PAT setup (optional) | 15 min | Create token, update config | +| Interactive components | 30 min | Add interaction config | +| Code migration | 1-2 hours | Update imports, add features | +| Testing | 30 min | Verify all operations work | +| Security audit | 15 min | Fix any findings | + +**Total estimated time: 2-4 hours** for a complete migration with all features. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c725622 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; + +export default [ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + { + ignores: ['dist/', 'node_modules/', '**/*.test.ts'], + }, +]; diff --git a/extensions/mattermost/api.ts b/extensions/mattermost/api.ts new file mode 100644 index 0000000..629bba8 --- /dev/null +++ b/extensions/mattermost/api.ts @@ -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"; diff --git a/extensions/mattermost/contract-api.ts b/extensions/mattermost/contract-api.ts new file mode 100644 index 0000000..c55b65e --- /dev/null +++ b/extensions/mattermost/contract-api.ts @@ -0,0 +1,6 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; + +export { collectMattermostSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/mattermost/contract-surfaces.ts b/extensions/mattermost/contract-surfaces.ts new file mode 100644 index 0000000..5b44a1c --- /dev/null +++ b/extensions/mattermost/contract-surfaces.ts @@ -0,0 +1,6 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; + +export const defaultMarkdownTableMode = "off"; diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts new file mode 100644 index 0000000..a40971b --- /dev/null +++ b/extensions/mattermost/index.ts @@ -0,0 +1,20 @@ +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { mattermostPlugin } from "./src/channel.js"; +import { registerSlashCommandRoute } from "./src/mattermost/slash-state.js"; +import { setMattermostRuntime } from "./src/runtime.js"; + +export { mattermostPlugin } from "./src/channel.js"; +export { setMattermostRuntime } from "./src/runtime.js"; + +export default defineChannelPluginEntry({ + id: "mattermost", + name: "Mattermost", + description: "Mattermost channel plugin", + plugin: mattermostPlugin, + setRuntime: 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); + }, +}); diff --git a/extensions/mattermost/openclaw.plugin.json b/extensions/mattermost/openclaw.plugin.json new file mode 100644 index 0000000..91bbca1 --- /dev/null +++ b/extensions/mattermost/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "mattermost", + "channels": ["mattermost"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json new file mode 100644 index 0000000..921617c --- /dev/null +++ b/extensions/mattermost/package.json @@ -0,0 +1,41 @@ +{ + "name": "@openclaw/mattermost", + "version": "2026.4.4", + "description": "OpenClaw Mattermost channel plugin", + "type": "module", + "dependencies": { + "@sinclair/typebox": "0.34.49", + "ws": "^8.20.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.4.4" + }, + "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": { + "npmSpec": "@openclaw/mattermost", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.4" + } + } +} diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts new file mode 100644 index 0000000..e77586c --- /dev/null +++ b/extensions/mattermost/runtime-api.ts @@ -0,0 +1,88 @@ +// 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/command-auth"; +export type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +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-lifecycle"; +export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"; +export { + buildModelsProviderData, + listSkillCommandsForAgents, + resolveControlCommandGate, + resolveStoredModelOverride, +} from "openclaw/plugin-sdk/command-auth"; +export { + GROUP_POLICY_BLOCKED_LABEL, + isDangerousNameMatchingEnabled, + loadSessionStore, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveStorePath, + warnMissingProviderGroupPolicyFallbackOnce, +} from "openclaw/plugin-sdk/config-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 { + DM_GROUP_ACCESS_REASON, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, + resolveEffectiveAllowFromLists, +} from "openclaw/plugin-sdk/channel-policy"; +export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +export { rawDataToString } from "openclaw/plugin-sdk/browser-support"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + DEFAULT_GROUP_HISTORY_LIMIT, + 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"; diff --git a/extensions/mattermost/setup-entry.ts b/extensions/mattermost/setup-entry.ts new file mode 100644 index 0000000..34ce409 --- /dev/null +++ b/extensions/mattermost/setup-entry.ts @@ -0,0 +1,4 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { mattermostPlugin } from "./src/channel.js"; + +export default defineSetupPluginEntry(mattermostPlugin); diff --git a/extensions/mattermost/src/approval-auth.test.ts b/extensions/mattermost/src/approval-auth.test.ts new file mode 100644 index 0000000..ec3ba05 --- /dev/null +++ b/extensions/mattermost/src/approval-auth.test.ts @@ -0,0 +1,28 @@ +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 }); + }); +}); diff --git a/extensions/mattermost/src/approval-auth.ts b/extensions/mattermost/src/approval-auth.ts new file mode 100644 index 0000000..6016dbf --- /dev/null +++ b/extensions/mattermost/src/approval-auth.ts @@ -0,0 +1,29 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-auth-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() + .toLowerCase(); + return MATTERMOST_USER_ID_RE.test(normalized) ? normalized : 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), +}); diff --git a/extensions/mattermost/src/channel-api.ts b/extensions/mattermost/src/channel-api.ts new file mode 100644 index 0000000..e63716c --- /dev/null +++ b/extensions/mattermost/src/channel-api.ts @@ -0,0 +1,8 @@ +export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +export type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core"; +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts new file mode 100644 index 0000000..0c4ecc9 --- /dev/null +++ b/extensions/mattermost/src/channel.test.ts @@ -0,0 +1,618 @@ +import { Type } from "@sinclair/typebox"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { createChannelReplyPipeline } from "../runtime-api.js"; + +vi.mock("../../../src/config/bundled-channel-config-runtime.js", () => ({ + getBundledChannelRuntimeMap: () => new Map(), + getBundledChannelConfigSchemaMap: () => new Map(), +})); + +const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({ + sendMessageMattermostMock: vi.fn(), + mockFetchGuard: vi.fn(async (p: { url: string; init?: RequestInit }) => { + const response = await globalThis.fetch(p.url, p.init); + return { response, release: async () => {}, finalUrl: p.url }; + }), +})); + +vi.mock("./mattermost/send.js", () => ({ + sendMessageMattermost: sendMessageMattermostMock, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { + const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record< + string, + unknown + >; + return { ...original, fetchWithSsrFGuard: mockFetchGuard }; +}); + +import { + createMattermostReactionFetchMock, + createMattermostTestConfig, + withMockedGlobalFetch, +} from "./mattermost/reactions.test-helpers.js"; + +let mattermostPlugin: typeof import("./channel.js").mattermostPlugin; +let resetMattermostReactionBotUserCacheForTests: typeof import("./mattermost/reactions.js").resetMattermostReactionBotUserCacheForTests; + +type MattermostHandleAction = NonNullable< + NonNullable["handleAction"] +>; +type MattermostActionContext = Parameters[0]; +type MattermostSendText = NonNullable["sendText"]>; +type MattermostSendTextParams = Parameters[0]; +type MattermostSendMedia = NonNullable["sendMedia"]>; +type MattermostSendMediaParams = Parameters[0]; + +function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[] { + return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])]; +} + +function requireMattermostNormalizeTarget() { + const normalize = mattermostPlugin.messaging?.normalizeTarget; + if (!normalize) { + throw new Error("mattermost messaging.normalizeTarget missing"); + } + return normalize; +} + +function requireMattermostPairingNormalizer() { + const normalize = mattermostPlugin.pairing?.normalizeAllowEntry; + if (!normalize) { + throw new Error("mattermost pairing.normalizeAllowEntry missing"); + } + return normalize; +} + +function requireMattermostReplyToModeResolver() { + const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode; + if (!resolveReplyToMode) { + throw new Error("mattermost threading.resolveReplyToMode missing"); + } + return resolveReplyToMode; +} + +function requireMattermostSendText() { + const sendText = mattermostPlugin.outbound?.sendText; + if (!sendText) { + throw new Error("mattermost outbound.sendText missing"); + } + return sendText; +} + +function requireMattermostSendMedia() { + const sendMedia = mattermostPlugin.outbound?.sendMedia; + if (!sendMedia) { + throw new Error("mattermost outbound.sendMedia missing"); + } + return sendMedia; +} + +function requireMattermostChunker() { + const chunker = mattermostPlugin.outbound?.chunker; + if (!chunker) { + throw new Error("mattermost outbound.chunker missing"); + } + return chunker; +} + +function createMattermostActionContext( + overrides: Partial, +): MattermostActionContext { + return { + channel: "mattermost", + action: "send", + params: {}, + cfg: createMattermostTestConfig(), + ...overrides, + }; +} + +describe("mattermostPlugin", () => { + beforeAll(async () => { + ({ mattermostPlugin } = await import("./channel.js")); + ({ resetMattermostReactionBotUserCacheForTests } = await import("./mattermost/reactions.js")); + }); + + beforeEach(() => { + sendMessageMattermostMock.mockReset(); + sendMessageMattermostMock.mockResolvedValue({ + messageId: "post-1", + channelId: "channel-1", + }); + }); + + describe("messaging", () => { + it("keeps @username targets", () => { + const normalize = requireMattermostNormalizeTarget(); + + expect(normalize("@Alice")).toBe("@Alice"); + expect(normalize("@alice")).toBe("@alice"); + }); + + it("normalizes spaced mattermost prefixes to user targets", () => { + const normalize = requireMattermostNormalizeTarget(); + + expect(normalize("mattermost:USER123")).toBe("user:USER123"); + expect(normalize(" mattermost:USER123 ")).toBe("user:USER123"); + }); + }); + + describe("pairing", () => { + it("normalizes allowlist entries", () => { + const normalize = requireMattermostPairingNormalizer(); + + expect(normalize("@Alice")).toBe("alice"); + expect(normalize("user:USER123")).toBe("user123"); + expect(normalize(" @Alice ")).toBe("alice"); + expect(normalize(" mattermost:USER123 ")).toBe("user123"); + }); + }); + + describe("threading", () => { + it("uses replyToMode for channel messages and keeps direct messages off", () => { + const resolveReplyToMode = requireMattermostReplyToModeResolver(); + + const cfg: OpenClawConfig = { + channels: { + mattermost: { + replyToMode: "all", + }, + }, + }; + + expect( + resolveReplyToMode({ + cfg, + accountId: "default", + chatType: "channel", + }), + ).toBe("all"); + expect( + resolveReplyToMode({ + cfg, + accountId: "default", + chatType: "direct", + }), + ).toBe("off"); + }); + + it("uses configured defaultAccount when accountId is omitted", () => { + const resolveReplyToMode = requireMattermostReplyToModeResolver(); + + const cfg: OpenClawConfig = { + channels: { + mattermost: { + defaultAccount: "alerts", + replyToMode: "off", + accounts: { + alerts: { + replyToMode: "all", + botToken: "alerts-token", + baseUrl: "https://alerts.example.com", + }, + }, + }, + }, + }; + + expect( + resolveReplyToMode({ + cfg, + chatType: "channel", + }), + ).toBe("all"); + }); + }); + + describe("messageActions", () => { + beforeEach(() => { + resetMattermostReactionBotUserCacheForTests(); + }); + + const runReactAction = async (params: Record, fetchMode: "add" | "remove") => { + const cfg = createMattermostTestConfig(); + const fetchImpl = createMattermostReactionFetchMock({ + mode: fetchMode, + postId: "POST1", + emojiName: "thumbsup", + }); + + return await withMockedGlobalFetch(fetchImpl, async () => { + return await mattermostPlugin.actions?.handleAction?.( + createMattermostActionContext({ + action: "react", + params, + cfg, + accountId: "default", + }), + ); + }); + }; + + it("exposes react when mattermost is configured", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + }; + + const actions = getDescribedActions(cfg); + expect(actions).toContain("react"); + expect(actions).toContain("send"); + expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true); + expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true); + }); + + it("hides react when mattermost is not configured", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + }, + }, + }; + + const actions = getDescribedActions(cfg); + expect(actions).toEqual([]); + }); + + it("keeps buttons optional in message tool schema", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + }; + + const discovery = mattermostPlugin.actions?.describeMessageTool?.({ cfg }); + const schema = discovery?.schema; + if (!schema || Array.isArray(schema)) { + throw new Error("expected mattermost message-tool schema"); + } + + expect(Type.Object(schema.properties).required).toBeUndefined(); + }); + + it("hides react when actions.reactions is false", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + }, + }; + + const actions = getDescribedActions(cfg); + expect(actions).not.toContain("react"); + expect(actions).toContain("send"); + }); + + it("respects per-account actions.reactions in message discovery", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + actions: { reactions: false }, + accounts: { + default: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: true }, + }, + }, + }, + }, + }; + + const actions = getDescribedActions(cfg); + expect(actions).toContain("react"); + }); + + it("honors the selected Mattermost account during discovery", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + actions: { reactions: false }, + accounts: { + default: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + work: { + enabled: true, + botToken: "work-token", + baseUrl: "https://chat.example.com", + actions: { reactions: true }, + }, + }, + }, + }, + }; + + expect(getDescribedActions(cfg, "default")).toEqual(["send"]); + expect(getDescribedActions(cfg, "work")).toEqual(["send", "react"]); + }); + + it("blocks react when default account disables reactions and accountId is omitted", async () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + actions: { reactions: true }, + accounts: { + default: { + enabled: true, + // pragma: allowlist secret + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + }, + }, + }, + }; + + await expect( + mattermostPlugin.actions?.handleAction?.( + createMattermostActionContext({ + action: "react", + params: { messageId: "POST1", emoji: "thumbsup" }, + cfg, + }), + ), + ).rejects.toThrow("Mattermost reactions are disabled in config"); + }); + + it("handles react by calling Mattermost reactions API", async () => { + const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add"); + + expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); + expect(result?.details).toEqual({}); + }); + + it("only treats boolean remove flag as removal", async () => { + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: "true" }, + "add", + ); + + expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); + }); + + it("removes reaction when remove flag is boolean true", async () => { + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: true }, + "remove", + ); + + expect(result?.content).toEqual([ + { type: "text", text: "Removed reaction :thumbsup: from POST1" }, + ]); + expect(result?.details).toEqual({}); + }); + + it("maps replyTo to replyToId for send actions", async () => { + const cfg = createMattermostTestConfig(); + + await mattermostPlugin.actions?.handleAction?.( + createMattermostActionContext({ + action: "send", + params: { + to: "channel:CHAN1", + message: "hello", + replyTo: "post-root", + }, + cfg, + accountId: "default", + }), + ); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + + it("falls back to trimmed replyTo when replyToId is blank", async () => { + const cfg = createMattermostTestConfig(); + + await mattermostPlugin.actions?.handleAction?.( + createMattermostActionContext({ + action: "send", + params: { + to: "channel:CHAN1", + message: "hello", + replyToId: " ", + replyTo: " post-root ", + }, + cfg, + accountId: "default", + }), + ); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + }); + + describe("outbound", () => { + it("chunks outbound text without requiring Mattermost runtime initialization", () => { + const chunker = requireMattermostChunker(); + + expect(() => chunker("hello world", 5)).not.toThrow(); + expect(chunker("hello world", 5)).toEqual(["hello", "world"]); + }); + + it("forwards mediaLocalRoots on sendMedia", async () => { + const sendMedia = requireMattermostSendMedia(); + const cfg = createMattermostTestConfig(); + + const params: MattermostSendMediaParams = { + cfg, + to: "channel:CHAN1", + text: "hello", + mediaUrl: "/tmp/workspace/image.png", + mediaLocalRoots: ["/tmp/workspace"], + accountId: "default", + replyToId: "post-root", + }; + + await sendMedia(params); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + mediaUrl: "/tmp/workspace/image.png", + mediaLocalRoots: ["/tmp/workspace"], + }), + ); + }); + + it("threads resolved cfg on sendText", async () => { + const sendText = requireMattermostSendText(); + const cfg = { + channels: { + mattermost: { + // pragma: allowlist secret + botToken: "resolved-bot-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig; + + const params: MattermostSendTextParams = { + cfg, + to: "channel:CHAN1", + text: "hello", + accountId: "default", + }; + + await sendText(params); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + cfg, + accountId: "default", + }), + ); + }); + + it("uses threadId as fallback when replyToId is absent (sendText)", async () => { + const sendText = requireMattermostSendText(); + const cfg = createMattermostTestConfig(); + + const params: MattermostSendTextParams = { + cfg, + to: "channel:CHAN1", + text: "hello", + accountId: "default", + threadId: "post-root", + }; + + await sendText(params); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + + it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => { + const sendMedia = requireMattermostSendMedia(); + const cfg = createMattermostTestConfig(); + + const params: MattermostSendMediaParams = { + cfg, + to: "channel:CHAN1", + text: "caption", + mediaUrl: "https://example.com/image.png", + accountId: "default", + threadId: "post-root", + }; + + await sendMedia(params); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "caption", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + }); + + describe("config", () => { + it("formats allowFrom entries", () => { + const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!; + + const formatted = formatAllowFrom({ + cfg: {} as OpenClawConfig, + allowFrom: [" @Alice ", " user:USER123 ", " mattermost:BOT999 "], + }); + expect(formatted).toEqual(["@alice", "user123", "bot999"]); + }); + + it("uses account responsePrefix overrides", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + responsePrefix: "[Channel]", + accounts: { + default: { responsePrefix: "[Account]" }, + }, + }, + }, + }; + + const prefixContext = createChannelReplyPipeline({ + cfg, + agentId: "main", + channel: "mattermost", + accountId: "default", + }); + + expect(prefixContext.responsePrefix).toBe("[Account]"); + }); + }); +}); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts new file mode 100644 index 0000000..380a711 --- /dev/null +++ b/extensions/mattermost/src/channel.ts @@ -0,0 +1,730 @@ +import { Type } from "@sinclair/typebox"; +import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; +import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions"; +import { + adaptScopedAccountAccessor, + createScopedChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing"; +import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; +import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { + createComputedAccountStatusAdapter, + createDefaultChannelRuntimeState, +} from "openclaw/plugin-sdk/status-helpers"; +import { mattermostApprovalAuth } from "./approval-auth.js"; +import { + compileMattermostInteractiveReplies, + isMattermostInteractiveRepliesEnabled, +} from "./interactive-replies.js"; +import { + chunkTextForOutbound, + createAccountStatusSink, + DEFAULT_ACCOUNT_ID, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + type ChannelPlugin, +} from "./channel-api.js"; +import { MattermostChannelConfigSchema } from "./config-surface.js"; +import { collectMattermostMutableAllowlistWarnings } from "./doctor.js"; +import { collectMattermostSecurityAuditFindings } from "./security-audit.js"; +import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; +import { + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, + resolveMattermostReplyToMode, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; +import { + listMattermostDirectoryGroups, + listMattermostDirectoryPeers, +} from "./mattermost/directory.js"; +import { monitorMattermostProvider } from "./mattermost/monitor.js"; +import { probeMattermost } from "./mattermost/probe.js"; +import { deleteMessage, editMessage } from "./mattermost/actions.js"; +import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; +import { sendMessageMattermost } from "./mattermost/send.js"; +import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js"; +import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; +import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; +import { getMattermostRuntime } from "./runtime.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"; +import type { OpenClawConfig } from "./mattermost/runtime-api.js"; + +const mattermostSecurityAdapter = createRestrictSendersChannelSecurity({ + 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 +>[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; delete?: boolean; edit?: boolean } + | undefined; + const baseReactions = actionsConfig?.reactions; + const hasReactionCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { reactions?: boolean } | undefined; + return (accountActions?.reactions ?? baseReactions ?? true) !== false; + }); + if (hasReactionCapableAccount) { + actions.push("react"); + } + + const baseDelete = actionsConfig?.delete; + const hasDeleteCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { delete?: boolean } | undefined; + return (accountActions?.delete ?? baseDelete ?? true) !== false; + }); + if (hasDeleteCapableAccount) { + actions.push("delete"); + } + + const baseEdit = actionsConfig?.edit; + const hasEditCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { edit?: boolean } | undefined; + return (accountActions?.edit ?? baseEdit ?? true) !== false; + }); + if (hasEditCapableAccount) { + actions.push("edit"); + } + + return { + actions, + capabilities: enabledAccounts.length > 0 ? ["buttons", "interactive"] : [], + schema: + enabledAccounts.length > 0 + ? { + properties: { + buttons: Type.Optional(createMessageToolButtonsSchema()), + }, + } + : null, + }; +} + +const mattermostMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: describeMattermostMessageTool, + supportsAction: ({ action }) => { + return action === "send" || action === "react" || action === "delete" || action === "edit"; + }, + handleAction: async ({ action, params, cfg, accountId }) => { + 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 removeMattermostReaction({ + cfg, + postId, + emojiName, + accountId: resolvedAccountId, + }); + if (!result.ok) { + throw new Error(result.error); + } + return { + content: [ + { type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` }, + ], + details: {}, + }; + } + + const result = await addMattermostReaction({ + cfg, + 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 === "edit") { + const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg); + const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined; + const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId }); + const editEnabled = + account.config.actions?.edit ?? mattermostConfig?.actions?.edit ?? true; + if (!editEnabled) { + throw new Error("Mattermost edit is disabled in config"); + } + + const postId = readTrimmedString(params.postId) ?? readTrimmedString(params.messageId); + if (!postId) { + throw new Error("Mattermost edit requires postId (messageId)"); + } + + const channelId = readTrimmedString(params.channelId); + if (!channelId) { + throw new Error("Mattermost edit requires channelId"); + } + + const message = readTrimmedString(params.message) ?? ""; + const props = + typeof params.props === "object" && params.props !== null + ? (params.props as Record) + : undefined; + + if (!message && !props) { + throw new Error("Mattermost edit requires message or props"); + } + + const result = await editMessage({ + cfg, + postId, + channelId, + message, + props, + accountId: resolvedAccountId, + }); + + if (!result.ok) { + throw new Error(result.error); + } + + return { + content: [{ type: "text" as const, text: `Edited message ${postId}` }], + details: { postId: result.postId, channelId: result.channelId }, + }; + } + + if (action === "delete") { + const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg); + const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined; + const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId }); + const deleteEnabled = + account.config.actions?.delete ?? mattermostConfig?.actions?.delete ?? true; + if (!deleteEnabled) { + throw new Error("Mattermost delete is disabled in config"); + } + + const postId = readTrimmedString(params.postId) ?? readTrimmedString(params.messageId); + if (!postId) { + throw new Error("Mattermost delete requires postId (messageId)"); + } + + const result = await deleteMessage({ + cfg, + postId, + accountId: resolvedAccountId, + }); + + if (!result.ok) { + throw new Error(result.error); + } + + return { + content: [{ type: "text" as const, text: `Deleted message ${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 message = typeof params.message === "string" ? params.message : ""; + // Match the shared runner semantics: trim empty reply IDs away before + // falling back from replyToId to replyTo on direct plugin calls. + const replyToId = readMattermostReplyToId(params); + const resolvedAccountId = accountId || undefined; + + const mediaUrl = + typeof params.media === "string" ? params.media.trim() || undefined : undefined; + + // Parse interactive directives from the message if interactiveReplies capability is enabled + const interactiveButtons = isMattermostInteractiveRepliesEnabled({ + cfg, + accountId: resolvedAccountId, + }) + ? parseInteractiveDirectivesToButtons(message) + : undefined; + + // Merge parsed directives with any explicitly provided buttons + const mergedButtons = mergeButtons(interactiveButtons, params.buttons); + + const result = await sendMessageMattermost(to, message, { + accountId: resolvedAccountId, + replyToId, + buttons: mergedButtons, + attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined, + mediaUrl, + }); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + ok: true, + channel: "mattermost", + messageId: result.messageId, + channelId: result.channelId, + }), + }, + ], + details: {}, + }; + }, +}; + +const meta = { + 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; + +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseMattermostReactActionParams(params: Record): { + postId: string; + emojiName: string; + remove: boolean; +} { + const postId = readTrimmedString(params.messageId) ?? readTrimmedString(params.postId); + if (!postId) { + throw new Error("Mattermost react requires messageId (post id)"); + } + + const emojiName = readTrimmedString(params.emoji)?.replace(/^:+|:+$/g, ""); + if (!emojiName) { + throw new Error("Mattermost react requires emoji"); + } + + return { + postId, + emojiName, + remove: params.remove === true, + }; +} + +function readMattermostReplyToId(params: Record): string | undefined { + return readTrimmedString(params.replyToId) ?? readTrimmedString(params.replyTo); +} + +function normalizeAllowEntry(entry: string): string { + return entry + .trim() + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +/** + * Parse interactive directives from message text and convert to buttons array. + * Supports [[mattermost_buttons: ...]] and [[mattermost_select: ...]] directives. + */ +function parseInteractiveDirectivesToButtons(message: string): Array> | undefined { + const result = compileMattermostInteractiveReplies({ text: message }); + const blocks = result.interactive?.blocks; + if (!blocks || blocks.length === 0) { + return undefined; + } + + const buttons: Array> = []; + + for (const block of blocks) { + if (block.type === "buttons" && Array.isArray(block.buttons)) { + for (const btn of block.buttons) { + if (typeof btn === "object" && btn !== null) { + buttons.push({ + id: String((btn as { value?: string }).value ?? "").replace(/\s+/g, "_"), + name: String((btn as { label?: string }).label ?? ""), + text: String((btn as { label?: string }).label ?? ""), + callback_data: String((btn as { value?: string }).value ?? ""), + style: (btn as { style?: string }).style ?? "default", + }); + } + } + } + } + + return buttons.length > 0 ? buttons : undefined; +} + +/** + * Merge parsed directive buttons with explicitly provided buttons. + * Explicit buttons take precedence over parsed directives. + */ +function mergeButtons( + parsedButtons: Array> | undefined, + explicitButtons: unknown, +): Array> | undefined { + const explicit = Array.isArray(explicitButtons) ? explicitButtons : []; + const parsed = parsedButtons ?? []; + + if (explicit.length === 0 && parsed.length === 0) { + return undefined; + } + + // Deduplicate by callback_data/value + const seenValues = new Set(); + const merged: Array> = []; + + for (const btn of [...explicit, ...parsed]) { + if (typeof btn !== "object" || btn === null) { + continue; + } + const value = String( + (btn as { callback_data?: string; value?: string }).callback_data ?? + (btn as { callback_data?: string; value?: string }).value ?? + "", + ); + if (value && !seenValues.has(value)) { + seenValues.add(value); + merged.push(btn as Record); + } + } + + return merged.length > 0 ? merged : undefined; +} + +function formatAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + return username ? `@${username.toLowerCase()}` : ""; + } + return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase(); +} + +const mattermostConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: "mattermost", + listAccountIds: listMattermostAccountIds, + resolveAccount: adaptScopedAccountAccessor(resolveMattermostAccount), + defaultAccountId: resolveDefaultMattermostAccountId, + clearBaseFields: ["botToken", "baseUrl", "name"], + resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: formatAllowEntry, + }), +}); + +export const mattermostPlugin: ChannelPlugin = 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: (account) => Boolean(account.botToken && account.baseUrl), + describeAccount: (account) => + describeAccountSnapshot({ + account, + configured: Boolean(account.botToken && account.baseUrl), + extra: { + botTokenSource: account.botTokenSource, + baseUrl: account.baseUrl, + }, + }), + }, + auth: mattermostApprovalAuth as any, + doctor: { + collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings, + }, + groups: { + resolveRequireMention: resolveMattermostGroupRequireMention, + }, + actions: mattermostMessageActions, + directory: createChannelDirectoryAdapter({ + listGroups: async (params) => listMattermostDirectoryGroups(params), + listGroupsLive: async (params) => listMattermostDirectoryGroups(params), + listPeers: async (params) => listMattermostDirectoryPeers(params), + listPeersLive: async (params) => listMattermostDirectoryPeers(params), + }), + messaging: { + normalizeTarget: normalizeMattermostMessagingTarget, + resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params), + targetResolver: { + looksLikeId: looksLikeMattermostTargetId, + hint: "", + resolveTarget: async ({ cfg, accountId, input }) => { + const resolved = await resolveMattermostOpaqueTarget({ + input, + cfg, + accountId, + }); + if (!resolved) { + return null; + } + return { + to: resolved.to, + kind: resolved.kind, + source: "directory", + }; + }, + }, + }, + status: createComputedAccountStatusAdapter({ + 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 probeMattermost( + baseUrl, + token, + timeoutMs, + account.config.allowPrivateNetwork === true, + ); + }, + 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, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastDisconnect: runtime?.lastDisconnect ?? null, + }, + }), + }), + gateway: { + resolveGatewayAuthBypassPaths: ({ cfg }) => { + const base = cfg.channels?.mattermost; + const callbackPaths = new Set( + collectMattermostSlashCallbackPaths(base?.commands).filter( + (path) => + path === "/api/channels/mattermost/command" || + path.startsWith("/api/channels/mattermost/"), + ), + ); + const accounts = base?.accounts ?? {}; + for (const account of Object.values(accounts)) { + const accountConfig = + account && typeof account === "object" && !Array.isArray(account) + ? (account as { + commands?: Parameters[0]; + }) + : undefined; + for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) { + if ( + path === "/api/channels/mattermost/command" || + path.startsWith("/api/channels/mattermost/") + ) { + callbackPaths.add(path); + } + } + } + return [...callbackPaths]; + }, + 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 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: { + scopedAccountReplyToMode: { + resolveAccount: (cfg, accountId) => + resolveMattermostAccount({ + cfg, + accountId: accountId ?? resolveDefaultMattermostAccountId(cfg), + }), + resolveReplyToMode: (account, chatType) => + resolveMattermostReplyToMode( + account, + chatType === "direct" || chatType === "group" || chatType === "channel" + ? chatType + : "channel", + ), + }, + }, + security: { + ...mattermostSecurityAdapter, + collectAuditFindings: async (params) => { + const account = params.account; + return await collectMattermostSecurityAuditFindings({ + cfg: params.sourceConfig, + accountId: account?.accountId, + account, + }); + }, + }, + outbound: { + base: { + deliveryMode: "direct", + chunker: chunkTextForOutbound, + chunkerMode: "markdown", + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Mattermost requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + }, + attachedResults: { + channel: "mattermost", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + replyToId, + threadId, + }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + }, + }, +}); diff --git a/extensions/mattermost/src/config-runtime.ts b/extensions/mattermost/src/config-runtime.ts new file mode 100644 index 0000000..e108645 --- /dev/null +++ b/extensions/mattermost/src/config-runtime.ts @@ -0,0 +1,7 @@ +export { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk/channel-config-schema"; diff --git a/extensions/mattermost/src/config-schema-core.ts b/extensions/mattermost/src/config-schema-core.ts new file mode 100644 index 0000000..b7d7262 --- /dev/null +++ b/extensions/mattermost/src/config-schema-core.ts @@ -0,0 +1,134 @@ +import { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk/channel-config-primitives"; +import { z } from "openclaw/plugin-sdk/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; + 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 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(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + replyToMode: z.enum(["off", "first", "all"]).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(), + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ + allowPrivateNetwork: z.boolean().optional(), + /** 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, + }); +}); diff --git a/extensions/mattermost/src/config-schema.test.ts b/extensions/mattermost/src/config-schema.test.ts new file mode 100644 index 0000000..3856149 --- /dev/null +++ b/extensions/mattermost/src/config-schema.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { MattermostConfigSchema } from "./config-schema.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("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); + }); +}); diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts new file mode 100644 index 0000000..93a12b9 --- /dev/null +++ b/extensions/mattermost/src/config-schema.ts @@ -0,0 +1,135 @@ +import { z } from "openclaw/plugin-sdk/zod"; +import { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + requireOpenAllowFrom, +} from "./config-runtime.js"; +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; + 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 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(), + pat: 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(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + replyToMode: z.enum(["off", "first", "all"]).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(), + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ + allowPrivateNetwork: z.boolean().optional(), + /** 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, + }); +}); diff --git a/extensions/mattermost/src/config-surface.ts b/extensions/mattermost/src/config-surface.ts new file mode 100644 index 0000000..dcf5eff --- /dev/null +++ b/extensions/mattermost/src/config-surface.ts @@ -0,0 +1,5 @@ +import type { ChannelConfigSchema } from "openclaw/plugin-sdk"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives"; +import { MattermostConfigSchema } from "./config-schema-core.js"; + +export const MattermostChannelConfigSchema: ChannelConfigSchema = buildChannelConfigSchema(MattermostConfigSchema); diff --git a/extensions/mattermost/src/doctor.ts b/extensions/mattermost/src/doctor.ts new file mode 100644 index 0000000..b2855da --- /dev/null +++ b/extensions/mattermost/src/doctor.ts @@ -0,0 +1,322 @@ +import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + type ChannelDoctorAdapter, + type ChannelDoctorConfigMutation, + type ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime"; +import { isMattermostMutableAllowEntry } from "./security-audit.js"; + +/** + * Check if an allowlist entry is mutable (uses name/username instead of stable ID). + * Mattermost IDs are 26-character alphanumeric strings. + */ +function isMattermostMutableAllowEntryLocal(raw: string): boolean { + const text = raw.trim(); + if (!text || text === "*") { + return false; + } + + const normalized = text + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .trim() + .toLowerCase(); + + // Mattermost IDs are exactly 26 alphanumeric characters + if (/^[a-z0-9]{26}$/.test(normalized)) { + return false; + } + + return true; +} + +/** + * Collect warnings for mutable allowlist entries that should be migrated to stable IDs. + */ +export const collectMattermostMutableAllowlistWarnings = + createDangerousNameMatchingMutableAllowlistWarningCollector({ + channel: "mattermost", + detector: isMattermostMutableAllowEntryLocal, + collectLists: (scope) => [ + { + pathLabel: `${scope.prefix}.allowFrom`, + list: scope.account.allowFrom, + }, + { + pathLabel: `${scope.prefix}.groupAllowFrom`, + list: scope.account.groupAllowFrom, + }, + ], + }); + +/** + * Type guard for object records. + */ +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Check if two allowFrom lists are equal. + */ +function allowFromEqual(a: unknown, b: unknown): boolean { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + const na = a.map((v) => String(v).trim()).filter(Boolean); + const nb = b.map((v) => String(v).trim()).filter(Boolean); + if (na.length !== nb.length) { + return false; + } + return na.every((v, i) => v === nb[i]); +} + +/** + * Normalize Mattermost DM aliases for legacy configuration migration. + * Handles migration of dm.policy -> dmPolicy and dm.allowFrom -> allowFrom. + */ +function normalizeMattermostDmAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; + let dmChanged = false; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if ( + topDmPolicy !== undefined && + legacyDmPolicy !== undefined && + topDmPolicy === legacyDmPolicy + ) { + if (dm) { + delete dm.policy; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } + + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + params.changes.push( + `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, + ); + } else if ( + topAllowFrom !== undefined && + legacyAllowFrom !== undefined && + allowFromEqual(topAllowFrom, legacyAllowFrom) + ) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } + + if (dm && asObjectRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; +} + +/** + * Check if a Mattermost config entry has legacy streaming aliases. + */ +function hasLegacyMattermostStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return false; +} + +function hasLegacyMattermostAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyMattermostStreamingAliases(account)); +} + +/** + * Legacy configuration rules for Mattermost. + */ +const MATTERMOST_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "mattermost"], + message: + "channels.mattermost.dm.policy and channels.mattermost.dm.allowFrom are legacy; use channels.mattermost.dmPolicy and channels.mattermost.allowForm.", + match: (value) => { + const entry = asObjectRecord(value); + if (!entry) return false; + const dm = asObjectRecord(entry.dm); + return dm?.policy !== undefined || dm?.allowFrom !== undefined; + }, + }, + { + path: ["channels", "mattermost", "accounts"], + message: + "channels.mattermost.accounts..dm.policy and .dm.allowFrom are legacy; use channels.mattermost.accounts..dmPolicy and .allowFrom.", + match: hasLegacyMattermostAccountStreamingAliases, + }, +]; + +/** + * Normalize Mattermost compatibility configuration. + * Handles migrations from legacy dm.* structure to flat structure. + */ +function normalizeMattermostCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.mattermost); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const base = normalizeMattermostDmAliases({ + entry: rawEntry, + pathPrefix: "channels.mattermost", + changes, + }); + updated = base.entry; + changed = base.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + const dm = normalizeMattermostDmAliases({ + entry: account, + pathPrefix: `channels.mattermost.accounts.${accountId}`, + changes, + }); + if (dm.changed) { + accounts[accountId] = dm.entry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + mattermost: updated as unknown as NonNullable["mattermost"], + } as OpenClawConfig["channels"], + }, + changes, + }; +} + +/** + * Collect warnings for deprecated or insecure configurations. + */ +function collectMattermostDeprecatedConfigWarnings(cfg: OpenClawConfig): string[] { + const warnings: string[] = []; + const mmCfg = (cfg.channels as Record | undefined)?.mattermost; + const entry = asObjectRecord(mmCfg); + if (!entry) return warnings; + + const baseUrl = entry.baseUrl; + if (typeof baseUrl === "string" && baseUrl.startsWith("http://")) { + warnings.push( + `- Insecure HTTP URL detected: channels.mattermost.baseUrl uses http://. Use https:// for secure communication.`, + ); + } + + const accounts = asObjectRecord(entry.accounts); + if (accounts) { + for (const [accountId, rawAccount] of Object.entries(accounts)) { + const account = asObjectRecord(rawAccount); + if (account && typeof account.baseUrl === "string" && account.baseUrl.startsWith("http://")) { + warnings.push( + `- Insecure HTTP URL detected: channels.mattermost.accounts.${accountId}.baseUrl uses http://. Use https:// for secure communication.`, + ); + } + } + } + + const dmPolicy = entry.dmPolicy; + const allowFrom = entry.allowFrom; + if (dmPolicy === "open" && (!allowFrom || (Array.isArray(allowFrom) && allowFrom.length === 0))) { + warnings.push( + `- Open DM policy configured without an allowlist. Any Mattermost user can send DMs to the bot. Consider using dmPolicy='pairing' or 'allowlist' for better security.`, + ); + } + + return warnings; +} + +/** + * Mattermost doctor adapter providing: + * - Legacy config migration detection + * - Mutable allowlist warnings + * - Security configuration checks + * - Compatibility normalization + */ +export const mattermostDoctor: ChannelDoctorAdapter = { + dmAllowFromMode: "topOrNested", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig: ({ cfg }) => normalizeMattermostCompatibilityConfig(cfg), + collectMutableAllowlistWarnings: ({ cfg }) => collectMattermostMutableAllowlistWarnings({ cfg }), + collectPreviewWarnings: ({ cfg }) => collectMattermostDeprecatedConfigWarnings(cfg), +}; + +/** + * @deprecated Use mattermostDoctor instead. Kept for backward compatibility. + */ +export { isMattermostMutableAllowEntry }; diff --git a/extensions/mattermost/src/errors.test.ts b/extensions/mattermost/src/errors.test.ts new file mode 100644 index 0000000..d3d09d6 --- /dev/null +++ b/extensions/mattermost/src/errors.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it } from "vitest"; +import { + ErrorCodes, + MattermostError, + MattermostAPIError, + AuthenticationError, + ValidationError, + PermissionError, + RateLimitError, + ResourceNotFoundError, + NetworkError, + TimeoutError, + ConfigurationError, + MissingBotTokenError, + MissingBaseUrlError, + isRetryableError, + getRetryAfterMs, + withRetry, + ErrorBoundary, + getUserFriendlyMessage, + createErrorFromResponse, +} from "./errors.js"; + +describe("Error Handling", () => { + describe("ErrorCodes", () => { + it("has all expected error codes", () => { + expect(ErrorCodes.MATTERMOST_API_ERROR).toBe("MATTERMOST_API_ERROR"); + expect(ErrorCodes.AUTHENTICATION_FAILED).toBe("AUTHENTICATION_FAILED"); + expect(ErrorCodes.VALIDATION_ERROR).toBe("VALIDATION_ERROR"); + expect(ErrorCodes.PERMISSION_DENIED).toBe("PERMISSION_DENIED"); + expect(ErrorCodes.RATE_LIMIT_EXCEEDED).toBe("RATE_LIMIT_EXCEEDED"); + expect(ErrorCodes.NETWORK_ERROR).toBe("NETWORK_ERROR"); + expect(ErrorCodes.TIMEOUT_ERROR).toBe("TIMEOUT_ERROR"); + expect(ErrorCodes.RESOURCE_NOT_FOUND).toBe("RESOURCE_NOT_FOUND"); + expect(ErrorCodes.CONFIGURATION_ERROR).toBe("CONFIGURATION_ERROR"); + expect(ErrorCodes.MISSING_BOT_TOKEN).toBe("MISSING_BOT_TOKEN"); + expect(ErrorCodes.MISSING_BASE_URL).toBe("MISSING_BASE_URL"); + expect(ErrorCodes.UNKNOWN_ERROR).toBe("UNKNOWN_ERROR"); + }); + }); + + describe("MattermostError", () => { + it("creates a basic error with context", () => { + const error = new MattermostError({ + code: ErrorCodes.UNKNOWN_ERROR, + message: "Test error", + context: { + operation: "test-operation", + timestamp: new Date().toISOString(), + }, + }); + + expect(error.code).toBe(ErrorCodes.UNKNOWN_ERROR); + expect(error.message).toBe("Test error"); + expect(error.context.operation).toBe("test-operation"); + expect(error.retryable).toBe(false); + }); + + it("serializes to JSON safely", () => { + const error = new MattermostError({ + code: ErrorCodes.MATTERMOST_API_ERROR, + message: "API error", + context: { + operation: "api-call", + accountId: "test-account", + statusCode: 500, + timestamp: new Date().toISOString(), + }, + retryable: true, + }); + + const json = error.toJSON(); + expect(json.code).toBe(ErrorCodes.MATTERMOST_API_ERROR); + expect(json.retryable).toBe(true); + expect(json.context.accountId).toBe("test-account"); + expect(json.context.statusCode).toBe(500); + }); + + it("formats log string correctly", () => { + const timestamp = new Date().toISOString(); + const error = new MattermostError({ + code: ErrorCodes.NETWORK_ERROR, + message: "Connection failed", + context: { + operation: "network-request", + timestamp, + }, + }); + + const logString = error.toLogString(); + expect(logString).toContain(ErrorCodes.NETWORK_ERROR); + expect(logString).toContain("Connection failed"); + expect(logString).toContain("network-request"); + }); + }); + + describe("MattermostAPIError", () => { + it("marks 5xx errors as retryable", () => { + const error = new MattermostAPIError( + "Server error", + { operation: "test", timestamp: new Date().toISOString() }, + 503 + ); + expect(error.retryable).toBe(true); + expect(error.code).toBe(ErrorCodes.MATTERMOST_API_SERVER_ERROR); + }); + + it("marks 429 errors as retryable", () => { + const error = new MattermostAPIError( + "Rate limited", + { operation: "test", timestamp: new Date().toISOString() }, + 429 + ); + expect(error.retryable).toBe(true); + expect(error.code).toBe(ErrorCodes.MATTERMOST_API_RATE_LIMIT); + }); + + it("marks 4xx errors (except 429) as not retryable", () => { + const error = new MattermostAPIError( + "Bad request", + { operation: "test", timestamp: new Date().toISOString() }, + 400 + ); + expect(error.retryable).toBe(false); + }); + + it("provides user-friendly messages for common status codes", () => { + const error401 = new MattermostAPIError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() }, + 401 + ); + expect(error401.userMessage).toContain("bot token"); + + const error429 = new MattermostAPIError( + "Rate limited", + { operation: "test", timestamp: new Date().toISOString() }, + 429 + ); + expect(error429.userMessage).toContain("Rate limit"); + }); + }); + + describe("AuthenticationError", () => { + it("is not retryable", () => { + const error = new AuthenticationError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() } + ); + expect(error.retryable).toBe(false); + expect(error.code).toBe(ErrorCodes.AUTHENTICATION_FAILED); + }); + + it("has user-friendly message", () => { + const error = new AuthenticationError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() } + ); + expect(error.userMessage).toContain("Authentication failed"); + expect(error.userMessage).toContain("bot token"); + }); + }); + + describe("ConfigurationError", () => { + it("MissingBaseUrlError has correct message", () => { + const error = new MissingBaseUrlError({ + operation: "create-client", + timestamp: new Date().toISOString(), + }); + expect(error.message).toContain("baseUrl is required"); + expect(error.code).toBe(ErrorCodes.MISSING_BASE_URL); + }); + + it("MissingBotTokenError has correct message", () => { + const error = new MissingBotTokenError({ + operation: "create-client", + timestamp: new Date().toISOString(), + }); + expect(error.message).toContain("bot token is required"); + expect(error.code).toBe(ErrorCodes.MISSING_BOT_TOKEN); + }); + }); + + describe("isRetryableError", () => { + it("returns true for MattermostError with retryable=true", () => { + const error = new MattermostAPIError( + "Server error", + { operation: "test", timestamp: new Date().toISOString() }, + 503 + ); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns false for MattermostError with retryable=false", () => { + const error = new AuthenticationError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() } + ); + expect(isRetryableError(error)).toBe(false); + }); + + it("returns true for network error codes", () => { + const error = { code: "ECONNRESET" }; + expect(isRetryableError(error)).toBe(true); + }); + + it("returns true for timeout errors", () => { + const error = { message: "Request timeout" }; + expect(isRetryableError(error)).toBe(true); + }); + }); + + describe("getRetryAfterMs", () => { + it("extracts retry-after from RateLimitError", () => { + const error = new RateLimitError( + "Rate limited", + { operation: "test", timestamp: new Date().toISOString() }, + 5000 + ); + expect(getRetryAfterMs(error)).toBe(5000); + }); + + it("extracts retry-after from headers", () => { + const error = { + headers: { + get: (name: string) => (name === "retry-after" ? "10" : null), + }, + }; + expect(getRetryAfterMs(error)).toBe(10000); + }); + + it("returns undefined when no retry-after available", () => { + expect(getRetryAfterMs(new Error("Some error"))).toBeUndefined(); + }); + }); + + describe("withRetry", () => { + it("succeeds on first attempt", async () => { + const fn = () => Promise.resolve("success"); + const result = await withRetry("test-op", fn); + expect(result).toBe("success"); + }); + + it("retries on retryable errors and succeeds", async () => { + let attempts = 0; + const fn = () => { + attempts++; + if (attempts < 3) { + const error = new MattermostAPIError( + "Server error", + { operation: "test", timestamp: new Date().toISOString() }, + 503 + ); + return Promise.reject(error); + } + return Promise.resolve("success"); + }; + + const result = await withRetry("test-op", fn, { maxRetries: 3, initialDelayMs: 10 }); + expect(result).toBe("success"); + expect(attempts).toBe(3); + }); + + it("does not retry on non-retryable errors", async () => { + let attempts = 0; + const fn = () => { + attempts++; + const error = new AuthenticationError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() } + ); + return Promise.reject(error); + }; + + await expect( + withRetry("test-op", fn, { maxRetries: 3, initialDelayMs: 10 }) + ).rejects.toBeInstanceOf(AuthenticationError); + expect(attempts).toBe(1); + }); + + it("calls onRetry callback", async () => { + const onRetry = jest.fn(); + let attempts = 0; + const fn = () => { + attempts++; + if (attempts < 2) { + const error = new NetworkError( + "Network error", + { operation: "test", timestamp: new Date().toISOString() } + ); + return Promise.reject(error); + } + return Promise.resolve("success"); + }; + + await withRetry("test-op", fn, { + maxRetries: 3, + initialDelayMs: 10, + onRetry, + }); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ + attempt: 1, + maxRetries: 3, + }) + ); + }); + }); + + describe("ErrorBoundary", () => { + it("registers and calls error handlers", () => { + const boundary = new ErrorBoundary(); + const handler = jest.fn(); + + boundary.onError(handler); + const error = new Error("Test error"); + boundary.handle(error); + + expect(handler).toHaveBeenCalledWith(expect.any(Error)); + }); + + it("wraps functions and handles errors", async () => { + const boundary = new ErrorBoundary(); + const handler = jest.fn(); + boundary.onError(handler); + + const fn = boundary.wrap( + async () => { + throw new Error("Wrapped error"); + }, + "test-operation" + ); + + await expect(fn()).rejects.toThrow(); + expect(handler).toHaveBeenCalled(); + }); + + it("does not call handlers after shutdown", () => { + const boundary = new ErrorBoundary(); + const handler = jest.fn(); + + boundary.onError(handler); + boundary.shutdown(); + boundary.handle(new Error("Test")); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe("getUserFriendlyMessage", () => { + it("returns userMessage for MattermostError", () => { + const error = new AuthenticationError( + "Auth failed", + { operation: "test", timestamp: new Date().toISOString() } + ); + expect(getUserFriendlyMessage(error)).toBe(error.userMessage); + }); + + it("returns friendly message for connection errors", () => { + const error = new Error("ECONNREFUSED"); + expect(getUserFriendlyMessage(error)).toContain("Unable to connect"); + }); + + it("returns friendly message for timeout errors", () => { + const error = new Error("Request timeout"); + expect(getUserFriendlyMessage(error)).toContain("timed out"); + }); + + it("returns default message for unknown errors", () => { + expect(getUserFriendlyMessage("unknown")).toContain("unexpected error"); + }); + }); + + describe("createErrorFromResponse", () => { + it("creates ValidationError for 400", async () => { + const response = new Response(JSON.stringify({ message: "Bad request" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(ValidationError); + expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR); + }); + + it("creates AuthenticationError for 401", async () => { + const response = new Response(JSON.stringify({ message: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.code).toBe(ErrorCodes.AUTHENTICATION_FAILED); + }); + + it("creates PermissionError for 403", async () => { + const response = new Response(JSON.stringify({ message: "Forbidden" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(PermissionError); + expect(error.code).toBe(ErrorCodes.PERMISSION_DENIED); + }); + + it("creates ResourceNotFoundError for 404", async () => { + const response = new Response(JSON.stringify({ message: "Not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(ResourceNotFoundError); + expect(error.code).toBe(ErrorCodes.RESOURCE_NOT_FOUND); + }); + + it("creates RateLimitError for 429 with retry-after", async () => { + const response = new Response(JSON.stringify({ message: "Rate limited" }), { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "10", + }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(RateLimitError); + expect(error.code).toBe(ErrorCodes.RATE_LIMIT_EXCEEDED); + expect((error as RateLimitError).retryAfterMs).toBe(10000); + }); + + it("creates MattermostAPIError for 5xx", async () => { + const response = new Response(JSON.stringify({ message: "Server error" }), { + status: 503, + headers: { "content-type": "application/json" }, + }); + + const error = await createErrorFromResponse(response, "test-op"); + expect(error).toBeInstanceOf(MattermostAPIError); + expect(error.code).toBe(ErrorCodes.MATTERMOST_API_SERVER_ERROR); + expect(error.retryable).toBe(true); + }); + }); +}); diff --git a/extensions/mattermost/src/errors.ts b/extensions/mattermost/src/errors.ts new file mode 100644 index 0000000..26ca9a3 --- /dev/null +++ b/extensions/mattermost/src/errors.ts @@ -0,0 +1,855 @@ +import { extractErrorCode, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { retryAsync, type RetryOptions, type RetryInfo } from "openclaw/plugin-sdk/retry-runtime"; + +export const ErrorCodes = { + MATTERMOST_API_ERROR: "MATTERMOST_API_ERROR", + MATTERMOST_API_TIMEOUT: "MATTERMOST_API_TIMEOUT", + MATTERMOST_API_RATE_LIMIT: "MATTERMOST_API_RATE_LIMIT", + MATTERMOST_API_SERVER_ERROR: "MATTERMOST_API_SERVER_ERROR", + + AUTHENTICATION_FAILED: "AUTHENTICATION_FAILED", + TOKEN_INVALID: "TOKEN_INVALID", + TOKEN_EXPIRED: "TOKEN_EXPIRED", + INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS", + + VALIDATION_ERROR: "VALIDATION_ERROR", + INVALID_BASE_URL: "INVALID_BASE_URL", + INVALID_CHANNEL_ID: "INVALID_CHANNEL_ID", + INVALID_USER_ID: "INVALID_USER_ID", + INVALID_MESSAGE_FORMAT: "INVALID_MESSAGE_FORMAT", + + PERMISSION_DENIED: "PERMISSION_DENIED", + CHANNEL_ACCESS_DENIED: "CHANNEL_ACCESS_DENIED", + POST_EDIT_DENIED: "POST_EDIT_DENIED", + FILE_UPLOAD_DENIED: "FILE_UPLOAD_DENIED", + + RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", + RATE_LIMIT_RETRY_AFTER: "RATE_LIMIT_RETRY_AFTER", + + NETWORK_ERROR: "NETWORK_ERROR", + CONNECTION_ERROR: "CONNECTION_ERROR", + TIMEOUT_ERROR: "TIMEOUT_ERROR", + DNS_ERROR: "DNS_ERROR", + + RESOURCE_NOT_FOUND: "RESOURCE_NOT_FOUND", + USER_NOT_FOUND: "USER_NOT_FOUND", + CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND", + POST_NOT_FOUND: "POST_NOT_FOUND", + TEAM_NOT_FOUND: "TEAM_NOT_FOUND", + + CONFIGURATION_ERROR: "CONFIGURATION_ERROR", + MISSING_BOT_TOKEN: "MISSING_BOT_TOKEN", + MISSING_BASE_URL: "MISSING_BASE_URL", + INVALID_CONFIGURATION: "INVALID_CONFIGURATION", + + FILE_UPLOAD_FAILED: "FILE_UPLOAD_FAILED", + FILE_TOO_LARGE: "FILE_TOO_LARGE", + INVALID_FILE_TYPE: "INVALID_FILE_TYPE", + + WEBSOCKET_ERROR: "WEBSOCKET_ERROR", + WEBSOCKET_CONNECTION_FAILED: "WEBSOCKET_CONNECTION_FAILED", + WEBSOCKET_MESSAGE_ERROR: "WEBSOCKET_MESSAGE_ERROR", + + UNKNOWN_ERROR: "UNKNOWN_ERROR", + UNEXPECTED_ERROR: "UNEXPECTED_ERROR", +} as const; + +export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]; + +export interface ErrorContext { + operation: string; + accountId?: string; + userId?: string; + channelId?: string; + postId?: string; + timestamp?: string; + statusCode?: number; + requestPath?: string; + metadata?: Record; +} + +export function createErrorContext( + operation: string, + partial?: Partial> +): ErrorContext { + return { + operation, + timestamp: new Date().toISOString(), + ...partial, + }; +} + +export interface MattermostErrorOptions { + code: ErrorCode; + message: string; + context: ErrorContext; + cause?: Error | unknown; + retryable?: boolean; + userMessage?: string; + retryAfterMs?: number; +} + +export class MattermostError extends Error { + readonly code: ErrorCode; + readonly context: ErrorContext; + readonly cause?: Error | unknown; + readonly retryable: boolean; + readonly userMessage: string; + readonly retryAfterMs?: number; + + constructor(options: MattermostErrorOptions) { + super(options.message); + this.name = "MattermostError"; + this.code = options.code; + this.context = options.context; + this.cause = options.cause; + this.retryable = options.retryable ?? false; + this.userMessage = options.userMessage ?? options.message; + this.retryAfterMs = options.retryAfterMs; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, MattermostError); + } + } + + toJSON(): Record { + const context = this.context as ErrorContext; + return { + name: this.name, + code: this.code, + message: this.userMessage, + context: { + operation: context.operation, + accountId: context.accountId, + userId: context.userId, + channelId: context.channelId, + postId: context.postId, + timestamp: context.timestamp, + statusCode: context.statusCode, + requestPath: context.requestPath, + }, + retryable: this.retryable, + retryAfterMs: this.retryAfterMs, + causeCode: this.cause instanceof MattermostError + ? this.cause.code + : extractErrorCode(this.cause), + }; + } + + toLogString(): string { + return `[${this.code}] ${this.userMessage} | Operation: ${this.context.operation} | Time: ${this.context.timestamp}`; + } +} + +export class MattermostAPIError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + statusCode?: number, + cause?: Error | unknown + ) { + const retryable = statusCode ? isRetryableStatusCode(statusCode) : false; + + let code: ErrorCode = ErrorCodes.MATTERMOST_API_ERROR; + if (statusCode === 429) { + code = ErrorCodes.MATTERMOST_API_RATE_LIMIT; + } else if (statusCode && statusCode >= 500) { + code = ErrorCodes.MATTERMOST_API_SERVER_ERROR; + } else if (statusCode && statusCode === 408) { + code = ErrorCodes.MATTERMOST_API_TIMEOUT; + } + + super({ + code, + message, + context: { ...context, statusCode }, + cause, + retryable, + userMessage: getUserMessageForStatusCode(statusCode, message), + }); + this.name = "MattermostAPIError"; + } +} + +export class AuthenticationError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + cause?: Error | unknown, + specificCode: ErrorCode = ErrorCodes.AUTHENTICATION_FAILED + ) { + super({ + code: specificCode, + message, + context: { ...context, statusCode: 401 }, + cause, + retryable: false, + userMessage: "Authentication failed. Please check your bot token and try again.", + }); + this.name = "AuthenticationError"; + } +} + +export class TokenInvalidError extends AuthenticationError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "The bot token is invalid or has been revoked", + context, + cause, + ErrorCodes.TOKEN_INVALID + ); + this.name = "TokenInvalidError"; + } +} + +export class TokenExpiredError extends AuthenticationError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "The bot token has expired", + context, + cause, + ErrorCodes.TOKEN_EXPIRED + ); + this.name = "TokenExpiredError"; + } +} + +export class ValidationError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + cause?: Error | unknown, + field?: string + ) { + super({ + code: ErrorCodes.VALIDATION_ERROR, + message, + context: { + ...context, + metadata: field ? { field } : undefined + }, + cause, + retryable: false, + userMessage: `Validation error: ${message}`, + }); + this.name = "ValidationError"; + } +} + +export class InvalidBaseUrlError extends ValidationError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "Invalid or missing Mattermost base URL", + context, + cause, + "baseUrl" + ); + this.name = "InvalidBaseUrlError"; + } +} + +export class PermissionError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + cause?: Error | unknown, + specificCode: ErrorCode = ErrorCodes.PERMISSION_DENIED + ) { + super({ + code: specificCode, + message, + context: { ...context, statusCode: 403 }, + cause, + retryable: false, + userMessage: "Permission denied. You don't have access to perform this action.", + }); + this.name = "PermissionError"; + } +} + +export class ChannelAccessDeniedError extends PermissionError { + constructor(channelId: string, context: ErrorContext, cause?: Error | unknown) { + super( + `Access denied to channel ${channelId}`, + { ...context, channelId }, + cause, + ErrorCodes.CHANNEL_ACCESS_DENIED + ); + this.name = "ChannelAccessDeniedError"; + } +} + +export class PostEditDeniedError extends PermissionError { + constructor(postId: string, context: ErrorContext, cause?: Error | unknown) { + super( + `Cannot edit post ${postId}: insufficient permissions`, + { ...context, postId }, + cause, + ErrorCodes.POST_EDIT_DENIED + ); + this.name = "PostEditDeniedError"; + } +} + +export class FileUploadDeniedError extends PermissionError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "File upload not permitted", + context, + cause, + ErrorCodes.FILE_UPLOAD_DENIED + ); + this.name = "FileUploadDeniedError"; + } +} + +export class RateLimitError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + retryAfterMs: number, + cause?: Error | unknown + ) { + super({ + code: ErrorCodes.RATE_LIMIT_EXCEEDED, + message, + context: { ...context, statusCode: 429 }, + cause, + retryable: true, + userMessage: "Rate limit exceeded. Please try again later.", + retryAfterMs, + }); + this.name = "RateLimitError"; + } +} + +export class ResourceNotFoundError extends MattermostError { + constructor( + resourceType: string, + resourceId: string, + context: ErrorContext, + cause?: Error | unknown + ) { + const codeMap: Record = { + user: ErrorCodes.USER_NOT_FOUND, + channel: ErrorCodes.CHANNEL_NOT_FOUND, + post: ErrorCodes.POST_NOT_FOUND, + team: ErrorCodes.TEAM_NOT_FOUND, + }; + + super({ + code: codeMap[resourceType] ?? ErrorCodes.RESOURCE_NOT_FOUND, + message: `${resourceType} not found: ${resourceId}`, + context: { ...context, statusCode: 404 }, + cause, + retryable: false, + userMessage: `The requested ${resourceType} could not be found.`, + }); + this.name = "ResourceNotFoundError"; + } +} + +export class ConfigurationError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + cause?: Error | unknown, + specificCode: ErrorCode = ErrorCodes.CONFIGURATION_ERROR + ) { + super({ + code: specificCode, + message, + context, + cause, + retryable: false, + userMessage: `Configuration error: ${message}. Please check your settings.`, + }); + this.name = "ConfigurationError"; + } +} + +export class MissingBotTokenError extends ConfigurationError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "Mattermost bot token is required", + context, + cause, + ErrorCodes.MISSING_BOT_TOKEN + ); + this.name = "MissingBotTokenError"; + } +} + +export class MissingBaseUrlError extends ConfigurationError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "Mattermost baseUrl is required", + context, + cause, + ErrorCodes.MISSING_BASE_URL + ); + this.name = "MissingBaseUrlError"; + } +} + +export class NetworkError extends MattermostError { + constructor( + message: string, + context: ErrorContext, + cause?: Error | unknown, + specificCode: ErrorCode = ErrorCodes.NETWORK_ERROR + ) { + super({ + code: specificCode, + message, + context, + cause, + retryable: true, + userMessage: "Network error. Please check your connection and try again.", + }); + this.name = "NetworkError"; + } +} + +export class ConnectionError extends NetworkError { + constructor(context: ErrorContext, cause?: Error | unknown) { + super( + "Failed to connect to Mattermost server", + context, + cause, + ErrorCodes.CONNECTION_ERROR + ); + this.name = "ConnectionError"; + } +} + +export class TimeoutError extends NetworkError { + constructor(operation: string, context: ErrorContext, cause?: Error | unknown) { + super( + `Operation timed out: ${operation}`, + context, + cause, + ErrorCodes.TIMEOUT_ERROR + ); + this.name = "TimeoutError"; + } +} + +function isRetryableStatusCode(statusCode: number): boolean { + if (statusCode === 429) return true; + if (statusCode >= 500 && statusCode < 600) return true; + if (statusCode === 408) return true; + return false; +} + +function getUserMessageForStatusCode(statusCode: number | undefined, detail: string): string { + if (!statusCode) { + return `Mattermost API error: ${detail}`; + } + + const messages: Record = { + 400: "Bad request. Please check your input and try again.", + 401: "Authentication failed. Please check your bot token.", + 403: "Permission denied. The bot doesn't have access to this resource.", + 404: "The requested resource was not found.", + 408: "Request timed out. Please try again.", + 429: "Rate limit exceeded. Please wait a moment and try again.", + 500: "Mattermost server error. Please try again later.", + 502: "Mattermost server is temporarily unavailable. Please try again later.", + 503: "Mattermost service is temporarily unavailable. Please try again later.", + 504: "Mattermost server timeout. Please try again later.", + }; + + return messages[statusCode] ?? `Mattermost API error (${statusCode}): ${detail}`; +} + +export function isRetryableError(error: unknown): boolean { + if (error instanceof MattermostError) { + return error.retryable; + } + + if (typeof error === "object" && error !== null) { + const code = (error as { code?: string }).code; + if (code && RETRYABLE_ERROR_CODES.has(code)) { + return true; + } + + const errno = (error as { errno?: string | number }).errno; + if (typeof errno === "string" && RETRYABLE_ERROR_CODES.has(errno)) { + return true; + } + + const message = String((error as { message?: string }).message || "").toLowerCase(); + if (RETRYABLE_MESSAGE_PATTERNS.some(pattern => message.includes(pattern))) { + return true; + } + } + + return false; +} + +const RETRYABLE_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_MESSAGE_PATTERNS = [ + "rate limit", + "too many requests", + "timeout", + "timed out", + "network error", + "connection refused", + "econnreset", + "econnrefused", + "etimedout", + "enotfound", + "socket hang up", + "getaddrinfo", + "temporary", + "unavailable", + "5", +]; + +export function getRetryAfterMs(error: unknown): number | undefined { + if (error instanceof RateLimitError) { + return error.retryAfterMs; + } + + if (typeof error === "object" && error !== null) { + const retryAfter = (error as { retryAfter?: number; retry_after?: number }).retryAfter + ?? (error as { retry_after?: number }).retry_after; + if (typeof retryAfter === "number" && retryAfter > 0) { + return retryAfter * 1000; + } + + const headers = (error as { headers?: { get?: (name: string) => string | null } }).headers; + if (headers?.get) { + const headerValue = headers.get("retry-after"); + if (headerValue) { + const seconds = parseInt(headerValue, 10); + if (!isNaN(seconds) && seconds > 0) { + return seconds * 1000; + } + } + } + } + + return undefined; +} + +export interface RetryConfig { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + timeoutMs?: number; + onRetry?: (info: RetryAttemptInfo) => void; +} + +export interface RetryAttemptInfo { + attempt: number; + maxRetries: number; + delayMs: number; + error: MattermostError | Error; +} + +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + timeoutMs: 30000, + onRetry: () => {}, +}; + +export async function withRetry( + operation: string, + fn: () => Promise, + config: RetryConfig = {}, + context?: Partial +): Promise { + const cfg = { ...DEFAULT_RETRY_CONFIG, ...config }; + let lastError: MattermostError | Error | undefined; + + for (let attempt = 1; attempt <= cfg.maxRetries + 1; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), cfg.timeoutMs); + + try { + return await fn(); + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (attempt > cfg.maxRetries) { + break; + } + + if (!isRetryableError(lastError)) { + throw lastError; + } + + const baseDelay = cfg.initialDelayMs * Math.pow(2, attempt - 1); + const jitter = Math.random() * 0.3 * baseDelay; + let delayMs = Math.min(baseDelay + jitter, cfg.maxDelayMs); + + const retryAfter = getRetryAfterMs(lastError); + if (retryAfter !== undefined) { + delayMs = Math.max(delayMs, retryAfter); + } + + delayMs = Math.round(delayMs); + + cfg.onRetry({ + attempt, + maxRetries: cfg.maxRetries, + delayMs, + error: lastError, + }); + + await sleep(delayMs); + } + } + + throw lastError ?? new MattermostError({ + code: ErrorCodes.UNKNOWN_ERROR, + message: `Operation failed after ${cfg.maxRetries} retries: ${operation}`, + context: createErrorContext(operation, context), + }); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export type ErrorHandler = (error: MattermostError | Error) => void; + +export class ErrorBoundary { + private handlers: Set = new Set(); + private isShutdown = false; + + onError(handler: ErrorHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + handle(error: unknown): void { + if (this.isShutdown) { + return; + } + + const normalizedError = normalizeError(error); + + for (const handler of this.handlers) { + try { + handler(normalizedError); + } catch { + // Silently ignore handler failures to avoid console noise + } + } + } + + wrap Promise>( + fn: T, + operation: string, + context?: Partial + ): (...args: Parameters) => Promise> { + return async (...args: Parameters): Promise> => { + try { + return await fn(...args) as ReturnType; + } catch (error) { + const normalized = normalizeError(error, operation, context); + this.handle(normalized); + throw normalized; + } + }; + } + + shutdown(): void { + this.isShutdown = true; + this.handlers.clear(); + } +} + +export const globalErrorBoundary = new ErrorBoundary(); + +function normalizeError( + error: unknown, + operation = "unknown", + context?: Partial +): MattermostError | Error { + if (error instanceof MattermostError) { + return error; + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + if (message.includes("unauthorized") || message.includes("401")) { + return new AuthenticationError( + error.message, + createErrorContext(operation, context), + error + ); + } + + if (message.includes("forbidden") || message.includes("403")) { + return new PermissionError( + error.message, + createErrorContext(operation, context), + error + ); + } + + if (message.includes("not found") || message.includes("404")) { + return new ResourceNotFoundError( + "resource", + "unknown", + createErrorContext(operation, context), + error + ); + } + + return error; + } + + return new MattermostError({ + code: ErrorCodes.UNKNOWN_ERROR, + message: String(error), + context: createErrorContext(operation, context), + }); +} + +export async function createErrorFromResponse( + response: Response, + operation: string, + context?: Partial +): Promise { + const statusCode = response.status; + const errorContext = createErrorContext(operation, { + ...context, + statusCode, + requestPath: response.url, + }); + + let detail = "Unknown error"; + try { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = await response.json() as { message?: string; error?: string }; + detail = data.message ?? data.error ?? JSON.stringify(data); + } else { + detail = await response.text(); + } + } catch { + detail = response.statusText; + } + + switch (statusCode) { + case 400: + return new ValidationError( + `Bad request: ${detail}`, + errorContext + ); + + case 401: + return new AuthenticationError( + `Authentication failed: ${detail}`, + errorContext + ); + + case 403: + return new PermissionError( + `Permission denied: ${detail}`, + errorContext + ); + + case 404: + return new ResourceNotFoundError( + "resource", + "unknown", + errorContext + ); + + case 408: + return new TimeoutError( + operation, + errorContext + ); + + case 429: { + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfterMs = retryAfterHeader + ? parseInt(retryAfterHeader, 10) * 1000 + : 5000; + + return new RateLimitError( + `Rate limit exceeded: ${detail}`, + errorContext, + retryAfterMs + ); + } + + case 500: + case 502: + case 503: + case 504: + return new MattermostAPIError( + `Server error (${statusCode}): ${detail}`, + errorContext, + statusCode + ); + + default: + return new MattermostAPIError( + `API error (${statusCode}): ${detail}`, + errorContext, + statusCode + ); + } +} + +export function getUserFriendlyMessage(error: unknown): string { + if (error instanceof MattermostError) { + return error.userMessage; + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + if (message.includes("econnrefused") || message.includes("enotfound")) { + return "Unable to connect to Mattermost server. Please check that the server URL is correct and the server is running."; + } + + if (message.includes("timeout")) { + return "The request timed out. The Mattermost server may be slow or unavailable. Please try again."; + } + + if (message.includes("certificate") || message.includes("ssl")) { + return "SSL/TLS certificate error. Please check the server configuration or use a valid certificate."; + } + + return formatErrorMessage(error); + } + + return "An unexpected error occurred. Please try again or contact support if the problem persists."; +} + +export { retryAsync, type RetryOptions, type RetryInfo }; +export { extractErrorCode, formatErrorMessage }; diff --git a/extensions/mattermost/src/group-mentions.test.ts b/extensions/mattermost/src/group-mentions.test.ts new file mode 100644 index 0000000..8a4d149 --- /dev/null +++ b/extensions/mattermost/src/group-mentions.test.ts @@ -0,0 +1,46 @@ +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); + }); +}); diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts new file mode 100644 index 0000000..4d8d484 --- /dev/null +++ b/extensions/mattermost/src/group-mentions.ts @@ -0,0 +1,23 @@ +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, + }); +} diff --git a/extensions/mattermost/src/interactive-replies.test.ts b/extensions/mattermost/src/interactive-replies.test.ts new file mode 100644 index 0000000..06d8eeb --- /dev/null +++ b/extensions/mattermost/src/interactive-replies.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from "vitest"; +import { + compileMattermostInteractiveReplies, + isMattermostInteractiveRepliesEnabled, + sanitizeButtonValue, + validateInteractiveChoices, +} from "../interactive-replies.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; + +describe("compileMattermostInteractiveReplies", () => { + it("should parse button directives correctly", () => { + const payload: ReplyPayload = { + text: "Please select an action: [[mattermost_buttons: Approve:approve:primary, Reject:reject:danger]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result.interactive).toBeDefined(); + expect(result.interactive?.blocks).toHaveLength(2); + expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "Please select an action:" }); + expect(result.interactive?.blocks[1]).toEqual({ + type: "buttons", + buttons: [ + { label: "Approve", value: "approve", style: "primary" }, + { label: "Reject", value: "reject", style: "danger" }, + ], + }); + expect(result.text).toBe("Please select an action:"); + }); + + it("should parse select directives correctly", () => { + const payload: ReplyPayload = { + text: "Choose a priority: [[mattermost_select: Select Priority | Low:low, Medium:medium, High:high]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result.interactive).toBeDefined(); + expect(result.interactive?.blocks).toHaveLength(2); + expect(result.interactive?.blocks[1]).toEqual({ + type: "select", + placeholder: "Select Priority", + options: [ + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, + ], + }); + }); + + it("should handle mixed buttons and selects", () => { + const payload: ReplyPayload = { + text: "First: [[mattermost_buttons: Yes:yes, No:no]] Second: [[mattermost_select: Pick | A:a, B:b]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result.interactive?.blocks).toHaveLength(4); + expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "First:" }); + expect(result.interactive?.blocks[1]?.type).toBe("buttons"); + expect(result.interactive?.blocks[2]).toEqual({ type: "text", text: " Second:" }); + expect(result.interactive?.blocks[3]?.type).toBe("select"); + }); + + it("should return original payload when no directives present", () => { + const payload: ReplyPayload = { + text: "Just a normal message without directives", + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result).toEqual(payload); + }); + + it("should handle empty text", () => { + const payload: ReplyPayload = { + text: "", + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result).toEqual(payload); + }); + + it("should preserve existing interactive blocks", () => { + const payload: ReplyPayload = { + text: "[[mattermost_buttons: OK:ok]]", + interactive: { + blocks: [{ type: "text", text: "Existing text" }], + }, + }; + + const result = compileMattermostInteractiveReplies(payload); + + expect(result.interactive?.blocks).toHaveLength(2); + expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "Existing text" }); + expect(result.interactive?.blocks[1]?.type).toBe("buttons"); + }); + + it("should handle buttons without explicit style (defaults to undefined)", () => { + const payload: ReplyPayload = { + text: "[[mattermost_buttons: Button1:val1, Button2:val2]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<{ style?: string }> }; + expect(buttons.buttons[0].style).toBeUndefined(); + expect(buttons.buttons[1].style).toBeUndefined(); + }); + + it("should handle select without placeholder", () => { + const payload: ReplyPayload = { + text: "[[mattermost_select: Option1:opt1, Option2:opt2]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + const select = result.interactive?.blocks[0] as { type: "select"; placeholder: string }; + expect(select.placeholder).toBe("Choose an option"); + }); + + it("should limit buttons to maximum of 5", () => { + const payload: ReplyPayload = { + text: "[[mattermost_buttons: A:a, B:b, C:c, D:d, E:e, F:f]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + + const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array }; + expect(buttons.buttons).toHaveLength(5); + }); + + it("should ignore invalid directives gracefully", () => { + const payload: ReplyPayload = { + text: "Valid: [[mattermost_buttons: OK:ok]] Invalid: [[mattermost_buttons:]] End", + }; + + const result = compileMattermostInteractiveReplies(payload); + + // Should still process the valid directive + expect(result.interactive?.blocks).toHaveLength(3); + expect(result.text).toContain("Valid:"); + expect(result.text).toContain("Invalid:"); + expect(result.text).toContain("End"); + }); + + it("should sanitize potentially dangerous values", () => { + const payload: ReplyPayload = { + text: "[[mattermost_buttons: Test:]]", + }; + + const result = compileMattermostInteractiveReplies(payload); + const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<{ value: string }> }; + expect(buttons.buttons[0].value).not.toContain(""; + const sanitized = sanitizeButtonValue(value); + expect(sanitized).not.toContain("<"); + expect(sanitized).not.toContain(">"); + }); + + it("should limit value length to 100 characters", () => { + const value = "a".repeat(200); + const sanitized = sanitizeButtonValue(value); + expect(sanitized.length).toBe(100); + }); + + it("should preserve safe alphanumeric characters", () => { + const value = "safe_value-123"; + const sanitized = sanitizeButtonValue(value); + expect(sanitized).toBe("safe_value-123"); + }); +}); + +describe("validateInteractiveChoices", () => { + it("should return sanitized choices for valid input", () => { + const choices = [ + { label: "OK", value: "ok", style: "primary" as const }, + { label: "Cancel", value: "cancel", style: "danger" as const }, + ]; + + const result = validateInteractiveChoices(choices); + expect(result).toHaveLength(2); + expect(result?.[0].label).toBe("OK"); + expect(result?.[0].style).toBe("primary"); + }); + + it("should return null for empty choices", () => { + const result = validateInteractiveChoices([]); + expect(result).toBeNull(); + }); + + it("should deduplicate by value", () => { + const choices = [ + { label: "A", value: "dup" }, + { label: "B", value: "DUP" }, // Same value, different case + ]; + + const result = validateInteractiveChoices(choices); + expect(result).toHaveLength(1); + }); + + it("should truncate labels to 30 characters", () => { + const choices = [{ label: "a".repeat(50), value: "val" }]; + + const result = validateInteractiveChoices(choices); + expect(result?.[0].label.length).toBe(30); + }); +}); diff --git a/extensions/mattermost/src/interactive-replies.ts b/extensions/mattermost/src/interactive-replies.ts new file mode 100644 index 0000000..3d36448 --- /dev/null +++ b/extensions/mattermost/src/interactive-replies.ts @@ -0,0 +1,317 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { + resolveDefaultMattermostAccountId, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; + +// Maximum items for Mattermost interactive components +const MATTERMOST_BUTTON_MAX_ITEMS = 5; +const MATTERMOST_SELECT_MAX_ITEMS = 100; + +// Directive regex pattern for [[mattermost_buttons: ...]] and [[mattermost_select: ...]] +const MATTERMOST_DIRECTIVE_RE = /\[\[mattermost_(buttons|select):\s*([^\]]+)\]\]/gi; + +// Button styles supported by Mattermost +const VALID_BUTTON_STYLES = new Set(["default", "primary", "danger"]); + +type MattermostChoice = { + label: string; + value: string; + style?: "default" | "primary" | "danger"; +}; + +/** + * Parse a single choice from a raw string. + * Format: "Label:value" or "Label:value:style" + * Style is optional and defaults to "default" + */ +function parseChoice(raw: string, options?: { allowStyle?: boolean }): MattermostChoice | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + // Split by first colon to get label and value+style + const delimiter = trimmed.indexOf(":"); + if (delimiter === -1) { + // No colon: use trimmed as both label and value + return { + label: trimmed, + value: trimmed, + }; + } + + const label = trimmed.slice(0, delimiter).trim(); + let valueAndStyle = trimmed.slice(delimiter + 1).trim(); + + if (!label || !valueAndStyle) { + return null; + } + + // Check for style suffix if enabled + let style: MattermostChoice["style"]; + if (options?.allowStyle) { + const lastColon = valueAndStyle.lastIndexOf(":"); + if (lastColon !== -1) { + const maybeStyle = valueAndStyle.slice(lastColon + 1).trim().toLowerCase(); + if (VALID_BUTTON_STYLES.has(maybeStyle)) { + const valueOnly = valueAndStyle.slice(0, lastColon).trim(); + if (valueOnly) { + valueAndStyle = valueOnly; + style = maybeStyle as MattermostChoice["style"]; + } + } + } + } + + return { label, value: valueAndStyle, style }; +} + +/** + * Parse multiple choices from a comma-separated string. + */ +function parseChoices( + raw: string, + maxItems: number, + options?: { allowStyle?: boolean }, +): MattermostChoice[] { + return raw + .split(",") + .map((entry) => parseChoice(entry, options)) + .filter((entry): entry is MattermostChoice => Boolean(entry)) + .slice(0, maxItems); +} + +/** + * Build a text block for interactive payload. + */ +function buildTextBlock( + text: string, +): NonNullable["blocks"][number] | null { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + return { type: "text", text: trimmed }; +} + +/** + * Build a buttons block from a raw directive body. + * Format: "Label1:value1:primary, Label2:value2:danger" + */ +function buildButtonsBlock( + raw: string, +): NonNullable["blocks"][number] | null { + const choices = parseChoices(raw, MATTERMOST_BUTTON_MAX_ITEMS, { allowStyle: true }); + if (choices.length === 0) { + return null; + } + + return { + type: "buttons", + buttons: choices.map((choice) => ({ + label: choice.label, + value: choice.value, + ...(choice.style && choice.style !== "default" ? { style: choice.style } : {}), + })), + }; +} + +/** + * Build a select (dropdown) block from a raw directive body. + * Format: "Placeholder | Label1:value1, Label2:value2" + * Or without placeholder: "Label1:value1, Label2:value2" + */ +function buildSelectBlock( + raw: string, +): NonNullable["blocks"][number] | null { + // Split by pipe to separate placeholder from options + const parts = raw + .split("|") + .map((entry) => entry.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return null; + } + + const [first, second] = parts; + const placeholder = parts.length >= 2 ? first : "Choose an option"; + const optionsRaw = parts.length >= 2 ? second : first; + + const choices = parseChoices(optionsRaw, MATTERMOST_SELECT_MAX_ITEMS); + if (choices.length === 0) { + return null; + } + + return { + type: "select", + placeholder, + options: choices, + }; +} + +/** + * Check if interactive replies capability is enabled for an account. + * Supports both array format ("interactiveReplies") and object format ({ interactiveReplies: true }) + */ +function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean { + if (!capabilities) { + return false; + } + + if (Array.isArray(capabilities)) { + return capabilities.some( + (entry) => String(entry).trim().toLowerCase() === "interactivereplies", + ); + } + + if (typeof capabilities === "object") { + return (capabilities as { interactiveReplies?: unknown }).interactiveReplies === true; + } + + return false; +} + +/** + * Check if interactive replies are enabled for a specific Mattermost account. + */ +export function isMattermostInteractiveRepliesEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const account = resolveMattermostAccount({ + cfg: params.cfg, + accountId: params.accountId ?? resolveDefaultMattermostAccountId(params.cfg), + }); + + return resolveInteractiveRepliesFromCapabilities(account.config.capabilities); +} + +/** + * Compile Mattermost interactive directives from a reply payload. + * Parses [[mattermost_buttons: ...]] and [[mattermost_select: ...]] directives + * and converts them to interactive blocks. + * + * @param payload - The reply payload potentially containing directives + * @returns Modified payload with parsed interactive blocks + */ +export function compileMattermostInteractiveReplies(payload: ReplyPayload): ReplyPayload { + const text = payload.text; + if (!text) { + return payload; + } + + const generatedBlocks: NonNullable["blocks"] = []; + const visibleTextParts: string[] = []; + let cursor = 0; + let matchedDirective = false; + let generatedInteractiveBlock = false; + + // Reset regex state + MATTERMOST_DIRECTIVE_RE.lastIndex = 0; + + // Find all directives in the text + for (const match of text.matchAll(MATTERMOST_DIRECTIVE_RE)) { + matchedDirective = true; + const matchText = match[0]; + const directiveType = match[1]; + const body = match[2]; + const index = match.index ?? 0; + + // Capture text before this directive + const precedingText = text.slice(cursor, index); + visibleTextParts.push(precedingText); + + // Add text block if there's content + const textBlock = buildTextBlock(precedingText); + if (textBlock) { + generatedBlocks.push(textBlock); + } + + // Build the appropriate interactive block + const block = + directiveType.toLowerCase() === "buttons" + ? buildButtonsBlock(body) + : buildSelectBlock(body); + + if (block) { + generatedInteractiveBlock = true; + generatedBlocks.push(block); + } + + cursor = index + matchText.length; + } + + // Capture any trailing text after the last directive + const trailingText = text.slice(cursor); + visibleTextParts.push(trailingText); + + const trailingBlock = buildTextBlock(trailingText); + if (trailingBlock) { + generatedBlocks.push(trailingBlock); + } + + // Clean the visible text by removing the directive syntax + const cleanedText = visibleTextParts.join(""); + + // If no directives were found or no blocks were generated, return original payload + if (!matchedDirective || !generatedInteractiveBlock) { + return payload; + } + + // Merge generated blocks with any existing interactive blocks + return { + ...payload, + text: cleanedText.trim() || undefined, + interactive: { + blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks], + }, + }; +} + +/** + * Sanitize button value for use in Mattermost callbacks. + * Removes potentially dangerous characters that could cause issues. + */ +export function sanitizeButtonValue(value: string): string { + // Remove any potentially dangerous characters + // Only allow alphanumeric, spaces, and common safe punctuation + return value + .replace(/[<>\{\}\[\]\\]/g, "") + .slice(0, 100); // Limit length +} + +/** + * Validate that a button/select configuration is safe to render. + * Returns sanitized choices or null if validation fails. + */ +export function validateInteractiveChoices(choices: MattermostChoice[]): MattermostChoice[] | null { + if (!choices.length || choices.length > MATTERMOST_BUTTON_MAX_ITEMS) { + return null; + } + + const seenValues = new Set(); + const sanitized: MattermostChoice[] = []; + + for (const choice of choices) { + const sanitizedValue = sanitizeButtonValue(choice.value); + const sanitizedLabel = choice.label.slice(0, 30); // Mattermost UI limit + + // Skip duplicates + if (seenValues.has(sanitizedValue.toLowerCase())) { + continue; + } + seenValues.add(sanitizedValue.toLowerCase()); + + sanitized.push({ + label: sanitizedLabel, + value: sanitizedValue, + style: choice.style && VALID_BUTTON_STYLES.has(choice.style) ? choice.style : undefined, + }); + } + + return sanitized.length > 0 ? sanitized : null; +} diff --git a/extensions/mattermost/src/mattermost/accounts.test.ts b/extensions/mattermost/src/mattermost/accounts.test.ts new file mode 100644 index 0000000..7bcf77a --- /dev/null +++ b/extensions/mattermost/src/mattermost/accounts.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../runtime-api.js"; +import { + 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"); + }); +}); + +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", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts new file mode 100644 index 0000000..dd01502 --- /dev/null +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -0,0 +1,159 @@ +import { createAccountListHelpers } 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 { 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"; + +export type MattermostTokenSource = "env" | "config" | "none"; +export type MattermostBaseUrlSource = "env" | "config" | "none"; + +export type ResolvedMattermostAccount = { + accountId: string; + enabled: boolean; + name?: string; + botToken?: string; + botTokenSource?: MattermostTokenSource; + pat?: string; + baseUrl?: string; + baseUrlSource?: MattermostBaseUrlSource; + config: MattermostAccountConfig; + chatmode?: MattermostChatMode; + oncharPrefixes?: string[]; + requireMention?: boolean; + textChunkLimit?: number; + blockStreaming?: boolean; + blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"]; +}; + +const mattermostAccountHelpers = createAccountListHelpers("mattermost"); + +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({ + channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined, + accounts: cfg.channels?.mattermost?.accounts as + | Record> + | 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 envPat = allowEnv ? process.env.MATTERMOST_PAT?.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 configPat = params.allowUnresolvedSecretRef + ? normalizeSecretInputString(merged.pat) + : normalizeResolvedSecretInputString({ + value: merged.pat, + path: `channels.mattermost.accounts.${accountId}.pat`, + }); + const configUrl = merged.baseUrl?.trim(); + const botToken = configToken || envToken; + const pat = configPat || envPat; + const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl); + const requireMention = resolveMattermostRequireMention(merged); + + let botTokenSource: MattermostTokenSource = "none"; + if (configToken) { + botTokenSource = "config"; + } else if (envToken) { + botTokenSource = "env"; + } + + let baseUrlSource: MattermostBaseUrlSource = "none"; + if (configUrl) { + baseUrlSource = "config"; + } else if (envUrl) { + baseUrlSource = "env"; + } + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + botToken, + botTokenSource, + pat, + baseUrl, + baseUrlSource, + config: merged, + chatmode: merged.chatmode, + oncharPrefixes: merged.oncharPrefixes, + requireMention, + textChunkLimit: merged.textChunkLimit, + blockStreaming: merged.blockStreaming, + blockStreamingCoalesce: 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"; +} + +export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] { + return listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/mattermost/src/mattermost/actions.ts b/extensions/mattermost/src/mattermost/actions.ts new file mode 100644 index 0000000..4d11b41 --- /dev/null +++ b/extensions/mattermost/src/mattermost/actions.ts @@ -0,0 +1,551 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import { dirname, extname } from "node:path"; +import { resolveMattermostAccount } from "./accounts.js"; +import { + createMattermostClient, + fetchMattermostMe, + type MattermostClient, + type MattermostFetch, +} from "./client.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +/** Default maximum file size (100MB in bytes) */ +export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024; + +/** Blocked executable file extensions */ +export const BLOCKED_EXTENSIONS = new Set([ + ".exe", + ".sh", + ".bat", + ".cmd", + ".com", + ".msi", + ".dll", + ".so", + ".dylib", + ".app", + ".dmg", + ".pkg", + ".deb", + ".rpm", + ".apk", + ".ipa", + ".jar", + ".war", + ".ear", + ".py", + ".rb", + ".pl", + ".php", + ".js", + ".ts", + ".vbs", + ".ps1", + ".psm1", + ".scr", + ".hta", + ".bin", + ".run", + ".out", + ".elf", +]); + +/** Allowed MIME type prefixes (configurable allowlist) */ +export const ALLOWED_MIME_PREFIXES = [ + "image/", + "video/", + "audio/", + "text/", + "application/pdf", + "application/json", + "application/xml", + "application/csv", + "application/zip", + "application/x-zip", + "application/x-zip-compressed", + "application/gzip", + "application/x-gzip", + "application/tar", + "application/x-tar", +]; + +export type EditMessageResult = + | { ok: true; postId: string; channelId: string } + | { ok: false; error: string; errorCode?: string }; + +export type EditMessageParams = { + cfg: OpenClawConfig; + postId: string; + channelId: string; + message: string; + props?: Record; + accountId?: string | null; + fetchImpl?: MattermostFetch; +}; + +const BOT_USER_CACHE_TTL_MS = 10 * 60_000; +const botUserIdCache = new Map(); + +async function resolveBotUserId( + client: MattermostClient, + cacheKey: string, +): Promise { + const cached = botUserIdCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.userId; + } + const me = await fetchMattermostMe(client); + const userId = me?.id?.trim(); + if (!userId) { + return null; + } + botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS }); + return userId; +} + +export async function editMessage(params: EditMessageParams): Promise { + 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: resolved.config?.allowPrivateNetwork === true, + }); + + const cacheKey = `${baseUrl}:${botToken}`; + const botUserId = await resolveBotUserId(client, cacheKey); + if (!botUserId) { + return { ok: false, error: "Mattermost edit failed: could not resolve bot user id." }; + } + + if (!params.postId?.trim()) { + return { ok: false, error: "Mattermost edit requires postId" }; + } + if (!params.channelId?.trim()) { + return { ok: false, error: "Mattermost edit requires channelId" }; + } + if (!params.message?.trim() && !params.props) { + return { ok: false, error: "Mattermost edit requires message or props" }; + } + + try { + const existingPost = await client.request<{ + id: string; + user_id: string; + channel_id: string; + type?: string; + }>(`/posts/${params.postId}`); + + if (existingPost.user_id !== botUserId) { + return { + ok: false, + error: "Permission denied: cannot edit messages from other users", + errorCode: "PERMISSION_DENIED", + }; + } + + if (existingPost.type && existingPost.type !== "") { + return { + ok: false, + error: "Cannot edit system messages", + errorCode: "SYSTEM_MESSAGE", + }; + } + + const updatePayload: Record = { + id: params.postId, + channel_id: params.channelId, + }; + + if (params.message?.trim()) { + updatePayload.message = params.message.trim(); + } + + if (params.props) { + updatePayload.props = params.props; + } + + const updatedPost = await client.request<{ + id: string; + channel_id: string; + }>(`/posts/${params.postId}`, { + method: "PUT", + body: JSON.stringify(updatePayload), + }); + + return { + ok: true, + postId: updatedPost.id, + channelId: updatedPost.channel_id, + }; + } catch (err) { + const errorMessage = String(err); + + if (errorMessage.includes("403") || errorMessage.includes("permission")) { + return { + ok: false, + error: "Permission denied: insufficient permissions to edit this message", + errorCode: "PERMISSION_DENIED", + }; + } + + if (errorMessage.includes("404") || errorMessage.includes("not found")) { + return { + ok: false, + error: "Post not found: the message may have been deleted", + errorCode: "POST_NOT_FOUND", + }; + } + + if (errorMessage.includes("429") || errorMessage.includes("rate limit")) { + return { + ok: false, + error: "Rate limited: too many edit requests, please try again later", + errorCode: "RATE_LIMITED", + }; + } + + if (errorMessage.includes("401") || errorMessage.includes("unauthorized")) { + return { + ok: false, + error: "Authentication failed: invalid or expired token", + errorCode: "AUTH_FAILED", + }; + } + + return { + ok: false, + error: `Mattermost edit failed: ${errorMessage}`, + errorCode: "UNKNOWN_ERROR", + }; + } +} + +export function resetMattermostEditBotUserCacheForTests(): void { + botUserIdCache.clear(); +} + +type DeleteMessageResult = { ok: true } | { ok: false; error: string }; + +export type DeleteMessageParams = { + cfg: OpenClawConfig; + postId: string; + channelId?: string; + accountId?: string | null; + fetchImpl?: MattermostFetch; +}; + +export async function deleteMessage(params: DeleteMessageParams): Promise { + 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: resolved.config?.allowPrivateNetwork === true, + }); + + return deleteMattermostPost(client, params.postId); +} + +async function deleteMattermostPost( + client: MattermostClient, + postId: string, +): Promise { + try { + await client.request(`/posts/${encodeURIComponent(postId)}`, { + method: "DELETE", + }); + return { ok: true }; + } catch (err) { + const errorMessage = String(err).toLowerCase(); + + const isNotFound = + errorMessage.includes("404") || + errorMessage.includes("not found") || + errorMessage.includes("message not found") || + errorMessage.includes("post not found") || + errorMessage.includes("no such post"); + + if (isNotFound) { + return { ok: true }; + } + + const isPermissionDenied = + errorMessage.includes("403") || + errorMessage.includes("permission denied") || + errorMessage.includes("unauthorized") || + errorMessage.includes("access denied") || + errorMessage.includes("forbidden"); + + if (isPermissionDenied) { + return { ok: false, error: `Permission denied: unable to delete message ${postId}` }; + } + + const isSystemMessage = + errorMessage.includes("system message") || + errorMessage.includes("cannot delete system message"); + + if (isSystemMessage) { + return { ok: false, error: `Cannot delete system message ${postId}` }; + } + + return { ok: false, error: `Failed to delete message ${postId}: ${String(err)}` }; + } +} + +export type DownloadFileResult = + | { ok: true; filePath: string; fileId: string; metadata: FileMetadata } + | { ok: false; error: string; errorCode?: string }; + +export type FileMetadata = { + id: string; + name: string; + mimeType: string; + size: number; + extension: string; +}; + +export type DownloadFileParams = { + cfg: OpenClawConfig; + fileId: string; + destinationPath: string; + maxSize?: number; + accountId?: string | null; + fetchImpl?: MattermostFetch; + /** Optional custom allowed MIME type prefixes */ + allowedMimePrefixes?: string[]; +}; + +/** + * Downloads a file from Mattermost with security validations. + * Uses PAT for authentication. + * Security: Validates file type against allowlist, blocks executables, checks size before download. + * Streams download to avoid memory issues. + * + * Mattermost API: GET /api/v4/files/{file_id} + */ +export async function downloadFile(params: DownloadFileParams): Promise { + 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.", errorCode: "CONFIG_ERROR" }; + } + + if (!params.fileId?.trim()) { + return { ok: false, error: "File ID is required", errorCode: "INVALID_PARAMS" }; + } + + if (!params.destinationPath?.trim()) { + return { ok: false, error: "Destination path is required", errorCode: "INVALID_PARAMS" }; + } + + const maxSize = params.maxSize ?? DEFAULT_MAX_FILE_SIZE; + const fileId = params.fileId.trim(); + const destinationPath = params.destinationPath.trim(); + + const client = createMattermostClient({ + baseUrl, + botToken, + fetchImpl: params.fetchImpl, + allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true, + }); + + try { + const fileInfo = await client.request<{ + id: string; + name?: string | null; + mime_type?: string | null; + size?: number | null; + }>(`/files/${fileId}/info`); + + const fileName = fileInfo.name?.trim() || "unknown"; + const mimeType = fileInfo.mime_type?.trim() || "application/octet-stream"; + const fileSize = fileInfo.size ?? 0; + const extension = extname(fileName).toLowerCase(); + + if (fileSize > maxSize) { + return { + ok: false, + error: `File size (${fileSize} bytes) exceeds maximum allowed (${maxSize} bytes)`, + errorCode: "FILE_TOO_LARGE", + }; + } + + if (fileSize <= 0) { + return { + ok: false, + error: "Invalid file size: file is empty", + errorCode: "EMPTY_FILE", + }; + } + + if (BLOCKED_EXTENSIONS.has(extension)) { + return { + ok: false, + error: `Executable file type not allowed: ${extension}`, + errorCode: "BLOCKED_FILE_TYPE", + }; + } + + const allowedPrefixes = params.allowedMimePrefixes ?? ALLOWED_MIME_PREFIXES; + const isMimeTypeAllowed = allowedPrefixes.some((prefix) => mimeType.toLowerCase().startsWith(prefix.toLowerCase())); + if (!isMimeTypeAllowed) { + return { + ok: false, + error: `File MIME type not allowed: ${mimeType}`, + errorCode: "BLOCKED_MIME_TYPE", + }; + } + + const dir = dirname(destinationPath); + await mkdir(dir, { recursive: true }); + + const downloadUrl = `${client.apiBaseUrl}/files/${fileId}`; + const res = await client.fetchImpl(downloadUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${botToken}`, + }, + }); + + if (!res.ok) { + const detail = await readMattermostError(res); + return { + ok: false, + error: `Download failed: ${res.status} ${res.statusText} - ${detail}`, + errorCode: "DOWNLOAD_FAILED", + }; + } + + const contentLength = res.headers.get("content-length"); + if (contentLength) { + const actualSize = parseInt(contentLength, 10); + if (actualSize > maxSize) { + return { + ok: false, + error: `File size (${actualSize} bytes) exceeds maximum allowed (${maxSize} bytes)`, + errorCode: "FILE_TOO_LARGE", + }; + } + } + + const fileStream = createWriteStream(destinationPath); + const body = res.body; + if (!body) { + return { + ok: false, + error: "Download failed: no response body", + errorCode: "DOWNLOAD_FAILED", + }; + } + + const reader = body.getReader(); + let downloadedSize = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + downloadedSize += value.length; + if (downloadedSize > maxSize) { + fileStream.destroy(); + await unlink(destinationPath).catch(() => undefined); + return { + ok: false, + error: `Downloaded file size exceeds maximum allowed (${maxSize} bytes)`, + errorCode: "FILE_TOO_LARGE", + }; + } + + fileStream.write(Buffer.from(value)); + } + + fileStream.end(); + await new Promise((resolve, reject) => { + fileStream.on("finish", resolve); + fileStream.on("error", reject); + }); + } catch (streamErr) { + fileStream.destroy(); + await unlink(destinationPath).catch(() => undefined); + throw streamErr; + } + + const metadata: FileMetadata = { + id: fileId, + name: fileName, + mimeType, + size: downloadedSize, + extension, + }; + + return { + ok: true, + filePath: destinationPath, + fileId, + metadata, + }; + } catch (err) { + const errorMessage = String(err); + + if (errorMessage.includes("404") || errorMessage.includes("not found")) { + return { + ok: false, + error: "File not found", + errorCode: "FILE_NOT_FOUND", + }; + } + + if (errorMessage.includes("403") || errorMessage.includes("permission")) { + return { + ok: false, + error: "Permission denied: insufficient permissions to download this file", + errorCode: "PERMISSION_DENIED", + }; + } + + if (errorMessage.includes("401") || errorMessage.includes("unauthorized")) { + return { + ok: false, + error: "Authentication failed: invalid or expired token", + errorCode: "AUTH_FAILED", + }; + } + + return { + ok: false, + error: `Download failed: ${errorMessage}`, + errorCode: "DOWNLOAD_FAILED", + }; + } +} + +async function readMattermostError(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await res.json()) as { message?: string } | undefined; + if (data?.message) { + return data.message; + } + return JSON.stringify(data); + } + return await res.text(); +} diff --git a/extensions/mattermost/src/mattermost/client.retry.test.ts b/extensions/mattermost/src/mattermost/client.retry.test.ts new file mode 100644 index 0000000..14e770e --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.retry.test.ts @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js"; + +describe("createMattermostDirectChannelWithRetry", () => { + const mockFetch = vi.fn(); + + 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", + // pragma: allowlist secret + 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(run: Promise): Promise { + await vi.runAllTimersAsync(); + return await run; + } + + function suppressUnhandled(run: Promise): Promise { + run.catch(() => {}); + return run; + } + + it("succeeds on first attempt without retries", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-123" }), + } as Response); + + 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({ + ok: false, + status: 429, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Too many requests" }), + text: async () => "Too many requests", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-456" }), + } as Response); + + 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); + expect(onRetry).toHaveBeenCalledWith( + 1, + expect.any(Number), + expect.objectContaining({ message: expect.stringContaining("429") }), + ); + }); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-port" }), + } as Response); + + 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({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Invalid user ID: 4294967295" }), + text: async () => "Invalid user ID: 4294967295", + } as Response); + + const client = createMockClient(); + + const run = suppressUnhandled( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ); + await expect(resolveRetryRun(run)).rejects.toThrow(); + + // 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({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 502, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad gateway" }), + text: async () => "Bad gateway", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-789" }), + } as Response); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-abc" }), + } as Response); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-fetch-failed" }), + } as Response); + + 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({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad request" }), + text: async () => "Bad request", + } as Response); + + 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({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "User not found" }), + text: async () => "User not found", + } as Response); + + 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.mockResolvedValue({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response); + + const client = createMockClient(); + + const run = suppressUnhandled( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 2, + initialDelayMs: 10, + }), + ); + await expect(resolveRetryRun(run)).rejects.toThrow(); + + 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(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(abortSignal).toBeDefined(); + expect(abortListenerCalled).toBe(true); + }); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-delay" }), + } as Response); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-max" }), + } as Response); + + 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({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request timeout: connection timed out" }), + text: async () => "Request timeout: connection timed out", + } as Response); + + 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({ + ok: false, + status: 403, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request aborted: forbidden" }), + text: async () => "Request aborted: forbidden", + } as Response); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-signal" }), + } as Response); + }); + + const client = createMockClient(); + await resolveRetryRun( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + timeoutMs: 5000, + }), + ); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + 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({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-5xx-with-404" }), + } as Response); + + 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"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts new file mode 100644 index 0000000..7a3e2ea --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createMattermostClient, + createMattermostPost, + normalizeMattermostBaseUrl, + 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 = typeof url === "string" ? url : url.toString(); + calls.push({ url: urlStr, init }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": contentType }, + }); + }); + + return { mockFetch: mockFetch as typeof fetch, calls }; +} + +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[2], + response?: { status?: number; body?: unknown; contentType?: string }, +) { + const { client, calls } = createTestClient(response ?? { body: { id: "post1" } }); + await updateMattermostPost(client, "post1", update); + return { + calls, + body: JSON.parse(calls[0].init?.body as string) as Record, + }; +} + +// ── normalizeMattermostBaseUrl ──────────────────────────────────────── + +describe("normalizeMattermostBaseUrl", () => { + it("strips trailing slashes", () => { + expect(normalizeMattermostBaseUrl("http://localhost:8065/")).toBe("http://localhost:8065"); + }); + + it("strips /api/v4 suffix", () => { + expect(normalizeMattermostBaseUrl("http://localhost:8065/api/v4")).toBe( + "http://localhost:8065", + ); + }); + + it("returns undefined for empty input", () => { + expect(normalizeMattermostBaseUrl("")).toBeUndefined(); + expect(normalizeMattermostBaseUrl(null)).toBeUndefined(); + expect(normalizeMattermostBaseUrl(undefined)).toBeUndefined(); + }); + + it("preserves valid base URL", () => { + expect(normalizeMattermostBaseUrl("http://mm.example.com")).toBe("http://mm.example.com"); + }); +}); + +// ── createMattermostClient ─────────────────────────────────────────── + +describe("createMattermostClient", () => { + it("creates a client with normalized baseUrl", () => { + const { mockFetch } = createMockFetch(); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065/", + botToken: "tok", + fetchImpl: mockFetch, + }); + expect(client.baseUrl).toBe("http://localhost:8065"); + expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4"); + }); + + it("throws on empty baseUrl", () => { + expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow( + "baseUrl is required", + ); + }); + + it("sends Authorization header with Bearer token", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "my-secret-token", + fetchImpl: mockFetch, + }); + await client.request("/users/me"); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("Authorization")).toBe("Bearer my-secret-token"); + }); + + it("sets Content-Type for string bodies", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) }); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("throws on non-ok responses", async () => { + const { mockFetch } = createMockFetch({ + status: 404, + body: { message: "Not Found" }, + }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404"); + }); + + it("returns undefined on 204 responses", async () => { + const fetchImpl = vi.fn(async () => { + return new Response(null, { status: 204 }); + }); + const client = createMattermostClient({ + baseUrl: "https://chat.example.com", + // pragma: allowlist secret + botToken: "test-token", + fetchImpl, + }); + const result = await client.request("/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 = JSON.parse(calls[0].init?.body as string); + expect(body.channel_id).toBe("ch123"); + expect(body.message).toBe("Hello world"); + }); + + it("includes rootId when provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post2" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "Reply", + rootId: "root456", + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.root_id).toBe("root456"); + }); + + it("includes fileIds when provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post3" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "With file", + fileIds: ["file1", "file2"], + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.file_ids).toEqual(["file1", "file2"]); + }); + + it("includes props when provided (for interactive buttons)", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post4" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + const props = { + attachments: [ + { + text: "Choose:", + actions: [{ id: "btn1", type: "button", name: "Click" }], + }, + ], + }; + + await createMattermostPost(client, { + channelId: "ch123", + message: "Pick an option", + props, + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.props).toEqual(props); + expect(body.props.attachments[0].actions[0].type).toBe("button"); + }); + + it("omits props when not provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "No props", + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.props).toBeUndefined(); + }); +}); + +// ── updateMattermostPost ───────────────────────────────────────────── + +describe("updateMattermostPost", () => { + it("sends PUT to /posts/{id}", async () => { + const { calls } = await updatePostAndCapture({ message: "Updated" }); + + expect(calls[0].url).toContain("/posts/post1"); + expect(calls[0].init?.method).toBe("PUT"); + }); + + it("includes post id in the body", async () => { + const { 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.message).toBe("Original message"); + expect(body.props).toMatchObject({ + attachments: [{ text: expect.stringContaining("✓") }], + }); + expect(body.props).toMatchObject({ + attachments: [{ text: expect.stringContaining("do_now") }], + }); + }); + + 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: [] }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts new file mode 100644 index 0000000..bcbbb38 --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.ts @@ -0,0 +1,496 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { z } from "openclaw/plugin-sdk/zod"; +import { + MattermostError, + MattermostAPIError, + AuthenticationError, + ValidationError, + PermissionError, + RateLimitError, + ResourceNotFoundError, + NetworkError, + TimeoutError, + ConfigurationError, + MissingBotTokenError, + MissingBaseUrlError, + createErrorFromResponse, + createErrorContext, + withRetry, + isRetryableError, + getRetryAfterMs, + globalErrorBoundary, + getUserFriendlyMessage, + ErrorCodes, + type ErrorContext, + type RetryConfig, +} from "../errors.js"; + +export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export type MattermostClient = { + baseUrl: string; + apiBaseUrl: string; + token: string; + request: (path: string, init?: RequestInit, operation?: string) => Promise; + /** Guarded fetch implementation; use in place of raw fetch for outbound requests. */ + fetchImpl: MattermostFetch; + /** + * Get the appropriate token for an operation. + * - 'read' operations use botToken + * - 'write' operations use PAT if available, otherwise botToken + */ + getTokenForOperation: (operation: "read" | "write") => string; +}; + +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; + +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, + operation = "build-url", + context?: Partial +): string { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + throw new MissingBaseUrlError( + { operation, ...context }, + new Error("Mattermost baseUrl is required") + ); + } + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${normalized}/api/v4${suffix}`; +} + +export async function readMattermostError(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await res.json()) as { message?: string } | undefined; + if (data?.message) { + return data.message; + } + return JSON.stringify(data); + } + return await res.text(); +} + +export function createMattermostClient( + params: { + baseUrl: string; + botToken: string; + pat?: string; + fetchImpl?: MattermostFetch; + allowPrivateNetwork?: boolean; + /** Optional account ID for error context */ + accountId?: string; + }, + operationContext?: Partial +): MattermostClient { + const baseUrl = normalizeMattermostBaseUrl(params.baseUrl); + const botToken = params.botToken.trim(); + const pat = params.pat?.trim(); + + if (!baseUrl) { + throw new MissingBaseUrlError( + { operation: "create-client", accountId: params.accountId, ...operationContext }, + new Error("Mattermost baseUrl is required") + ); + } + + if (!botToken) { + throw new MissingBotTokenError( + { operation: "create-client", accountId: params.accountId, ...operationContext }, + new Error("Mattermost bot token is required") + ); + } + + const apiBaseUrl = `${baseUrl}/api/v4`; + + const getTokenForOperation = (operation: "read" | "write"): string => { + if (operation === "read") { + return botToken; + } + return pat || botToken; + }; + + const token = botToken; + const errorContext: Partial = { + accountId: params.accountId, + ...operationContext + }; + // 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; + + // Guarded fetch adapter: calls fetchWithSsrFGuard and returns a plain Response. + // Body is buffered before releasing the dispatcher so callers get a complete Response. + // Null-body status codes per Fetch spec — Response constructor rejects a body for these. + const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); + + const guardedFetchImpl: MattermostFetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + auditContext: "mattermost-api", + policy: params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + }); + try { + const bodyBytes = NULL_BODY_STATUSES.has(response.status) + ? null + : await response.arrayBuffer(); + return new Response(bodyBytes, { status: response.status, headers: response.headers }); + } finally { + await release(); + } + }; + + const fetchImpl = externalFetchImpl ?? guardedFetchImpl; + + const request = async ( + path: string, + init?: RequestInit, + operation = "api-request" + ): Promise => { + const url = buildMattermostApiUrl(baseUrl, path, operation, errorContext); + 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 operationName = `${operation}: ${path}`; + const requestContext: Partial = { + ...errorContext, + requestPath: path, + }; + + try { + const res = await fetchImpl(url, { ...init, headers }); + + if (!res.ok) { + const error = await createErrorFromResponse(res, operationName, requestContext); + throw error; + } + + if (res.status === 204) { + return undefined as T; + } + + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return (await res.json()) as T; + } + + return (await res.text()) as T; + } catch (err) { + if (err instanceof MattermostError) { + throw err; + } + + const isNetworkError = err instanceof TypeError || + (err instanceof Error && ( + err.message.includes("fetch") || + err.message.includes("network") || + err.message.includes("ECONN") + )); + + if (isNetworkError) { + throw new NetworkError( + `Network error during ${operationName}`, + createErrorContext(operationName, requestContext), + err + ); + } + + throw err; + } + }; + + return { baseUrl, apiBaseUrl, token, request, fetchImpl, getTokenForOperation }; +} + +export async function fetchMattermostMe( + client: MattermostClient, + context?: Partial +): Promise { + return await client.request("/users/me", undefined, "fetch-me"); +} + +export async function fetchMattermostUser( + client: MattermostClient, + userId: string, + context?: Partial +): Promise { + return await client.request( + `/users/${userId}`, + undefined, + "fetch-user" + ); +} + +export async function fetchMattermostUserByUsername( + client: MattermostClient, + username: string, +): Promise { + return await client.request(`/users/username/${encodeURIComponent(username)}`); +} + +export async function fetchMattermostChannel( + client: MattermostClient, + channelId: string, +): Promise { + return await client.request(`/channels/${channelId}`); +} + +export async function fetchMattermostChannelByName( + client: MattermostClient, + teamId: string, + channelName: string, +): Promise { + return await client.request( + `/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`, + ); +} + +export async function sendMattermostTyping( + client: MattermostClient, + params: { channelId: string; parentId?: string }, +): Promise { + const payload: Record = { + channel_id: params.channelId, + }; + const parentId = params.parentId?.trim(); + if (parentId) { + payload.parent_id = parentId; + } + await client.request>("/users/me/typing", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function createMattermostDirectChannel( + client: MattermostClient, + userIds: string[], + signal?: AbortSignal, +): Promise { + return await client.request("/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; +}; + +export async function createMattermostDirectChannelWithRetry( + client: MattermostClient, + userIds: string[], + options: CreateDmChannelRetryOptions = {}, + context?: Partial +): Promise { + const { + maxRetries = 3, + initialDelayMs = 1000, + maxDelayMs = 10000, + timeoutMs = 30000, + onRetry, + } = options; + + const operationContext: Partial = { + operation: "create-dm-channel", + ...context, + }; + + const retryConfig: RetryConfig = { + maxRetries, + initialDelayMs, + maxDelayMs, + timeoutMs, + onRetry: onRetry + ? (info) => onRetry(info.attempt, info.delayMs, info.error) + : undefined, + }; + + return await withRetry( + "create-dm-channel", + async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await createMattermostDirectChannel(client, userIds, controller.signal); + } finally { + clearTimeout(timeoutId); + } + }, + retryConfig, + operationContext + ); +} + +export async function createMattermostPost( + client: MattermostClient, + params: { + channelId: string; + message: string; + rootId?: string; + fileIds?: string[]; + props?: Record; + }, +): Promise { + const payload: Record = { + 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("/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 { + return await client.request(`/users/${userId}/teams`); +} + +export async function updateMattermostPost( + client: MattermostClient, + postId: string, + params: { + message?: string; + props?: Record; + }, +): Promise { + const payload: Record = { id: postId }; + if (params.message !== undefined) { + payload.message = params.message; + } + if (params.props !== undefined) { + payload.props = params.props; + } + return await client.request(`/posts/${postId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export async function uploadMattermostFile( + client: MattermostClient, + params: { + channelId: string; + buffer: Buffer; + fileName: string; + contentType?: string; + }, +): Promise { + const form = new FormData(); + const fileName = params.fileName?.trim() || "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 res.json()) as { file_infos?: MattermostFileInfo[] }; + const info = data.file_infos?.[0]; + if (!info?.id) { + throw new Error("Mattermost file upload failed"); + } + return info; +} diff --git a/extensions/mattermost/src/mattermost/directory.test.ts b/extensions/mattermost/src/mattermost/directory.test.ts new file mode 100644 index 0000000..98dd679 --- /dev/null +++ b/extensions/mattermost/src/mattermost/directory.test.ts @@ -0,0 +1,172 @@ +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("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", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ term: "ali", team_id: "team-1" }), + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts new file mode 100644 index 0000000..b622f4a --- /dev/null +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -0,0 +1,172 @@ +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: account.config?.allowPrivateNetwork === true, + }); +} + +/** + * 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(); + 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 { + const clients = buildClients(params); + if (!clients.length) { + return []; + } + const q = params.query?.trim().toLowerCase() || ""; + const seenIds = new Set(); + const entries: ChannelDirectoryEntry[] = []; + + for (const client of clients) { + try { + const me = await fetchMattermostMe(client); + const channels = await client.request( + `/users/${me.id}/channels?per_page=200`, + ); + for (const ch of channels) { + if (ch.type !== "O" && ch.type !== "P") continue; + if (seenIds.has(ch.id)) continue; + if (q) { + const name = (ch.name ?? "").toLowerCase(); + const display = (ch.display_name ?? "").toLowerCase(); + if (!name.includes(q) && !display.includes(q)) continue; + } + seenIds.add(ch.id); + entries.push({ + kind: "group" as const, + id: `channel:${ch.id}`, + name: ch.name ?? undefined, + handle: ch.display_name ?? undefined, + }); + } + } catch (err) { + // Token may be expired/revoked — skip this account and try others + console.debug?.( + "[mattermost-directory] listGroups: skipping account:", + (err as Error)?.message, + ); + continue; + } + } + return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries; +} + +/** + * List team members as peer directory entries. + * + * Uses only the first available client since all bots in a team see the same + * user list (unlike channels where membership varies). Uses the first team + * returned — multi-team setups will only see members from that team. + * + * NOTE: per_page=200 for member listing; same pagination caveat as groups. + */ +export async function listMattermostDirectoryPeers( + params: MattermostDirectoryParams, +): Promise { + const clients = buildClients(params); + if (!clients.length) { + return []; + } + // All bots see the same user list, so one client suffices (unlike channels + // where private channel membership varies per bot). + const client = clients[0]; + try { + const me = await fetchMattermostMe(client); + const teams = await client.request<{ id: string }[]>("/users/me/teams"); + if (!teams.length) { + return []; + } + // Uses first team — multi-team setups may need iteration in the future + const teamId = teams[0].id; + const q = params.query?.trim().toLowerCase() || ""; + + let users: MattermostUser[]; + if (q) { + users = await client.request("/users/search", { + method: "POST", + body: JSON.stringify({ term: q, team_id: teamId }), + }); + } else { + const members = await client.request<{ user_id: string }[]>( + `/teams/${teamId}/members?per_page=200`, + ); + const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id); + if (!userIds.length) { + return []; + } + users = await client.request("/users/ids", { + method: "POST", + body: JSON.stringify(userIds), + }); + } + + const entries = users + .filter((u) => u.id !== me.id) + .map((u) => ({ + kind: "user" as const, + id: `user:${u.id}`, + name: u.username ?? undefined, + handle: + [u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined, + })); + return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries; + } catch (err) { + console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message); + return []; + } +} diff --git a/extensions/mattermost/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts new file mode 100644 index 0000000..9d09fc4 --- /dev/null +++ b/extensions/mattermost/src/mattermost/index.ts @@ -0,0 +1,9 @@ +export { + listEnabledMattermostAccounts, + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, +} from "./accounts.js"; +export { monitorMattermostProvider } from "./monitor.js"; +export { probeMattermost } from "./probe.js"; +export { sendMessageMattermost } from "./send.js"; diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts new file mode 100644 index 0000000..d35c830 --- /dev/null +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -0,0 +1,887 @@ +import { type IncomingMessage, type 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, + getInteractionCallbackUrl, + getInteractionSecret, + resolveInteractionCallbackPath, + resolveInteractionCallbackUrl, + setInteractionCallbackUrl, + setInteractionSecret, + verifyInteractionToken, +} from "./interactions.js"; + +// ── HMAC token management ──────────────────────────────────────────── + +describe("setInteractionSecret / getInteractionSecret", () => { + beforeEach(() => { + // pragma: allowlist secret + 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(() => { + // pragma: allowlist secret + 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", () => { + // pragma: allowlist secret + setInteractionSecret("acct-a", "bot-token-a"); + // pragma: allowlist secret + setInteractionSecret("acct-b", "bot-token-b"); + const context = { action_id: "do_now", item_id: "123" }; + const tokenA = generateInteractionToken(context, "acct-a"); + + expect(verifyInteractionToken(context, tokenA, "acct-a")).toBe(true); + expect(verifyInteractionToken(context, tokenA, "acct-b")).toBe(false); + }); +}); + +// ── Callback URL registry ──────────────────────────────────────────── + +describe("callback URL registry", () => { + it("stores and retrieves callback URLs", () => { + setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1"); + expect(getInteractionCallbackUrl("acct1")).toBe( + "http://localhost:18789/mattermost/interactions/acct1", + ); + }); + + it("returns undefined for unknown account", () => { + expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined(); + }); +}); + +describe("resolveInteractionCallbackUrl", () => { + afterEach(() => { + 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: { + // pragma: allowlist secret + 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(result[0].actions).toHaveLength(2); + }); + + it("sets type to 'button' on every action", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "a", name: "A" }], + }); + + expect(result[0].actions![0].type).toBe("button"); + }); + + it("includes HMAC _token in integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "test", name: "Test" }], + }); + + const action = result[0].actions![0]; + expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("includes sanitized action_id in integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "my_action", name: "Do It" }], + }); + + const action = result[0].actions![0]; + // sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747) + expect(action.integration.context.action_id).toBe("myaction"); + expect(action.id).toBe("myaction"); + }); + + it("merges custom context into integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }], + }); + + const ctx = result[0].actions![0].integration.context; + expect(ctx.tweet_id).toBe("123"); + expect(ctx.batch).toBe(true); + expect(ctx.action_id).toBe("btn"); + expect(ctx._token).toBeDefined(); + }); + + it("passes callback URL to each button integration", () => { + const url = "http://localhost:18789/mattermost/interactions/default"; + const result = buildButtonAttachments({ + callbackUrl: url, + buttons: [ + { id: "a", name: "A" }, + { id: "b", name: "B" }, + ], + }); + + for (const action of result[0].actions!) { + expect(action.integration.url).toBe(url); + } + }); + + it("preserves button style", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [ + { id: "ok", name: "OK", style: "primary" }, + { id: "no", name: "No", style: "danger" }, + ], + }); + + expect(result[0].actions![0].style).toBe("primary"); + expect(result[0].actions![1].style).toBe("danger"); + }); + + it("uses provided text for the attachment", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "x", name: "X" }], + text: "Choose an action:", + }); + + expect(result[0].text).toBe("Choose an action:"); + }); + + it("defaults to empty string text when not provided", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "x", name: "X" }], + }); + + expect(result[0].text).toBe(""); + }); + + it("generates verifiable tokens", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + const { _token, ...contextWithoutToken } = ctx; + expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); + }); + + it("generates tokens that verify even when Mattermost reorders context keys", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + + // Simulate Mattermost returning context with keys in a different order + const reordered: Record = {}; + const keys = Object.keys(ctx).filter((k) => k !== "_token"); + // Reverse the key order to simulate reordering + for (const key of keys.reverse()) { + reordered[key] = ctx[key]; + } + expect(verifyInteractionToken(reordered, token)).toBe(true); + }); +}); + +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, + ): MattermostClient { + return { + baseUrl: "https://chat.example.com", + apiBaseUrl: "https://chat.example.com/api/v4", + // pragma: allowlist secret + token: "bot-token", + request: async (path: string, init?: RequestInit) => (await requestImpl(path, init)) as T, + fetchImpl: vi.fn(), + }; + } + + beforeEach(() => { + setInteractionRuntime(); + // pragma: allowlist secret + setInteractionSecret("acct", "bot-token"); + }); + + function createReq(params: { + method?: string; + body?: unknown; + remoteAddress?: string; + headers?: Record; + }): IncomingMessage { + const body = params.body === undefined ? "" : JSON.stringify(params.body); + const listeners = new Map void>>(); + + const req = { + 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; + }, + } 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; 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; 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; + 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, + params: { + body: unknown; + remoteAddress?: string; + headers?: Record; + }, + ) { + 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; + }) { + 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("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( + expect.stringContaining('Mattermost button click: action="approve"'), + expect.objectContaining({ sessionKey: "session:thread:root-9" }), + ); + expect(dispatchButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "chan-1", + userId: "user-1", + 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 handler = createMattermostInteractionHandler({ + client: createMattermostClientMock(async (path: string, init?: { method?: string }) => { + requestLog.push({ path, method: init?.method }); + return createActionPost({ + actionId: "mdlprov", + actionName: "Browse providers", + }); + }), + botUserId: "bot", + accountId: "acct", + handleInteraction, + dispatchButtonClick, + }); + + const res = await runHandler(handler, { + body: createInteractionBody({ + context, + token, + userId: "user-2", + userName: "alice", + }), + }); + + 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( + expect.objectContaining({ + actionId: "mdlprov", + actionName: "Browse providers", + originalMessage: "Choose", + post: expect.objectContaining({ id: "post-1" }), + userName: "alice", + }), + ); + expect(dispatchButtonClick).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts new file mode 100644 index 0000000..1ede117 --- /dev/null +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -0,0 +1,687 @@ +import { createHmac } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { getMattermostRuntime } from "../runtime.js"; +import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; +import { isTrustedProxyAddress, 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/ + */ +export type MattermostInteractionPayload = { + user_id: string; + user_name?: string; + channel_id: string; + team_id?: string; + post_id: string; + trigger_id?: string; + type?: string; + data_source?: string; + context?: Record; +}; + +export type MattermostInteractionResponse = { + update?: { + message: string; + props?: Record; + }; + ephemeral_text?: string; +}; + +export 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; +}; + +// ── Callback URL registry ────────────────────────────────────────────── + +const callbackUrls = new Map(); + +export function setInteractionCallbackUrl(accountId: string, url: string): void { + callbackUrls.set(accountId, url); +} + +export function getInteractionCallbackUrl(accountId: string): string | undefined { + return callbackUrls.get(accountId); +} + +type InteractionCallbackConfig = Pick & { + 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 value[0]?.trim() || undefined; + } + return value?.trim() || undefined; +} + +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 = + cfg?.interactions?.callbackBaseUrl?.trim() ?? + cfg?.channels?.mattermost?.interactions?.callbackBaseUrl?.trim(); + 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(); +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) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]); + return Object.fromEntries(entries); + } + return value; +} + +export function generateInteractionToken( + context: Record, + 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, + token: string, + accountId?: string, +): boolean { + const expected = generateInteractionToken(context, accountId); + return safeEqualSecret(expected, token); +} + +// ── Button builder helpers ───────────────────────────────────────────── + +export type MattermostButton = { + id: string; + type: "button" | "select"; + name: string; + style?: "default" | "primary" | "danger"; + integration: { + url: string; + context: Record; + }; +}; + +export type MattermostAttachment = { + text?: string; + actions?: MattermostButton[]; + [key: string]: unknown; +}; + +/** + * Build Mattermost `props.attachments` with interactive buttons. + * + * Each button includes an HMAC token in its integration context so the + * callback handler can verify the request originated from a legitimate + * button click (Mattermost's recommended security pattern). + */ +/** + * Sanitize a button ID so Mattermost's action router can match it. + * Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}` + * and IDs containing hyphens or underscores break the server-side routing. + * See: https://github.com/mattermost/mattermost/issues/25747 + */ +function sanitizeActionId(id: string): string { + return id.replace(/[-_]/g, ""); +} + +export function buildButtonAttachments(params: { + callbackUrl: string; + accountId?: string; + buttons: Array<{ + id: string; + name: string; + style?: "default" | "primary" | "danger"; + context?: Record; + }>; + text?: string; +}): MattermostAttachment[] { + const actions: MattermostButton[] = params.buttons.map((btn) => { + const safeId = sanitizeActionId(btn.id); + const context: Record = { + 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; + text?: string; +}): Record | undefined { + const rawButtons = params.buttons.flatMap((item) => + Array.isArray(item) ? item : [item], + ) as MattermostInteractiveButtonInput[]; + + const buttons = rawButtons + .map((btn) => ({ + id: String(btn.id ?? btn.callback_data ?? "").trim(), + name: String(btn.text ?? btn.name ?? btn.label ?? "").trim(), + 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 { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + const timer = setTimeout(() => { + req.destroy(); + reject(new Error("Request body read timeout")); + }, INTERACTION_BODY_TIMEOUT_MS); + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > INTERACTION_MAX_BODY_BYTES) { + req.destroy(); + clearTimeout(timer); + reject(new Error("Request body too large")); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + clearTimeout(timer); + resolve(Buffer.concat(chunks).toString("utf8")); + }); + + req.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +// ── HTTP handler ─────────────────────────────────────────────────────── + +export function createMattermostInteractionHandler(params: { + client: MattermostClient; + botUserId: string; + accountId: string; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + resolveSessionKey?: (params: { + channelId: string; + userId: string; + post: MattermostPost; + }) => Promise; + handleInteraction?: (opts: { + payload: MattermostInteractionPayload; + userName: string; + actionId: string; + actionName: string; + originalMessage: string; + context: Record; + post: MattermostPost; + }) => Promise; + authorizeButtonClick?: (opts: { + payload: MattermostInteractionPayload; + post: MattermostPost; + }) => Promise; + dispatchButtonClick?: (opts: { + channelId: string; + userId: string; + userName: string; + actionId: string; + actionName: string; + postId: string; + post: MattermostPost; + }) => Promise; + log?: (message: string) => void; +}): (req: IncomingMessage, res: ServerResponse) => Promise { + const { client, accountId, log } = params; + const core = getMattermostRuntime(); + + return async (req: IncomingMessage, res: ServerResponse) => { + // Only accept POST + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Method Not Allowed" })); + return; + } + + 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 = JSON.parse(raw) as MattermostInteractionPayload; + } catch (err) { + log?.(`mattermost interaction: failed to parse body: ${String(err)}`); + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid request body" })); + return; + } + + const context = payload.context; + if (!context) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing context" })); + return; + } + + // Verify HMAC token + const token = context._token; + if (typeof token !== "string") { + log?.("mattermost interaction: missing _token in context"); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing token" })); + return; + } + + // Strip _token before verification (it wasn't in the original context) + const { _token, ...contextWithoutToken } = context; + if (!verifyInteractionToken(contextWithoutToken, token, accountId)) { + log?.("mattermost interaction: invalid _token"); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid token" })); + return; + } + + const actionId = context.action_id; + if (typeof actionId !== "string") { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing action_id in context" })); + return; + } + + 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 = null; + let clickedButtonName: string | null = null; + try { + originalPost = await client.request(`/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 ?? ""}`, + ); + 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)}`); + } + } + }; +} diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts new file mode 100644 index 0000000..753c921 --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -0,0 +1,175 @@ +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>([ + ["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(), +}; + +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 to switch"); + expect(view.buttons[0]?.[0]?.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("falls back to the routed agent default model when no override is stored", async () => { + 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>([ + ["anthropic", new Set(["claude-opus-4-5"])], + ["openai", new Set(["gpt-5"])], + ]), + providers: ["anthropic", "openai"], + resolvedDefault: { + provider: "openai", + model: "gpt-5", + }, + modelNames: new Map(), + }; + + expect( + resolveMattermostModelPickerCurrentModel({ + cfg, + route: { + agentId: "support", + sessionKey: "agent:support:main", + }, + data: providerData, + }), + ).toBe("openai/gpt-5"); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts new file mode 100644 index 0000000..f743041 --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -0,0 +1,382 @@ +import { createHash } from "node:crypto"; +import type { MattermostInteractiveButtonInput } from "./interactions.js"; +import { + loadSessionStore, + normalizeProviderId, + resolveStorePath, + resolveStoredModelOverride, + type ModelsProviderData, + type OpenClawConfig, +} from "./runtime-api.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; + +export type MattermostModelPickerEntry = + | { kind: "summary" } + | { kind: "providers" } + | { kind: "models"; provider: string }; + +export 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 }; + +export type MattermostModelPickerRenderedView = { + text: string; + buttons: MattermostInteractiveButtonInput[][]; +}; + +function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null { + const trimmed = modelRef?.trim(); + 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 = match[2].trim(); + if (!provider || !model) { + return null; + } + return { provider, model }; +} + +function normalizePage(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 1; + } + return Math.max(1, Math.floor(value as number)); +} + +function paginateItems(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 { + 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: String(params.model ?? "").trim(), + }; + + 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())].sort(); +} + +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, +): MattermostModelPickerState | null { + if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) { + return null; + } + + const ownerUserId = String(context.ownerUserId ?? "").trim(); + const action = String(context.action ?? "").trim(); + if (!ownerUserId) { + return null; + } + + if (action === "providers" || action === "back") { + return { action, ownerUserId }; + } + + const provider = normalizeProviderId(String(context.provider ?? "")); + const page = Number.parseInt(String(context.page ?? "1"), 10); + if (!provider) { + return null; + } + + if (action === "list") { + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + }; + } + + if (action === "select") { + const model = String(context.model ?? "").trim(); + if (!model) { + return null; + } + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + model, + }; + } + + return null; +} + +export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set { + const refs = new Set(); + 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; + skipCache?: boolean; +}): string { + const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`; + try { + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const sessionStore = params.skipCache + ? loadSessionStore(storePath, { skipCache: true }) + : loadSessionStore(storePath); + const sessionEntry = sessionStore[params.route.sessionKey]; + const override = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey: params.route.sessionKey, + 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 to switch", + "/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, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-auth.test.ts b/extensions/mattermost/src/mattermost/monitor-auth.test.ts new file mode 100644 index 0000000..471d5b4 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-auth.test.ts @@ -0,0 +1,165 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn()); +const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); +const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn()); +const resolveControlCommandGate = vi.hoisted(() => vi.fn()); +const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn()); + +vi.mock("./runtime-api.js", () => ({ + evaluateSenderGroupAccessForPolicy, + isDangerousNameMatchingEnabled, + resolveAllowlistMatchSimple, + resolveControlCommandGate, + resolveEffectiveAllowFromLists, +})); + +describe("mattermost monitor auth", () => { + let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation; + let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed; + let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry; + let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList; + let resolveMattermostEffectiveAllowFromLists: typeof import("./monitor-auth.js").resolveMattermostEffectiveAllowFromLists; + + beforeAll(async () => { + ({ + authorizeMattermostCommandInvocation, + isMattermostSenderAllowed, + normalizeMattermostAllowEntry, + normalizeMattermostAllowList, + resolveMattermostEffectiveAllowFromLists, + } = await import("./monitor-auth.js")); + }); + + beforeEach(() => { + evaluateSenderGroupAccessForPolicy.mockReset(); + isDangerousNameMatchingEnabled.mockReset(); + resolveAllowlistMatchSimple.mockReset(); + resolveControlCommandGate.mockReset(); + resolveEffectiveAllowFromLists.mockReset(); + }); + + it("normalizes allowlist entries and resolves effective lists", () => { + resolveEffectiveAllowFromLists.mockReturnValue({ + effectiveAllowFrom: ["alice"], + effectiveGroupAllowFrom: ["team"], + }); + + expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice"); + expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob"); + expect(normalizeMattermostAllowEntry("*")).toBe("*"); + expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([ + "alice", + "*", + ]); + expect( + resolveMattermostEffectiveAllowFromLists({ + allowFrom: [" Alice "], + groupAllowFrom: [" Team "], + storeAllowFrom: ["Store"], + dmPolicy: "pairing", + }), + ).toEqual({ + effectiveAllowFrom: ["alice"], + effectiveGroupAllowFrom: ["team"], + }); + expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({ + allowFrom: ["alice"], + groupAllowFrom: ["team"], + storeAllowFrom: ["store"], + dmPolicy: "pairing", + }); + }); + + 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("authorizes direct messages in open mode and blocks disabled/group-restricted channels", async () => { + isDangerousNameMatchingEnabled.mockReturnValue(false); + resolveEffectiveAllowFromLists.mockReturnValue({ + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + }); + resolveControlCommandGate.mockReturnValue({ + commandAuthorized: false, + shouldBlock: false, + }); + evaluateSenderGroupAccessForPolicy.mockReturnValue({ + allowed: false, + reason: "empty_allowlist", + }); + resolveAllowlistMatchSimple.mockReturnValue({ allowed: false }); + + 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: false, + hasControlCommand: false, + }), + ).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "direct", + roomLabel: "#alice", + }); + + 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, + }), + ).toMatchObject({ + ok: false, + denyReason: "dm-disabled", + }); + + 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, + }), + ).toMatchObject({ + ok: false, + denyReason: "channel-no-allowlist", + kind: "channel", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts new file mode 100644 index 0000000..c4b5a37 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -0,0 +1,315 @@ +import type { ResolvedMattermostAccount } from "./accounts.js"; +import type { MattermostChannel } from "./client.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { + evaluateSenderGroupAccessForPolicy, + isDangerousNameMatchingEnabled, + resolveAllowlistMatchSimple, + resolveControlCommandGate, + resolveEffectiveAllowFromLists, +} from "./runtime-api.js"; + +export function normalizeMattermostAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + return trimmed + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +export function normalizeMattermostAllowList(entries: Array): string[] { + const normalized = entries + .map((entry) => normalizeMattermostAllowEntry(String(entry))) + .filter(Boolean); + return Array.from(new Set(normalized)); +} + +export function resolveMattermostEffectiveAllowFromLists(params: { + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + dmPolicy?: string | null; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + return resolveEffectiveAllowFromLists({ + allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []), + storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []), + dmPolicy: params.dmPolicy, + }); +} + +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; + }; + +export function authorizeMattermostCommandInvocation(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + senderId: string; + senderName: string; + channelId: string; + channelInfo: MattermostChannel | null; + storeAllowFrom?: Array | null; + allowTextCommands: boolean; + hasControlCommand: boolean; +}): MattermostCommandAuthDecision { + const { + account, + cfg, + senderId, + senderName, + channelId, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + } = params; + + if (!channelInfo) { + return { + ok: false, + denyReason: "unknown-channel", + commandAuthorized: false, + channelInfo: null, + 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 dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); + const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); + const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: normalizedStoreAllowFrom, + dmPolicy, + }); + + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; + const commandGroupAllowFrom = + kind === "direct" + ? effectiveGroupAllowFrom + : configGroupAllowFrom.length > 0 + ? configGroupAllowFrom + : configAllowFrom; + + const senderAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandDmAllowFrom, + allowNameMatching, + }); + const groupAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandGroupAllowFrom, + allowNameMatching, + }); + + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: commandGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand: allowTextCommands && hasControlCommand, + }); + + const commandAuthorized = + kind === "direct" + ? dmPolicy === "open" || senderAllowedForCommands + : commandGate.commandAuthorized; + + if (kind === "direct") { + if (dmPolicy === "disabled") { + return { + ok: false, + denyReason: "dm-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (dmPolicy !== "open" && !senderAllowedForCommands) { + return { + ok: false, + denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } else { + const senderGroupAccess = evaluateSenderGroupAccessForPolicy({ + groupPolicy, + groupAllowFrom: effectiveGroupAllowFrom, + senderId, + isSenderAllowed: (_senderId, allowFrom) => + isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + + if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") { + return { + ok: false, + denyReason: "channels-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") { + return { + ok: false, + denyReason: "channel-no-allowlist", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (commandGate.shouldBlock) { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } + + return { + ok: true, + commandAuthorized, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-gating.test.ts b/extensions/mattermost/src/mattermost/monitor-gating.test.ts new file mode 100644 index 0000000..e818467 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-gating.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} 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("channel"); + }); + + 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("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, + }), + ).toMatchObject({ + shouldRequireMention: false, + dropReason: null, + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-gating.ts b/extensions/mattermost/src/mattermost/monitor-gating.ts new file mode 100644 index 0000000..cb0bf7e --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-gating.ts @@ -0,0 +1,99 @@ +import type { ChatType, OpenClawConfig } from "./runtime-api.js"; + +export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType { + if (!channelType) { + return "channel"; + } + const normalized = channelType.trim().toUpperCase(); + if (normalized === "D") { + return "direct"; + } + if (normalized === "G" || normalized === "P") { + return "group"; + } + return "channel"; +} + +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; + 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; + if ( + params.oncharEnabled && + !params.oncharTriggered && + !params.wasMentioned && + !params.isControlCommand + ) { + 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, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.test.ts b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts new file mode 100644 index 0000000..191d0a6 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMention } 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"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts new file mode 100644 index 0000000..07dda5d --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -0,0 +1,110 @@ +import { + createDedupeCache, + formatInboundFromLabel as formatInboundFromLabelShared, + rawDataToString, + resolveThreadSessionKeys as resolveThreadSessionKeysShared, + type OpenClawConfig, +} from "./runtime-api.js"; + +export { createDedupeCache, rawDataToString }; + +export type ResponsePrefixContext = { + model?: string; + modelFull?: string; + provider?: string; + thinkingLevel?: string; + identityName?: string; +}; + +export function extractShortModelName(fullModel: string): string { + const slash = fullModel.lastIndexOf("/"); + const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel; + return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); +} + +export const formatInboundFromLabel = formatInboundFromLabelShared; + +function normalizeAgentId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return "main"; + } + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) { + return trimmed; + } + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || "main" + ); +} + +type AgentEntry = NonNullable["list"]>[number]; + +function isAgentEntry(entry: unknown): entry is AgentEntry { + return Boolean(entry && typeof entry === "object"); +} + +function listAgents(cfg: OpenClawConfig): AgentEntry[] { + return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(isAgentEntry) : []; +} + +function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined { + const id = normalizeAgentId(agentId); + return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); +} + +export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined { + const entry = resolveAgentEntry(cfg, agentId); + return entry?.identity?.name?.trim() || undefined; +} + +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"); +} diff --git a/extensions/mattermost/src/mattermost/monitor-onchar.test.ts b/extensions/mattermost/src/mattermost/monitor-onchar.test.ts new file mode 100644 index 0000000..2520cbd --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-onchar.test.ts @@ -0,0 +1,32 @@ +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", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-onchar.ts b/extensions/mattermost/src/mattermost/monitor-onchar.ts new file mode 100644 index 0000000..c23629f --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-onchar.ts @@ -0,0 +1,25 @@ +const DEFAULT_ONCHAR_PREFIXES = [">", "!"]; + +export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] { + const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? 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 }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-resources.test.ts b/extensions/mattermost/src/mattermost/monitor-resources.test.ts new file mode 100644 index 0000000..80a75e7 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-resources.test.ts @@ -0,0 +1,155 @@ +import { 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; + + beforeAll(async () => { + ({ createMattermostMonitorResources } = await import("./monitor-resources.js")); + }); + + beforeEach(() => { + fetchMattermostChannel.mockReset(); + fetchMattermostUser.mockReset(); + sendMattermostTyping.mockReset(); + updateMattermostPost.mockReset(); + buildButtonProps.mockReset(); + }); + + it("downloads media, preserves auth headers, and infers media kind", async () => { + const fetchRemoteMedia = vi.fn(async () => ({ + buffer: new Uint8Array([1, 2, 3]), + contentType: "image/png", + })); + const saveMediaBuffer = 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", + // pragma: allowlist secret + token: "bot-token", + } as never, + logger: {}, + mediaMaxBytes: 1024, + fetchRemoteMedia, + saveMediaBuffer, + mediaKindFromMime: () => "image", + }); + + await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([ + { + path: "/tmp/file.png", + contentType: "image/png", + kind: "image", + }, + ]); + + expect(fetchRemoteMedia).toHaveBeenCalledWith({ + url: "https://chat.example.com/api/v4/files/file-1", + requestInit: { + headers: { + // pragma: allowlist secret + 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, + fetchRemoteMedia: vi.fn(), + saveMediaBuffer: 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", + expect.objectContaining({ + message: "Pick a model", + props: { attachments: [] }, + }), + ); + }); + + 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, + fetchRemoteMedia: vi.fn(), + saveMediaBuffer: vi.fn(), + mediaKindFromMime: () => "document", + }); + + await resources.sendTypingIndicator("chan-1", "root-1"); + expect(sendMattermostTyping).toHaveBeenCalledWith(client, { + channelId: "chan-1", + parentId: "root-1", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-resources.ts b/extensions/mattermost/src/mattermost/monitor-resources.ts new file mode 100644 index 0000000..5b34a90 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-resources.ts @@ -0,0 +1,183 @@ +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; +}; + +const CHANNEL_CACHE_TTL_MS = 5 * 60_000; +const USER_CACHE_TTL_MS = 10 * 60_000; + +type FetchRemoteMedia = (params: { + url: string; + requestInit?: RequestInit; + filePathHint?: string; + maxBytes: number; + ssrfPolicy?: { allowedHostnames?: string[] }; +}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>; + +type SaveMediaBuffer = ( + buffer: Uint8Array, + contentType: string | undefined, + direction: "inbound" | "outbound", + maxBytes: number, +) => Promise<{ path: string; contentType?: string | null }>; + +export function createMattermostMonitorResources(params: { + accountId: string; + callbackUrl: string; + client: MattermostClient; + logger: { debug?: (...args: unknown[]) => void }; + mediaMaxBytes: number; + fetchRemoteMedia: FetchRemoteMedia; + saveMediaBuffer: SaveMediaBuffer; + mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined; +}) { + const { + accountId, + callbackUrl, + client, + logger, + mediaMaxBytes, + fetchRemoteMedia, + saveMediaBuffer, + mediaKindFromMime, + } = params; + const channelCache = new Map(); + const userCache = new Map(); + + const resolveMattermostMedia = async ( + fileIds?: string[] | null, + ): Promise => { + const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean); + if (ids.length === 0) { + return []; + } + const out: MattermostMediaInfo[] = []; + for (const fileId of ids) { + try { + const fetched = await fetchRemoteMedia({ + url: `${client.apiBaseUrl}/files/${fileId}`, + requestInit: { + headers: { + Authorization: `Bearer ${client.token}`, + }, + }, + filePathHint: fileId, + maxBytes: mediaMaxBytes, + ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] }, + }); + const saved = await saveMediaBuffer( + Buffer.from(fetched.buffer), + fetched.contentType ?? undefined, + "inbound", + mediaMaxBytes, + ); + const contentType = saved.contentType ?? fetched.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 => { + const cached = channelCache.get(channelId); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + try { + const info = await fetchMattermostChannel(client, channelId); + channelCache.set(channelId, { + value: info, + expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS, + }); + return info; + } catch (err) { + logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`); + channelCache.set(channelId, { + value: null, + expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS, + }); + return null; + } + }; + + const resolveUserInfo = async (userId: string): Promise => { + const cached = userCache.get(userId); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + try { + const info = await fetchMattermostUser(client, userId); + userCache.set(userId, { + value: info, + expiresAt: Date.now() + USER_CACHE_TTL_MS, + }); + return info; + } catch (err) { + logger.debug?.(`mattermost: user lookup failed: ${String(err)}`); + userCache.set(userId, { + value: null, + expiresAt: Date.now() + USER_CACHE_TTL_MS, + }); + return null; + } + }; + + const buildModelPickerProps = ( + channelId: string, + buttons: Array, + ): Record | undefined => + buildButtonProps({ + callbackUrl, + accountId, + channelId, + buttons, + }); + + const updateModelPickerPost = async (params: { + channelId: string; + postId: string; + message: string; + buttons?: Array; + }): Promise => { + const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? { + attachments: [], + }; + await updateMattermostPost(client, params.postId, { + message: params.message, + props, + }); + return {}; + }; + + return { + resolveMattermostMedia, + sendTypingIndicator, + resolveChannelInfo, + resolveUserInfo, + updateModelPickerPost, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-slash.test.ts b/extensions/mattermost/src/mattermost/monitor-slash.test.ts new file mode 100644 index 0000000..06e3fe0 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-slash.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const listSkillCommandsForAgents = vi.hoisted(() => vi.fn()); +const parseStrictPositiveInteger = 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, + parseStrictPositiveInteger, +})); + +vi.mock("./client.js", async () => { + const actual = await vi.importActual("./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, +})); + +describe("mattermost monitor slash", () => { + let registerMattermostMonitorSlashCommands: typeof import("./monitor-slash.js").registerMattermostMonitorSlashCommands; + + beforeAll(async () => { + ({ registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js")); + }); + + beforeEach(() => { + listSkillCommandsForAgents.mockReset(); + parseStrictPositiveInteger.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); + parseStrictPositiveInteger.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 runtime = { + log: vi.fn(), + error: vi.fn(), + }; + + await registerMattermostMonitorSlashCommands({ + client: {} as never, + 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); + expect(registerSlashCommands.mock.calls[0]?.[0]).toMatchObject({ + teamId: "team-1", + creatorUserId: "bot-user", + callbackUrl: "https://openclaw.test/slash", + }); + expect(registerSlashCommands.mock.calls[0]?.[0].commands).toEqual([ + { 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", + }, + ]); + expect(activateSlashCommands).toHaveBeenCalledWith( + expect.objectContaining({ + commandTokens: ["token-1", "token-2"], + triggerMap: 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("warns on loopback callback urls and reports partial team failures", async () => { + resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false }); + isSlashCommandsEnabled.mockReturnValue(true); + parseStrictPositiveInteger.mockReturnValue(undefined); + 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( + expect.stringContaining( + "slash commands callbackUrl resolved to http://127.0.0.1:18789/slash", + ), + ); + 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)", + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-slash.ts b/extensions/mattermost/src/mattermost/monitor-slash.ts new file mode 100644 index 0000000..51adf9b --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-slash.ts @@ -0,0 +1,211 @@ +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { + fetchMattermostUserTeams, + normalizeMattermostBaseUrl, + type MattermostClient, +} from "./client.js"; +import { + listSkillCommandsForAgents, + parseStrictPositiveInteger, + 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 isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +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(); + 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 { + const triggerMap = new Map(); + 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 + | undefined; + const slashConfig = resolveSlashCommandConfig(commandsRaw); + if (!isSlashCommandsEnabled(slashConfig)) { + return; + } + + try { + const teams = await fetchMattermostUserTeams(params.client, params.botUserId); + const envPort = parseStrictPositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim()); + 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)}`); + } +} diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts new file mode 100644 index 0000000..58640ca --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts @@ -0,0 +1,405 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../../runtime-api.js"; +import { + createMattermostConnectOnce, + type MattermostWebSocketLike, + WebSocketClosedBeforeOpenError, +} from "./monitor-websocket.js"; + +class FakeWebSocket implements MattermostWebSocketLike { + public readonly sent: string[] = []; + public closeCalls = 0; + public terminateCalls = 0; + private openListeners: Array<() => void> = []; + private messageListeners: Array<(data: Buffer) => void | Promise> = []; + 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; + on(event: "close", listener: (code: number, reason: Buffer) => void): void; + on(event: "error", listener: (err: unknown) => void): void; + on(event: "open" | "message" | "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); + return; + } + if (event === "close") { + this.closeListeners.push(listener as (code: number, reason: Buffer) => void); + return; + } + this.errorListeners.push(listener as (err: unknown) => void); + } + + 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); + } + } + + 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"); + }); + + const failure = connectOnce(); + await expect(failure).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError); + await expect(failure).rejects.toMatchObject({ + message: "websocket closed before open (code 1006)", + }); + }); + + it("retries when first attempt errors before open and next attempt succeeds", async () => { + const patches: Array> = []; + 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); + }, + 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])).toMatchObject({ + action: "authentication_challenge", + data: { token: "token" }, + seq: 1, + }); + expect(patches.some((patch) => patch.connected === true)).toBe(true); + expect(patches.filter((patch) => patch.connected === false)).toHaveLength(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 payload = onReaction.mock.calls[0]?.[0]; + expect(payload).toMatchObject({ + event: "reaction_added", + data: { + reaction: JSON.stringify({ + user_id: "user-1", + post_id: "post-1", + emoji_name: "thumbsup", + }), + }, + }); + expect(payload.data?.reaction).toBe( + JSON.stringify({ + user_id: "user-1", + post_id: "post-1", + emoji_name: "thumbsup", + }), + ); + }); + + 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("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 = [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((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(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts new file mode 100644 index 0000000..747dfe1 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -0,0 +1,329 @@ +import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared"; +import { z } from "openclaw/plugin-sdk/zod"; +import WebSocket from "ws"; +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; + 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; + on(event: "close", listener: (code: number, reason: Buffer) => void): void; + on(event: "error", listener: (err: unknown) => void): 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; + +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) => void; + runtime: RuntimeEnv; + nextSeq: () => number; + onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise; + onReaction?: (payload: MattermostEventPayload) => Promise; + 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; + healthCheckIntervalMs?: number; +}; + +export const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) => + new WebSocket(url) as MattermostWebSocketLike; + +export 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 parsePostedEvent( + data: WebSocket.RawData, +): { payload: MattermostEventPayload; post: MattermostPost } | null { + const raw = rawDataToString(data); + const payload = parseMattermostEventPayload(raw); + if (!payload) { + return null; + } + return parsePostedPayload(payload); +} + +export function createMattermostConnectOnce( + opts: CreateMattermostConnectOnceOpts, +): () => Promise { + const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory; + const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000; + return async () => { + 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((resolve, reject) => { + let opened = false; + let settled = false; + let healthCheckEnabled = getBotUpdateAt != null; + let healthCheckInFlight = false; + let healthCheckTimer: ReturnType | undefined; + let initialUpdateAt: number | undefined; + + const clearTimers = () => { + if (healthCheckTimer !== undefined) { + clearTimeout(healthCheckTimer); + healthCheckTimer = undefined; + } + }; + + const stopHealthChecks = () => { + healthCheckEnabled = false; + clearTimers(); + }; + + 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; + opts.statusSink?.({ + connected: true, + lastConnectedAt: Date.now(), + lastError: null, + }); + ws.send( + JSON.stringify({ + seq: opts.nextSeq(), + action: "authentication_challenge", + data: { token: opts.botToken }, + }), + ); + + // 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("message", async (data) => { + 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) => { + 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) => { + 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") : ""; +} diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts new file mode 100644 index 0000000..2569123 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { resolveControlCommandGate } from "../../runtime-api.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { + authorizeMattermostCommandInvocation, + resolveMattermostEffectiveAllowFromLists, +} from "./monitor-auth.js"; + +const accountFixture: ResolvedMattermostAccount = { + accountId: "default", + enabled: true, + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + 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", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + }); + + it("uses explicit groupAllowFrom without pairing-store inheritance", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not inherit pairing-store entries into group allowlist", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]); + }); + + it("does not auto-authorize DM commands in open mode without allowlists", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: [], + }); + + const commandGate = resolveControlCommandGate({ + useAccessGroups: true, + authorizers: [ + { configured: resolved.effectiveAllowFrom.length > 0, allowed: false }, + { configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false }, + ], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(commandGate.commandAuthorized).toBe(false); + }); + + it("denies group control commands when the sender is outside the allowlist", () => { + const decision = authorizeGroupCommand("attacker"); + + expect(decision).toMatchObject({ + ok: false, + denyReason: "unauthorized", + kind: "channel", + }); + }); + + it("authorizes group control commands for allowlisted senders", () => { + const decision = authorizeGroupCommand("trusted-user"); + + expect(decision).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "channel", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.channel-kind.test.ts b/extensions/mattermost/src/mattermost/monitor.channel-kind.test.ts new file mode 100644 index 0000000..0928ef3 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.channel-kind.test.ts @@ -0,0 +1,20 @@ +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 values as channel", () => { + expect(mapMattermostChannelTypeToChatType("O")).toBe("channel"); + expect(mapMattermostChannelTypeToChatType("x")).toBe("channel"); + expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts new file mode 100644 index 0000000..eafa122 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../runtime-api.js"; +import { resolveMattermostAccount } from "./accounts.js"; +import { + evaluateMattermostMentionGate, + resolveMattermostReactionChannelId, + resolveMattermostEffectiveReplyToId, + resolveMattermostReplyRootId, + resolveMattermostThreadSessionContext, + type MattermostMentionGateInput, + type MattermostRequireMentionResolverInput, +} from "./monitor.js"; + +function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean { + const root = params.cfg.channels?.mattermost; + const accountGroups = root?.accounts?.[params.accountId]?.groups; + const groups = accountGroups ?? root?.groups; + const groupConfig = params.groupId ? groups?.[params.groupId] : undefined; + const defaultGroupConfig = groups?.["*"]; + const configMention = + typeof groupConfig?.requireMention === "boolean" + ? groupConfig.requireMention + : typeof defaultGroupConfig?.requireMention === "boolean" + ? defaultGroupConfig.requireMention + : undefined; + if (typeof configMention === "boolean") { + return configMention; + } + if (typeof params.requireMentionOverride === "boolean") { + return params.requireMentionOverride; + } + return true; +} + +function evaluateMentionGateForMessage(params: { cfg: OpenClawConfig; threadRootId?: string }) { + const account = resolveMattermostAccount({ cfg: params.cfg, accountId: "default" }); + const resolver = vi.fn(resolveRequireMentionForTest); + const input: MattermostMentionGateInput = { + kind: "channel", + cfg: params.cfg, + accountId: account.accountId, + channelId: "chan-1", + threadRootId: params.threadRootId, + requireMentionOverride: account.requireMention, + resolveRequireMention: resolver, + wasMentioned: false, + isControlCommand: false, + commandAuthorized: false, + oncharEnabled: false, + oncharTriggered: false, + canDetectMention: true, + }; + const decision = evaluateMattermostMentionGate(input); + return { account, resolver, decision }; +} + +describe("mattermost mention gating", () => { + it("accepts unmentioned root channel posts in onmessage mode", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + chatmode: "onmessage", + groupPolicy: "open", + }, + }, + }; + const { resolver, decision } = evaluateMentionGateForMessage({ cfg }); + expect(decision.dropReason).toBeNull(); + expect(decision.shouldRequireMention).toBe(false); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + groupId: "chan-1", + requireMentionOverride: false, + }), + ); + }); + + it("accepts unmentioned thread replies in onmessage mode", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + chatmode: "onmessage", + groupPolicy: "open", + }, + }, + }; + const { resolver, decision } = evaluateMentionGateForMessage({ + cfg, + threadRootId: "thread-root-1", + }); + expect(decision.dropReason).toBeNull(); + expect(decision.shouldRequireMention).toBe(false); + const resolverCall = resolver.mock.calls.at(-1)?.[0]; + expect(resolverCall?.groupId).toBe("chan-1"); + expect(resolverCall?.groupId).not.toBe("thread-root-1"); + }); + + it("rejects unmentioned channel posts in oncall mode", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + chatmode: "oncall", + groupPolicy: "open", + }, + }, + }; + const { decision, account } = evaluateMentionGateForMessage({ cfg }); + expect(account.requireMention).toBe(true); + expect(decision.shouldRequireMention).toBe(true); + expect(decision.dropReason).toBe("missing-mention"); + }); +}); + +describe("resolveMattermostReplyRootId with block streaming payloads", () => { + it("uses threadRootId for block-streamed payloads with replyToId", () => { + // When block streaming sends a payload with replyToId from the threading + // mode, the deliver callback should still use the existing threadRootId. + expect( + resolveMattermostReplyRootId({ + threadRootId: "thread-root-1", + replyToId: "streamed-reply-id", + }), + ).toBe("thread-root-1"); + }); + + it("falls back to payload replyToId when no threadRootId in block streaming", () => { + // Top-level channel message: no threadRootId, payload carries the + // inbound post id as replyToId from the "all" threading mode. + expect( + resolveMattermostReplyRootId({ + replyToId: "inbound-post-for-threading", + }), + ).toBe("inbound-post-for-threading"); + }); +}); + +describe("resolveMattermostReplyRootId", () => { + it("uses replyToId for top-level replies", () => { + expect( + resolveMattermostReplyRootId({ + replyToId: "inbound-post-123", + }), + ).toBe("inbound-post-123"); + }); + + it("keeps the thread root when replying inside an existing thread", () => { + expect( + resolveMattermostReplyRootId({ + threadRootId: "thread-root-456", + replyToId: "child-post-789", + }), + ).toBe("thread-root-456"); + }); + + it("falls back to undefined when neither reply target is available", () => { + expect(resolveMattermostReplyRootId({})).toBeUndefined(); + }); +}); + +describe("resolveMattermostEffectiveReplyToId", () => { + it("keeps an existing thread root", () => { + expect( + resolveMattermostEffectiveReplyToId({ + kind: "channel", + postId: "post-123", + replyToMode: "all", + threadRootId: "thread-root-456", + }), + ).toBe("thread-root-456"); + }); + + it("suppresses existing thread roots when replyToMode is off", () => { + expect( + resolveMattermostEffectiveReplyToId({ + kind: "channel", + postId: "post-123", + replyToMode: "off", + threadRootId: "thread-root-456", + }), + ).toBeUndefined(); + }); + + it("starts a thread for top-level channel messages when replyToMode is all", () => { + expect( + resolveMattermostEffectiveReplyToId({ + kind: "channel", + postId: "post-123", + replyToMode: "all", + }), + ).toBe("post-123"); + }); + + it("starts a thread for top-level group messages when replyToMode is first", () => { + expect( + resolveMattermostEffectiveReplyToId({ + kind: "group", + postId: "post-123", + replyToMode: "first", + }), + ).toBe("post-123"); + }); + + it("keeps direct messages non-threaded", () => { + expect( + resolveMattermostEffectiveReplyToId({ + kind: "direct", + postId: "post-123", + replyToMode: "all", + }), + ).toBeUndefined(); + }); +}); + +describe("resolveMattermostThreadSessionContext", () => { + it("forks channel sessions by top-level post when replyToMode is all", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:chan-1", + kind: "channel", + postId: "post-123", + replyToMode: "all", + }), + ).toEqual({ + effectiveReplyToId: "post-123", + sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123", + parentSessionKey: "agent:main:mattermost:default:chan-1", + }); + }); + + it("keeps existing thread roots for threaded follow-ups", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:chan-1", + kind: "group", + postId: "post-123", + replyToMode: "first", + threadRootId: "root-456", + }), + ).toEqual({ + effectiveReplyToId: "root-456", + sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456", + parentSessionKey: "agent:main:mattermost:default:chan-1", + }); + }); + + it("keeps threaded messages top-level when replyToMode is off", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:chan-1", + kind: "group", + postId: "post-123", + replyToMode: "off", + threadRootId: "root-456", + }), + ).toEqual({ + effectiveReplyToId: undefined, + sessionKey: "agent:main:mattermost:default:chan-1", + parentSessionKey: undefined, + }); + }); + + it("keeps direct-message sessions linear", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:user-1", + kind: "direct", + postId: "post-123", + replyToMode: "all", + }), + ).toEqual({ + effectiveReplyToId: undefined, + sessionKey: "agent:main:mattermost:default:user-1", + parentSessionKey: undefined, + }); + }); +}); + +describe("resolveMattermostReactionChannelId", () => { + it("prefers broadcast channel_id when present", () => { + expect( + resolveMattermostReactionChannelId({ + broadcast: { channel_id: "chan-broadcast" }, + data: { channel_id: "chan-data" }, + }), + ).toBe("chan-broadcast"); + }); + + it("falls back to data.channel_id when broadcast channel_id is missing", () => { + expect( + resolveMattermostReactionChannelId({ + data: { channel_id: "chan-data" }, + }), + ).toBe("chan-data"); + }); + + it("returns undefined when neither payload location includes channel_id", () => { + expect(resolveMattermostReactionChannelId({})).toBeUndefined(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts new file mode 100644 index 0000000..09155d2 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -0,0 +1,1743 @@ +import { getMattermostRuntime } from "../runtime.js"; +import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; +import { + createMattermostClient, + fetchMattermostChannel, + fetchMattermostMe, + fetchMattermostUser, + normalizeMattermostBaseUrl, + sendMattermostTyping, + updateMattermostPost, + type MattermostChannel, + type MattermostPost, + type MattermostUser, +} from "./client.js"; +import { + computeInteractionCallbackUrl, + createMattermostInteractionHandler, + resolveInteractionCallbackPath, + setInteractionCallbackUrl, + setInteractionSecret, + type MattermostInteractionResponse, +} from "./interactions.js"; +import { + buildMattermostAllowedModelRefs, + parseMattermostModelPickerContext, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, + isMattermostSenderAllowed, + normalizeMattermostAllowList, +} from "./monitor-auth.js"; +import { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} from "./monitor-gating.js"; +import { + createDedupeCache, + formatInboundFromLabel, + normalizeMention, + resolveThreadSessionKeys, +} from "./monitor-helpers.js"; +import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js"; +import { createMattermostMonitorResources, type MattermostMediaInfo } from "./monitor-resources.js"; +import { registerMattermostMonitorSlashCommands } from "./monitor-slash.js"; +import { + createMattermostConnectOnce, + type MattermostEventPayload, + type MattermostWebSocketFactory, +} from "./monitor-websocket.js"; +import { runWithReconnect } from "./reconnect.js"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; +import type { + ChannelAccountSnapshot, + ChatType, + OpenClawConfig, + ReplyPayload, + RuntimeEnv, +} from "./runtime-api.js"; +import { + buildAgentMediaPayload, + buildModelsProviderData, + DM_GROUP_ACCESS_REASON, + createChannelPairingController, + createChannelReplyPipeline, + logInboundDrop, + logTypingFailure, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + recordPendingHistoryEntryIfEnabled, + isDangerousNameMatchingEnabled, + registerPluginHttpRoute, + resolveControlCommandGate, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveChannelMediaMaxBytes, + warnMissingProviderGroupPolicyFallbackOnce, + type HistoryEntry, +} from "./runtime-api.js"; +import { sendMessageMattermost } from "./send.js"; +import { cleanupSlashCommands } from "./slash-commands.js"; +import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js"; + +export { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} from "./monitor-gating.js"; +export type { + MattermostMentionGateInput, + MattermostRequireMentionResolverInput, +} from "./monitor-gating.js"; + +export type MonitorMattermostOpts = { + botToken?: string; + baseUrl?: string; + accountId?: string; + config?: OpenClawConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + statusSink?: (patch: Partial) => void; + webSocketFactory?: MattermostWebSocketFactory; +}; + +type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +type MattermostReaction = { + user_id?: string; + post_id?: string; + emoji_name?: string; + create_at?: number; +}; +const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; +const RECENT_MATTERMOST_MESSAGE_MAX = 2000; + +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function normalizeInteractionSourceIps(values?: string[]): string[] { + return (values ?? []).map((value) => value.trim()).filter(Boolean); +} + +const recentInboundMessages = createDedupeCache({ + ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, + maxSize: RECENT_MATTERMOST_MESSAGE_MAX, +}); + +const noopLogger = { + debug: (): void => {}, + info: (): void => {}, + warn: (): void => {}, + error: (): void => {}, +}; + +function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv { + return ( + opts.runtime ?? { + log: noopLogger.info, + error: noopLogger.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + } + ); +} + +function isSystemPost(post: MattermostPost): boolean { + const type = post.type?.trim(); + return Boolean(type); +} + +function channelChatType(kind: ChatType): "direct" | "group" | "channel" { + if (kind === "direct") { + return "direct"; + } + if (kind === "group") { + return "group"; + } + return "channel"; +} + +export function resolveMattermostReplyRootId(params: { + threadRootId?: string; + replyToId?: string; +}): string | undefined { + const threadRootId = params.threadRootId?.trim(); + if (threadRootId) { + return threadRootId; + } + return params.replyToId?.trim() || undefined; +} + +export function resolveMattermostEffectiveReplyToId(params: { + kind: ChatType; + postId?: string | null; + replyToMode: "off" | "first" | "all"; + threadRootId?: string | null; +}): string | undefined { + const threadRootId = params.threadRootId?.trim(); + if (threadRootId && params.replyToMode !== "off") { + return threadRootId; + } + if (params.kind === "direct") { + return undefined; + } + const postId = params.postId?.trim(); + if (!postId) { + return undefined; + } + return params.replyToMode === "all" || params.replyToMode === "first" ? postId : undefined; +} + +export function resolveMattermostThreadSessionContext(params: { + baseSessionKey: string; + kind: ChatType; + postId?: string | null; + replyToMode: "off" | "first" | "all"; + threadRootId?: string | null; +}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } { + const effectiveReplyToId = resolveMattermostEffectiveReplyToId({ + kind: params.kind, + postId: params.postId, + replyToMode: params.replyToMode, + threadRootId: params.threadRootId, + }); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey: params.baseSessionKey, + threadId: effectiveReplyToId, + parentSessionKey: effectiveReplyToId ? params.baseSessionKey : undefined, + }); + return { + effectiveReplyToId, + sessionKey: threadKeys.sessionKey, + parentSessionKey: threadKeys.parentSessionKey, + }; +} + +export function resolveMattermostReactionChannelId( + payload: Pick, +): string | undefined { + return payload.broadcast?.channel_id?.trim() || payload.data?.channel_id?.trim() || undefined; +} + +function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string { + if (mediaList.length === 0) { + return ""; + } + if (mediaList.length === 1) { + const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind; + return ``; + } + const allImages = mediaList.every((media) => media.kind === "image"); + const label = allImages ? "image" : "file"; + const suffix = mediaList.length === 1 ? label : `${label}s`; + const tag = allImages ? "" : ""; + return `${tag} (${mediaList.length} ${suffix})`; +} + +function buildMattermostWsUrl(baseUrl: string): string { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + throw new Error("Mattermost baseUrl is required"); + } + const wsBase = normalized.replace(/^http/i, "ws"); + return `${wsBase}/api/v4/websocket`; +} + +export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise { + const core = getMattermostRuntime(); + const runtime = resolveRuntime(opts); + const cfg = opts.config ?? core.config.loadConfig(); + const account = resolveMattermostAccount({ + cfg, + accountId: opts.accountId, + }); + const pairing = createChannelPairingController({ + core, + channel: "mattermost", + accountId: account.accountId, + }); + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); + const botToken = opts.botToken?.trim() || account.botToken?.trim(); + if (!botToken) { + 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 client = createMattermostClient({ + baseUrl, + botToken, + allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + }); + + // Wait for the Mattermost API to accept our bot token before proceeding. + // When a bot account is disabled and re-enabled, the session is invalidated + // and API calls return 401 until the account is fully active again. Retrying + // here (with exponential backoff) keeps the monitor alive and prevents the + // framework's auto-restart budget from being exhausted. + let botUser!: MattermostUser; + await runWithReconnect( + async () => { + botUser = await fetchMattermostMe(client); + }, + { + abortSignal: opts.abortSignal, + jitterRatio: 0.2, + shouldReconnect: ({ outcome }) => outcome === "rejected", + onError: (err) => { + runtime.error?.(`mattermost: API auth failed: ${String(err)}`); + opts.statusSink?.({ lastError: String(err), connected: false }); + }, + onReconnect: (delayMs) => { + runtime.log?.(`mattermost: API not accessible, retrying in ${Math.round(delayMs / 1000)}s`); + }, + }, + ); + if (opts.abortSignal?.aborted) { + return; + } + const botUserId = botUser.id; + const botUsername = botUser.username?.trim() || undefined; + runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`); + await registerMattermostMonitorSlashCommands({ + client, + cfg, + runtime, + account, + baseUrl, + botUserId, + }); + const slashEnabled = getSlashCommandState(account.accountId) != null; + + // ─── Interactive buttons registration ────────────────────────────────────── + // Derive a stable HMAC secret from the bot token so CLI and gateway share it. + setInteractionSecret(account.accountId, botToken); + + // Register HTTP callback endpoint for interactive button clicks. + // Mattermost POSTs to this URL when a user clicks a button action. + const interactionPath = resolveInteractionCallbackPath(account.accountId); + // Recompute from config on each monitor start so reconnects or config reloads can refresh the + // cached callback URL for downstream callers such as `message action=send`. + const callbackUrl = computeInteractionCallbackUrl(account.accountId, { + gateway: cfg.gateway, + interactions: account.config.interactions, + }); + setInteractionCallbackUrl(account.accountId, callbackUrl); + const allowedInteractionSourceIps = normalizeInteractionSourceIps( + account.config.interactions?.allowedSourceIps, + ); + + try { + const mmHost = new URL(baseUrl).hostname; + const callbackHost = new URL(callbackUrl).hostname; + if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) { + runtime.error?.( + `mattermost: interactions callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If button clicks don't work, set channels.mattermost.interactions.callbackBaseUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`, + ); + } + if (!isLoopbackHost(callbackHost) && allowedInteractionSourceIps.length === 0) { + runtime.error?.( + `mattermost: interactions callbackUrl resolved to ${callbackUrl} without channels.mattermost.interactions.allowedSourceIps. For safety, non-loopback callback sources will be rejected until you allowlist the Mattermost server or trusted ingress IPs.`, + ); + } + } catch { + // URL parse failed; ignore and continue (we will fail naturally if callbacks cannot be delivered). + } + + const effectiveInteractionSourceIps = + allowedInteractionSourceIps.length > 0 ? allowedInteractionSourceIps : ["127.0.0.1", "::1"]; + + const unregisterInteractions = registerPluginHttpRoute({ + path: interactionPath, + fallbackPath: "/mattermost/interactions/default", + auth: "plugin", + handler: createMattermostInteractionHandler({ + client, + botUserId, + accountId: account.accountId, + allowedSourceIps: effectiveInteractionSourceIps, + trustedProxies: cfg.gateway?.trustedProxies, + allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true, + handleInteraction: handleModelPickerInteraction, + authorizeButtonClick: async ({ payload, post }) => { + const channelInfo = await resolveChannelInfo(payload.channel_id); + const isDirect = channelInfo?.type?.trim().toUpperCase() === "D"; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const decision = authorizeMattermostCommandInvocation({ + account, + cfg, + senderId: payload.user_id, + senderName: payload.user_name ?? "", + channelId: payload.channel_id, + channelInfo, + storeAllowFrom: isDirect + ? await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy: account.config.dmPolicy ?? "pairing", + readStore: pairing.readStoreForDmPolicy, + }) + : undefined, + allowTextCommands, + hasControlCommand: false, + }); + if (decision.ok) { + return { ok: true }; + } + return { + ok: false, + response: { + update: { + message: post.message ?? "", + props: post.props ?? undefined, + }, + ephemeral_text: `OpenClaw ignored this action for ${decision.roomLabel}.`, + }, + }; + }, + resolveSessionKey: async ({ channelId, userId, post }) => { + const channelInfo = await resolveChannelInfo(channelId); + const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); + const teamId = channelInfo?.team_id ?? undefined; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? userId : channelId, + }, + }); + const replyToMode = resolveMattermostReplyToMode(account, kind); + return resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: post.id || undefined, + replyToMode, + threadRootId: post.root_id, + }).sessionKey; + }, + dispatchButtonClick: async (opts) => { + const channelInfo = await resolveChannelInfo(opts.channelId); + const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); + const chatType = channelChatType(kind); + const teamId = channelInfo?.team_id ?? undefined; + const channelName = channelInfo?.name ?? undefined; + const channelDisplay = channelInfo?.display_name ?? channelName ?? opts.channelId; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? opts.userId : opts.channelId, + }, + }); + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: opts.post.id || opts.postId, + replyToMode, + threadRootId: opts.post.root_id, + }); + const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`; + const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: bodyText, + BodyForAgent: bodyText, + RawBody: bodyText, + CommandBody: bodyText, + From: + kind === "direct" + ? `mattermost:${opts.userId}` + : kind === "group" + ? `mattermost:group:${opts.channelId}` + : `mattermost:channel:${opts.channelId}`, + To: to, + SessionKey: threadContext.sessionKey, + ParentSessionKey: threadContext.parentSessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: `mattermost:${opts.userName}`, + GroupSubject: kind !== "direct" ? channelDisplay : undefined, + GroupChannel: channelName ? `#${channelName}` : undefined, + GroupSpace: teamId, + SenderName: opts.userName, + SenderId: opts.userId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: `interaction:${opts.postId}:${opts.actionId}`, + ReplyToId: threadContext.effectiveReplyToId, + MessageThreadId: threadContext.effectiveReplyToId, + WasMentioned: true, + CommandAuthorized: false, + 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 } = createChannelReplyPipeline({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: { + start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: opts.channelId, + error: err, + }); + }, + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...replyPipeline, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: threadContext.effectiveReplyToId, + replyToId: payload.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); + runtime.log?.(`delivered button-click reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, + }); + markDispatchIdle(); + }, + log: (msg) => runtime.log?.(msg), + }), + pluginId: "mattermost", + source: "mattermost-interactions", + accountId: account.accountId, + log: (msg: string) => runtime.log?.(msg), + }); + + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const logVerboseMessage = (message: string) => { + if (!core.logging.shouldLogVerbose()) { + return; + } + logger.debug?.(message); + }; + const mediaMaxBytes = + resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: () => undefined, + accountId: account.accountId, + }) ?? 8 * 1024 * 1024; + const historyLimit = Math.max( + 0, + cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + ); + const channelHistories = new Map(); + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "mattermost", + accountId: account.accountId, + log: (message) => logVerboseMessage(message), + }); + + const { + resolveMattermostMedia, + sendTypingIndicator, + resolveChannelInfo, + resolveUserInfo, + updateModelPickerPost, + } = createMattermostMonitorResources({ + accountId: account.accountId, + callbackUrl, + client, + logger: { + debug: (message) => logger.debug?.(String(message)), + }, + mediaMaxBytes, + fetchRemoteMedia: (params) => core.channel.media.fetchRemoteMedia(params), + saveMediaBuffer: (buffer, contentType, direction, maxBytes) => + core.channel.media.saveMediaBuffer(Buffer.from(buffer), contentType, direction, maxBytes), + mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind, + }); + + const runModelPickerCommand = async (params: { + commandText: string; + commandAuthorized: boolean; + route: ReturnType; + sessionKey: string; + parentSessionKey?: string; + channelId: string; + senderId: string; + senderName: string; + kind: ChatType; + chatType: "direct" | "group" | "channel"; + channelName?: string; + channelDisplay?: string; + roomLabel: string; + teamId?: string; + postId: string; + effectiveReplyToId?: string; + deliverReplies?: boolean; + }): Promise => { + const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`; + const fromLabel = + params.kind === "direct" + ? `Mattermost DM from ${params.senderName}` + : `Mattermost message in ${params.roomLabel} from ${params.senderName}`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: params.commandText, + BodyForAgent: params.commandText, + RawBody: params.commandText, + CommandBody: params.commandText, + From: + params.kind === "direct" + ? `mattermost:${params.senderId}` + : params.kind === "group" + ? `mattermost:group:${params.channelId}` + : `mattermost:channel:${params.channelId}`, + To: to, + SessionKey: params.sessionKey, + ParentSessionKey: params.parentSessionKey, + AccountId: params.route.accountId, + ChatType: params.chatType, + ConversationLabel: fromLabel, + GroupSubject: + params.kind !== "direct" ? params.channelDisplay || params.roomLabel : undefined, + GroupChannel: params.channelName ? `#${params.channelName}` : undefined, + GroupSpace: params.teamId, + SenderName: params.senderName, + SenderId: params.senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: `interaction:${params.postId}:${Date.now()}`, + ReplyToId: params.effectiveReplyToId, + MessageThreadId: params.effectiveReplyToId, + Timestamp: Date.now(), + WasMentioned: true, + CommandAuthorized: params.commandAuthorized, + CommandSource: "native" as const, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + const textLimit = core.channel.text.resolveTextChunkLimit( + cfg, + "mattermost", + account.accountId, + { + fallbackLimit: account.textChunkLimit ?? 4000, + }, + ); + const shouldDeliverReplies = params.deliverReplies === true; + const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ + cfg, + agentId: params.route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: shouldDeliverReplies + ? { + start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: params.channelId, + error: err, + }); + }, + } + : undefined, + }); + const capturedTexts: string[] = []; + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...replyPipeline, + // Picker-triggered confirmations should stay immediate. + deliver: async (payload: ReplyPayload) => { + const trimmedPayload = { + ...payload, + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(), + }; + + if (!shouldDeliverReplies) { + if (trimmedPayload.text) { + capturedTexts.push(trimmedPayload.text); + } + return; + } + + await deliverMattermostReplyPayload({ + core, + cfg, + payload: trimmedPayload, + to, + accountId: account.accountId, + agentId: params.route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: params.effectiveReplyToId, + replyToId: trimmedPayload.replyToId, + }), + textLimit, + // The picker path already converts and trims text before capture/delivery. + tableMode: "off", + sendMessage: sendMessageMattermost, + }); + }, + onError: (err, info) => { + runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(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, + }, + }), + }); + + return capturedTexts.join("\n\n").trim(); + }; + + async function handleModelPickerInteraction(params: { + payload: { + channel_id: string; + post_id: string; + team_id?: string; + user_id: string; + }; + userName: string; + context: Record; + post: MattermostPost; + }): Promise { + const pickerState = parseMattermostModelPickerContext(params.context); + if (!pickerState) { + return null; + } + + if (pickerState.ownerUserId !== params.payload.user_id) { + return { + ephemeral_text: "Only the person who opened this picker can use it.", + }; + } + + const channelInfo = await resolveChannelInfo(params.payload.channel_id); + const pickerCommandText = + pickerState.action === "select" + ? `/model ${pickerState.provider}/${pickerState.model}` + : pickerState.action === "list" + ? `/models ${pickerState.provider}` + : "/models"; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg); + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const auth = authorizeMattermostCommandInvocation({ + account, + cfg, + senderId: params.payload.user_id, + senderName: params.userName, + channelId: params.payload.channel_id, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + }); + if (!auth.ok) { + if (auth.denyReason === "dm-pairing") { + const { code } = await pairing.upsertPairingRequest({ + id: params.payload.user_id, + meta: { name: params.userName }, + }); + return { + ephemeral_text: core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${params.payload.user_id}`, + code, + }), + }; + } + const denyText = + auth.denyReason === "unknown-channel" + ? "Temporary error: unable to determine channel type. Please try again." + : auth.denyReason === "dm-disabled" + ? "This bot is not accepting direct messages." + : auth.denyReason === "channels-disabled" + ? "Model picker actions are disabled in channels." + : auth.denyReason === "channel-no-allowlist" + ? "Model picker actions are not configured for this channel." + : "Unauthorized."; + return { + ephemeral_text: denyText, + }; + } + const kind = auth.kind; + const chatType = auth.chatType; + const teamId = auth.channelInfo.team_id ?? params.payload.team_id ?? undefined; + const channelName = auth.channelName || undefined; + const channelDisplay = auth.channelDisplay || auth.channelName || params.payload.channel_id; + const roomLabel = auth.roomLabel; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? params.payload.user_id : params.payload.channel_id, + }, + }); + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: params.post.id || params.payload.post_id, + replyToMode, + threadRootId: params.post.root_id, + }); + const modelSessionRoute = { + agentId: route.agentId, + sessionKey: threadContext.sessionKey, + }; + + const data = await buildModelsProviderData(cfg, route.agentId); + if (data.providers.length === 0) { + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: "No models available.", + }); + } + + if (pickerState.action === "providers" || pickerState.action === "back") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route: modelSessionRoute, + data, + }); + const view = renderMattermostProviderPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + if (pickerState.action === "list") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route: modelSessionRoute, + data, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + const targetModelRef = `${pickerState.provider}/${pickerState.model}`; + if (!buildMattermostAllowedModelRefs(data).has(targetModelRef)) { + return { + ephemeral_text: `That model is no longer available: ${targetModelRef}`, + }; + } + + void (async () => { + try { + await runModelPickerCommand({ + commandText: `/model ${targetModelRef}`, + commandAuthorized: auth.commandAuthorized, + route, + sessionKey: threadContext.sessionKey, + parentSessionKey: threadContext.parentSessionKey, + channelId: params.payload.channel_id, + senderId: params.payload.user_id, + senderName: params.userName, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + teamId, + postId: params.payload.post_id, + effectiveReplyToId: threadContext.effectiveReplyToId, + deliverReplies: true, + }); + const updatedModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route: modelSessionRoute, + data, + skipCache: true, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel: updatedModel, + }); + + await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } catch (err) { + runtime.error?.(`mattermost model picker select failed: ${String(err)}`); + } + })(); + + return {}; + } + + const handlePost = async ( + post: MattermostPost, + payload: MattermostEventPayload, + messageIds?: string[], + ) => { + const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id; + if (!channelId) { + logVerboseMessage("mattermost: drop post (missing channel id)"); + return; + } + + const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : []; + if (allMessageIds.length === 0) { + logVerboseMessage("mattermost: drop post (missing message id)"); + return; + } + const dedupeEntries = allMessageIds.map((id) => + recentInboundMessages.check(`${account.accountId}:${id}`), + ); + if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) { + logVerboseMessage( + `mattermost: drop post (dedupe account=${account.accountId} ids=${allMessageIds.length})`, + ); + return; + } + + const senderId = post.user_id ?? payload.broadcast?.user_id; + if (!senderId) { + logVerboseMessage("mattermost: drop post (missing sender id)"); + return; + } + if (senderId === botUserId) { + logVerboseMessage(`mattermost: drop post (self sender=${senderId})`); + return; + } + if (isSystemPost(post)) { + logVerboseMessage(`mattermost: drop post (system post type=${post.type ?? "unknown"})`); + return; + } + + const channelInfo = await resolveChannelInfo(channelId); + const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined; + const kind = mapMattermostChannelTypeToChatType(channelType); + const chatType = channelChatType(kind); + + const senderName = + payload.data?.sender_name?.trim() || + (await resolveUserInfo(senderId))?.username?.trim() || + senderId; + const rawText = post.message?.trim() || ""; + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const normalizedGroupAllowFrom = normalizeMattermostAllowList( + account.config.groupAllowFrom ?? [], + ); + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizedAllowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); + const isControlCommand = allowTextCommands && hasControlCommand; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom; + const senderAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandDmAllowFrom, + allowNameMatching, + }); + const groupAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: effectiveGroupAllowFrom, + allowNameMatching, + }); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: effectiveGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); + const commandAuthorized = commandGate.commandAuthorized; + + if (accessDecision.decision !== "allow") { + if (kind === "direct") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (accessDecision.decision === "pairing") { + const { code, created } = await pairing.upsertPairingRequest({ + id: senderId, + meta: { name: senderName }, + }); + logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`); + if (created) { + try { + await sendMessageMattermost( + `user:${senderId}`, + core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }), + { cfg, accountId: account.accountId }, + ); + opts.statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); + } + } + return; + } + logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); + return; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { + logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); + return; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); + return; + } + logVerboseMessage( + `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`, + ); + return; + } + + if (kind !== "direct" && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "mattermost", + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + + const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined; + const channelName = payload.data?.channel_name ?? channelInfo?.name ?? ""; + const channelDisplay = + payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName; + const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? senderId : channelId, + }, + }); + + const baseSessionKey = route.sessionKey; + const threadRootId = post.root_id?.trim() || undefined; + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ + baseSessionKey, + kind, + postId: post.id, + replyToMode, + threadRootId, + }); + const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext; + const historyKey = kind === "direct" ? null : sessionKey; + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); + const wasMentioned = + kind !== "direct" && + ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) || + core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes)); + const pendingBody = + rawText || + (post.file_ids?.length + ? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]` + : ""); + const pendingSender = senderName; + const recordPendingHistory = () => { + const trimmed = pendingBody.trim(); + recordPendingHistoryEntryIfEnabled({ + historyMap: channelHistories, + limit: historyLimit, + historyKey: historyKey ?? "", + entry: + historyKey && trimmed + ? { + sender: pendingSender, + body: trimmed, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + messageId: post.id ?? undefined, + } + : null, + }); + }; + + const oncharEnabled = account.chatmode === "onchar" && kind !== "direct"; + const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : []; + const oncharResult = oncharEnabled + ? stripOncharPrefix(rawText, oncharPrefixes) + : { triggered: false, stripped: rawText }; + const oncharTriggered = oncharResult.triggered; + const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + const mentionDecision = evaluateMattermostMentionGate({ + kind, + cfg, + accountId: account.accountId, + channelId, + threadRootId, + requireMentionOverride: account.requireMention, + resolveRequireMention: core.channel.groups.resolveRequireMention, + wasMentioned, + isControlCommand, + commandAuthorized, + oncharEnabled, + oncharTriggered, + canDetectMention, + }); + const { shouldRequireMention, shouldBypassMention } = mentionDecision; + + if (mentionDecision.dropReason === "onchar-not-triggered") { + logVerboseMessage( + `mattermost: drop group message (onchar not triggered channel=${channelId} sender=${senderId})`, + ); + recordPendingHistory(); + return; + } + + if (mentionDecision.dropReason === "missing-mention") { + logVerboseMessage( + `mattermost: drop group message (missing mention channel=${channelId} sender=${senderId} requireMention=${shouldRequireMention} bypass=${shouldBypassMention} canDetectMention=${canDetectMention})`, + ); + recordPendingHistory(); + return; + } + const mediaList = await resolveMattermostMedia(post.file_ids); + const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList); + const bodySource = oncharTriggered ? oncharResult.stripped : rawText; + const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim(); + const bodyText = normalizeMention(baseText, botUsername); + if (!bodyText) { + logVerboseMessage( + `mattermost: drop group message (empty body after normalization channel=${channelId} sender=${senderId})`, + ); + return; + } + + core.channel.activity.record({ + channel: "mattermost", + accountId: account.accountId, + direction: "inbound", + }); + + const fromLabel = formatInboundFromLabel({ + isGroup: kind !== "direct", + groupLabel: channelDisplay || roomLabel, + groupId: channelId, + groupFallback: roomLabel || "Channel", + directLabel: senderName, + directId: senderId, + }); + + const preview = bodyText.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = + kind === "direct" + ? `Mattermost DM from ${senderName}` + : `Mattermost message in ${roomLabel} from ${senderName}`; + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey, + contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`, + }); + + const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`; + const body = core.channel.reply.formatInboundEnvelope({ + channel: "Mattermost", + from: fromLabel, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + body: textWithId, + chatType, + sender: { name: senderName, id: senderId }, + }); + let combinedBody = body; + if (historyKey) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: channelHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + core.channel.reply.formatInboundEnvelope({ + channel: "Mattermost", + from: fromLabel, + timestamp: entry.timestamp, + body: `${entry.body}${ + entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : "" + }`, + chatType, + senderLabel: entry.sender, + }), + }); + } + + const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`; + const mediaPayload = buildAgentMediaPayload(mediaList); + const commandBody = rawText.trim(); + const inboundHistory = + historyKey && historyLimit > 0 + ? (channelHistories.get(historyKey) ?? []).map((entry) => ({ + sender: entry.sender, + body: entry.body, + timestamp: entry.timestamp, + })) + : undefined; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: combinedBody, + BodyForAgent: bodyText, + InboundHistory: inboundHistory, + RawBody: bodyText, + CommandBody: commandBody, + BodyForCommands: commandBody, + From: + kind === "direct" + ? `mattermost:${senderId}` + : kind === "group" + ? `mattermost:group:${channelId}` + : `mattermost:channel:${channelId}`, + To: to, + SessionKey: sessionKey, + ParentSessionKey: parentSessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: fromLabel, + GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined, + GroupChannel: channelName ? `#${channelName}` : undefined, + GroupSpace: teamId, + SenderName: senderName, + SenderId: senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: post.id ?? undefined, + MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined, + MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined, + MessageSidLast: + allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined, + ReplyToId: effectiveReplyToId, + MessageThreadId: effectiveReplyToId, + Timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + ...mediaPayload, + }); + + if (kind === "direct") { + const sessionCfg = cfg.session; + const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); + await core.channel.session.updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + deliveryContext: { + channel: "mattermost", + to, + accountId: route.accountId, + }, + }); + } + + const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage( + `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, + ); + + 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 } = createChannelReplyPipeline({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: { + start: () => sendTypingIndicator(channelId, effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: channelId, + error: err, + }); + }, + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...replyPipeline, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, + deliver: async (payload: ReplyPayload) => { + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: effectiveReplyToId, + replyToId: payload.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); + runtime.log?.(`delivered reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); + }, + }); + + 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, + }, + }), + }); + if (historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: channelHistories, + historyKey, + limit: historyLimit, + }); + } + }; + + const handleReactionEvent = async (payload: MattermostEventPayload) => { + const reactionData = payload.data?.reaction; + if (!reactionData) { + return; + } + let reaction: MattermostReaction | null = null; + if (typeof reactionData === "string") { + try { + reaction = JSON.parse(reactionData) as MattermostReaction; + } catch { + return; + } + } else if (typeof reactionData === "object") { + reaction = reactionData as MattermostReaction; + } + if (!reaction) { + return; + } + + const userId = reaction.user_id?.trim(); + const postId = reaction.post_id?.trim(); + const emojiName = reaction.emoji_name?.trim(); + if (!userId || !postId || !emojiName) { + return; + } + + // Skip reactions from the bot itself + if (userId === botUserId) { + return; + } + + const isRemoved = payload.event === "reaction_removed"; + const action = isRemoved ? "removed" : "added"; + + const senderInfo = await resolveUserInfo(userId); + const senderName = senderInfo?.username?.trim() || userId; + + // Resolve the channel from broadcast or post to route to the correct agent session + const channelId = resolveMattermostReactionChannelId(payload); + if (!channelId) { + // Without a channel id we cannot verify DM/group policies — drop to be safe + logVerboseMessage( + `mattermost: drop reaction (no channel_id in broadcast, cannot enforce policy)`, + ); + return; + } + const channelInfo = await resolveChannelInfo(channelId); + if (!channelInfo?.type) { + // Cannot determine channel type — drop to avoid policy bypass + logVerboseMessage(`mattermost: drop reaction (cannot resolve channel type for ${channelId})`); + return; + } + const kind = mapMattermostChannelTypeToChatType(channelInfo.type); + + // Enforce DM/group policy and allowlist checks (same as normal messages) + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const reactionAccess = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []), + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId: userId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + if (reactionAccess.decision !== "allow") { + if (kind === "direct") { + logVerboseMessage( + `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`, + ); + } else { + logVerboseMessage( + `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`, + ); + } + return; + } + + const teamId = channelInfo?.team_id ?? undefined; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? userId : channelId, + }, + }); + const sessionKey = route.sessionKey; + + const eventText = `Mattermost reaction ${action}: :${emojiName}: by @${senderName} on post ${postId} in channel ${channelId}`; + + core.system.enqueueSystemEvent(eventText, { + sessionKey, + contextKey: `mattermost:reaction:${postId}:${emojiName}:${userId}:${action}`, + }); + + logVerboseMessage( + `mattermost reaction: ${action} :${emojiName}: by ${senderName} on ${postId}`, + ); + }; + + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "mattermost", + }); + const debouncer = core.channel.debounce.createInboundDebouncer<{ + post: MattermostPost; + payload: MattermostEventPayload; + }>({ + debounceMs: inboundDebounceMs, + buildKey: (entry) => { + const channelId = + entry.post.channel_id ?? + entry.payload.data?.channel_id ?? + entry.payload.broadcast?.channel_id; + if (!channelId) { + return null; + } + const threadId = entry.post.root_id?.trim(); + const threadKey = threadId ? `thread:${threadId}` : "channel"; + return `mattermost:${account.accountId}:${channelId}:${threadKey}`; + }, + shouldDebounce: (entry) => { + if (entry.post.file_ids && entry.post.file_ids.length > 0) { + return false; + } + const text = entry.post.message?.trim() ?? ""; + if (!text) { + return false; + } + return !core.channel.text.hasControlCommand(text, cfg); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + await handlePost(last.post, last.payload); + return; + } + const combinedText = entries + .map((entry) => entry.post.message?.trim() ?? "") + .filter(Boolean) + .join("\n"); + const mergedPost: MattermostPost = { + ...last.post, + message: combinedText, + file_ids: [], + }; + const ids = entries.map((entry) => entry.post.id).filter(Boolean); + await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined); + }, + onError: (err) => { + runtime.error?.(`mattermost debounce flush failed: ${String(err)}`); + }, + }); + + const wsUrl = buildMattermostWsUrl(baseUrl); + let seq = 1; + const connectOnce = createMattermostConnectOnce({ + wsUrl, + botToken, + abortSignal: opts.abortSignal, + statusSink: opts.statusSink, + runtime, + webSocketFactory: opts.webSocketFactory, + nextSeq: () => seq++, + getBotUpdateAt: async () => { + const me = await fetchMattermostMe(client); + return me.update_at ?? 0; + }, + onPosted: async (post, payload) => { + await debouncer.enqueue({ post, payload }); + }, + onReaction: async (payload) => { + await handleReactionEvent(payload); + }, + }); + + let slashShutdownCleanup: Promise | null = null; + + // Clean up slash commands on shutdown + if (slashEnabled) { + const runAbortCleanup = () => { + if (slashShutdownCleanup) { + return; + } + // Snapshot registered commands before deactivating state. + // This listener may run concurrently with startup in a new process, so we keep + // monitor shutdown alive until the remote cleanup completes. + const commands = getSlashCommandState(account.accountId)?.registeredCommands ?? []; + // Deactivate state immediately to prevent new local dispatches during teardown. + deactivateSlashCommands(account.accountId); + + slashShutdownCleanup = cleanupSlashCommands({ + client, + commands, + log: (msg) => runtime.log?.(msg), + }).catch((err) => { + runtime.error?.(`mattermost: slash cleanup failed: ${String(err)}`); + }); + }; + + if (opts.abortSignal?.aborted) { + runAbortCleanup(); + } else { + opts.abortSignal?.addEventListener("abort", runAbortCleanup, { once: true }); + } + } + + try { + await runWithReconnect(connectOnce, { + abortSignal: opts.abortSignal, + jitterRatio: 0.2, + onError: (err) => { + runtime.error?.(`mattermost connection failed: ${String(err)}`); + opts.statusSink?.({ lastError: String(err), connected: false }); + }, + onReconnect: (delayMs) => { + runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`); + }, + }); + } finally { + unregisterInteractions?.(); + } + + if (slashShutdownCleanup) { + await slashShutdownCleanup; + } +} diff --git a/extensions/mattermost/src/mattermost/probe.test.ts b/extensions/mattermost/src/mattermost/probe.test.ts new file mode 100644 index 0000000..a457a3d --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.test.ts @@ -0,0 +1,136 @@ +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 }; +}); + +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, + }); + + // pragma: allowlist secret + const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token"); + + expect(mockFetchGuard).toHaveBeenCalledWith({ + url: "https://mm.example.com/api/v4/users/me", + init: expect.objectContaining({ + headers: { Authorization: "Bearer bot-token" }, + }), + auditContext: "mattermost-probe", + policy: undefined, + }); + expect(result).toEqual( + expect.objectContaining({ + ok: true, + status: 200, + bot: { id: "bot-1", username: "clawbot" }, + }), + ); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + 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, + }); + + // pragma: allowlist secret + await probeMattermost("https://mm.example.com", "bot-token", 2500, true); + + expect(mockFetchGuard).toHaveBeenCalledWith( + expect.objectContaining({ + policy: { allowPrivateNetwork: true }, + }), + ); + }); + + 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, + }); + + await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 401, + error: "invalid auth token", + }), + ); + 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, + }); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 403, + error: "Forbidden", + }), + ); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it("returns fetch error when request throws", async () => { + mockFetchGuard.mockRejectedValueOnce(new Error("network down")); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: null, + error: "network down", + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts new file mode 100644 index 0000000..c68b18b --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -0,0 +1,72 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; +import type { BaseProbeResult } from "./runtime-api.js"; + +export type MattermostProbe = BaseProbeResult & { + status?: number | null; + elapsedMs?: number | null; + bot?: MattermostUser; +}; + +export async function probeMattermost( + baseUrl: string, + botToken: string, + timeoutMs = 2500, + allowPrivateNetwork = false, +): Promise { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + return { ok: false, error: "baseUrl missing" }; + } + const url = `${normalized}/api/v4/users/me`; + const start = Date.now(); + const controller = timeoutMs > 0 ? new AbortController() : undefined; + let timer: NodeJS.Timeout | null = null; + if (controller) { + timer = setTimeout(() => controller.abort(), timeoutMs); + } + try { + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + headers: { Authorization: `Bearer ${botToken}` }, + signal: controller?.signal, + }, + auditContext: "mattermost-probe", + policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + }); + 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 res.json()) as MattermostUser; + return { + ok: true, + status: res.status, + elapsedMs, + bot, + }; + } finally { + await release(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: null, + error: message, + elapsedMs: Date.now() - start, + }; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/extensions/mattermost/src/mattermost/reactions.test-helpers.ts b/extensions/mattermost/src/mattermost/reactions.test-helpers.ts new file mode 100644 index 0000000..7c501c8 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reactions.test-helpers.ts @@ -0,0 +1,88 @@ +import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../../runtime-api.js"; +import type { MattermostFetch } from "./client.js"; + +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(async (url, init) => { + if (String(url).endsWith("/api/v4/users/me")) { + return new Response(JSON.stringify({ id: userId }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (allowAdd && String(url).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 && String(url).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: ${url}`); + }); +} + +export async function withMockedGlobalFetch( + fetchImpl: MattermostFetch, + run: () => Promise, +): Promise { + const prevFetch = globalThis.fetch; + globalThis.fetch = fetchImpl; + try { + return await run(); + } finally { + globalThis.fetch = prevFetch; + } +} diff --git a/extensions/mattermost/src/mattermost/reactions.test.ts b/extensions/mattermost/src/mattermost/reactions.test.ts new file mode 100644 index 0000000..90c3758 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reactions.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addMattermostReaction, + removeMattermostReaction, + resetMattermostReactionBotUserCacheForTests, +} from "./reactions.js"; +import { + createMattermostReactionFetchMock, + createMattermostTestConfig, +} from "./reactions.test-helpers.js"; + +describe("mattermost reactions", () => { + beforeEach(() => { + resetMattermostReactionBotUserCacheForTests(); + }); + + 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) => + String(call[0]).endsWith("/api/v4/users/me"), + ); + expect(addResult).toEqual({ ok: true }); + expect(removeResult).toEqual({ ok: true }); + expect(usersMeCalls).toHaveLength(1); + }); +}); diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts new file mode 100644 index 0000000..d363d05 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -0,0 +1,130 @@ +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; +type MutationPayload = { userId: string; postId: string; emojiName: string }; + +const BOT_USER_CACHE_TTL_MS = 10 * 60_000; +const botUserIdCache = new Map(); + +async function resolveBotUserId( + client: MattermostClient, + cacheKey: string, +): Promise { + const cached = botUserIdCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.userId; + } + const me = await fetchMattermostMe(client); + const userId = me?.id?.trim(); + if (!userId) { + return null; + } + botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS }); + return userId; +} + +export async function addMattermostReaction(params: { + cfg: OpenClawConfig; + postId: string; + emojiName: string; + accountId?: string | null; + fetchImpl?: MattermostFetch; +}): Promise { + return runMattermostReaction(params, { + action: "add", + mutation: createReaction, + }); +} + +export async function removeMattermostReaction(params: { + cfg: OpenClawConfig; + postId: string; + emojiName: string; + accountId?: string | null; + fetchImpl?: MattermostFetch; +}): Promise { + 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 { + 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: resolved.config?.allowPrivateNetwork === true, + }); + + 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 { + await client.request>("/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 { + const emoji = encodeURIComponent(params.emojiName); + await client.request( + `/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`, + { + method: "DELETE", + }, + ); +} diff --git a/extensions/mattermost/src/mattermost/reconnect.test.ts b/extensions/mattermost/src/mattermost/reconnect.test.ts new file mode 100644 index 0000000..adc8aac --- /dev/null +++ b/extensions/mattermost/src/mattermost/reconnect.test.ts @@ -0,0 +1,198 @@ +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): Promise { + 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).toHaveBeenCalledWith(expect.objectContaining({ message: "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(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/reconnect.ts b/extensions/mattermost/src/mattermost/reconnect.ts new file mode 100644 index 0000000..7de004d --- /dev/null +++ b/extensions/mattermost/src/mattermost/reconnect.ts @@ -0,0 +1,103 @@ +export type ReconnectOutcome = "resolved" | "rejected"; + +export type ShouldReconnectParams = { + attempt: number; + delayMs: number; + outcome: ReconnectOutcome; + error?: unknown; +}; + +export 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, + opts: RunWithReconnectOpts = {}, +): Promise { + const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts; + const jitterRatio = Math.max(0, opts.jitterRatio ?? 0); + const random = opts.random ?? Math.random; + let retryDelay = initialDelayMs; + let attempt = 0; + + while (!opts.abortSignal?.aborted) { + let shouldIncreaseDelay = false; + let outcome: ReconnectOutcome = "resolved"; + let error: unknown; + try { + await connectFn(); + retryDelay = initialDelayMs; + } catch (err) { + if (opts.abortSignal?.aborted) { + return; + } + outcome = "rejected"; + error = err; + opts.onError?.(err); + shouldIncreaseDelay = true; + } + if (opts.abortSignal?.aborted) { + return; + } + const delayMs = withJitter(retryDelay, jitterRatio, random); + const shouldReconnect = + opts.shouldReconnect?.({ + attempt, + delayMs, + outcome, + error, + }) ?? true; + if (!shouldReconnect) { + return; + } + opts.onReconnect?.(delayMs); + await sleepAbortable(delayMs, opts.abortSignal); + if (shouldIncreaseDelay) { + retryDelay = Math.min(retryDelay * 2, maxDelayMs); + } + attempt++; + } +} + +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 { + 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 }); + }); +} diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts new file mode 100644 index 0000000..c8ad1cb --- /dev/null +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; + +describe("reply-delivery placeholder", () => { + it("placeholder test", () => { + expect(true).toBe(true); + }); +}); diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts new file mode 100644 index 0000000..bb04797 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -0,0 +1,95 @@ +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; +import { + getAgentScopedMediaLocalRoots, + type OpenClawConfig, + type PluginRuntime, + type ReplyPayload, +} from "./runtime-api.js"; +import { compileMattermostInteractiveReplies } from "../interactive-replies.js"; + +type MarkdownTableMode = Parameters[1]; + +type SendMattermostMessage = ( + to: string, + text: string, + opts: { + cfg?: OpenClawConfig; + accountId?: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + replyToId?: string; + buttons?: Array<{ text: string; value: string; style?: string }>; + }, +) => Promise; + +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; +}): Promise { + // Compile interactive directives (buttons, selects) from [[...]] syntax + const compiledPayload = compileMattermostInteractiveReplies(params.payload); + + // Extract buttons from interactive blocks for Mattermost API + const buttons: Array<{ text: string; value: string; style?: string }> = []; + for (const block of compiledPayload.interactive?.blocks || []) { + if (block.type === "buttons" && "buttons" in block && Array.isArray(block.buttons)) { + for (const btn of block.buttons) { + if (btn && typeof btn === "object") { + buttons.push({ + text: (btn as { label?: string }).label || "", + value: (btn as { value?: string }).value || "", + style: (btn as { style?: string }).style || "default", + }); + } + } + } + } + + const reply = resolveSendableOutboundReplyParts(compiledPayload, { + text: params.core.channel.text.convertMarkdownTables( + compiledPayload.text ?? "", + params.tableMode, + ), + }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); + const chunkMode = params.core.channel.text.resolveChunkMode( + params.cfg, + "mattermost", + params.accountId, + ); + await deliverTextOrMediaReply({ + payload: compiledPayload, + 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, + buttons, + }); + }, + sendMedia: async ({ mediaUrl, caption }) => { + await params.sendMessage(params.to, caption ?? "", { + cfg: params.cfg, + accountId: params.accountId, + mediaUrl, + mediaLocalRoots, + replyToId: params.replyToId, + buttons, + }); + }, + }); +} diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts new file mode 100644 index 0000000..cb13339 --- /dev/null +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "../../runtime-api.js"; diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts new file mode 100644 index 0000000..9e988b3 --- /dev/null +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -0,0 +1,642 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../../../test/helpers/plugins/send-config.js"; + +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[2] +>; + +const mockState = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + loadOutboundMediaFromUrl: vi.fn(), + recordActivity: vi.fn(), + resolveMattermostAccount: vi.fn(() => ({ + accountId: "default", + // pragma: allowlist secret + // pragma: allowlist secret + 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(), +})); + +vi.mock("../../runtime-api.js", () => ({ + loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl, +})); + +vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ + resolveMarkdownTableMode: vi.fn(() => "off"), +})); + +vi.mock("openclaw/plugin-sdk/text-runtime", () => ({ + convertMarkdownTables: vi.fn((text: string) => text), +})); + +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", + // pragma: allowlist secret + 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("falls back to runtime loadConfig when cfg is omitted", async () => { + const runtimeCfg = { + channels: { + mattermost: { + botToken: "runtime-token", + }, + }, + }; + mockState.loadConfig.mockReturnValueOnce(runtimeCfg); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "runtime-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); + + await sendMessageMattermost("channel:town-square", "hello"); + + expectRuntimeCfgFallback({ + loadConfig: mockState.loadConfig, + resolveAccount: mockState.resolveMattermostAccount, + cfg: runtimeCfg, + accountId: undefined, + }); + }); + + 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"); + }); + + await expect( + sendMessageMattermost("channel:town-square", "hello", { + cfg: providedCfg, + accountId: "work", + }), + ).resolves.toEqual({ + messageId: "post-1", + channelId: "town-square", + }); + 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", + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); + + await sendMessageMattermost("channel:town-square", "hello", { + mediaUrl: "file:///tmp/agent-workspace/photo.png", + mediaLocalRoots: ["/tmp/agent-workspace"], + }); + + expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith( + "file:///tmp/agent-workspace/photo.png", + { + mediaLocalRoots: ["/tmp/agent-workspace"], + }, + ); + expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + channelId: "town-square", + fileName: "photo.png", + contentType: "image/png", + }), + ); + }); + + it("builds interactive button props when buttons are provided", async () => { + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); + + await sendMessageMattermost("channel:town-square", "Pick a model", { + buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], + }); + + expect(mockState.createMattermostPost).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + channelId: "town-square", + message: "Pick a model", + props: expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + id: "mdlprov", + name: "Browse providers", + }), + ]), + }), + ]), + }), + }), + ); + }); + + it("resolves a bare Mattermost user id as a DM target before upload", async () => { + const userId = "dthcxgoxhifn3pwh65cut3ud3w"; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + // pragma: allowlist secret + 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", { + mediaUrl: "file:///tmp/agent-workspace/photo.png", + mediaLocalRoots: ["/tmp/agent-workspace"], + }); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-user", userId], + expect.any(Object), + ); + expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + channelId: "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", + // pragma: allowlist secret + 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", { + mediaUrl: "file:///tmp/agent-workspace/photo.png", + mediaLocalRoots: ["/tmp/agent-workspace"], + }); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); + expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + 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"); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + expect(params.channelId).toBe("dm-channel-id"); + expect(res.channelId).toBe("dm-channel-id"); + expect(res.messageId).toBe("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"); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + 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"); + 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"); + 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"); + + expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); + expect(res.channelId).toBe("dm-channel-id"); + }); + + 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"); + + expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + 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", { + dmRetryOptions: retryOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(retryOptions), + ); + }); + + 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"); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + maxRetries: 4, + initialDelayMs: 2000, + maxDelayMs: 8000, + timeoutMs: 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", { + dmRetryOptions: overrideOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(overrideOptions), + ); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + initialDelayMs: 1000, + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts new file mode 100644 index 0000000..7389a46 --- /dev/null +++ b/extensions/mattermost/src/mattermost/send.ts @@ -0,0 +1,471 @@ +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; +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, + type MattermostInteractiveButtonInput, +} 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; + replyToId?: string; + props?: Record; + buttons?: Array; + attachmentText?: string; + /** Retry options for DM channel creation */ + dmRetryOptions?: CreateDmChannelRetryOptions; +}; + +export type MattermostSendResult = { + messageId: string; + channelId: string; +}; + +export type MattermostReplyButtons = Array< + MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[] +>; + +type MattermostTarget = + | { kind: "channel"; id: string } + | { kind: "channel-name"; name: string } + | { kind: "user"; id?: string; username?: string }; + +const botUserCache = new Map(); +const userByNameCache = new Map(); +const channelByNameCache = new Map(); +const dmChannelCache = new Map(); + +const getCore = () => getMattermostRuntime(); + +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 = text.trim(); + const media = mediaUrl?.trim(); + 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 = trimmed.toLowerCase(); + 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 { + 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 { + const { baseUrl, token, username } = params; + const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`; + 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 { + const { baseUrl, token, name } = params; + const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`; + const cached = channelByNameCache.get(key); + if (cached) { + return cached; + } + const client = createMattermostClient({ + baseUrl, + botToken: token, + 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; + 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 { + 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 channel = await 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}`, + ); + } + }, + }); + 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 { + const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const cfg = opts.cfg ?? core.config.loadConfig(); + const account = resolveMattermostAccount({ + cfg, + accountId: opts.accountId, + }); + const token = opts.botToken?.trim() || account.botToken?.trim(); + 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 = to?.trim() ?? ""; + 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 = account.config.allowPrivateNetwork === true; + const channelId = await resolveTargetChannelId({ + target, + baseUrl, + token, + allowPrivateNetwork, + dmRetryOptions, + logger: core.logging.shouldLogVerbose() ? logger : undefined, + }); + + return { + cfg, + accountId: account.accountId, + token, + baseUrl, + channelId, + allowPrivateNetwork, + }; +} + +export async function resolveMattermostSendChannelId( + to: string, + opts: MattermostSendOpts = {}, +): Promise { + return (await resolveMattermostSendContext(to, opts)).channelId; +} + +export async function sendMessageMattermost( + to: string, + text: string, + opts: MattermostSendOpts = {}, +): Promise { + 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 = text?.trim() ?? ""; + 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, + }); + 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 (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}`); + } + throw new Error("Mattermost message is empty"); + } + + const post = await createMattermostPost(client, { + channelId, + message, + rootId: opts.replyToId, + fileIds, + props, + }); + + recordMattermostOutboundActivity(accountId); + + return { + messageId: post.id ?? "unknown", + channelId, + }; +} diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts new file mode 100644 index 0000000..4dfd55d --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MattermostClient } from "./client.js"; +import { + DEFAULT_COMMAND_SPECS, + parseSlashCommandPayload, + registerSlashCommands, + resolveCallbackUrl, + resolveCommandText, + resolveSlashCommandConfig, +} from "./slash-commands.js"; + +describe("slash-commands", () => { + async function registerSingleStatusCommand( + requestImpl: (path: string, init?: { method?: string }) => Promise, + ) { + const client: MattermostClient = { + baseUrl: "https://chat.example.com", + apiBaseUrl: "https://chat.example.com/api/v4", + // pragma: allowlist secret + token: "bot-token", + request: async (path: string, init?: RequestInit) => (await requestImpl(path, init)) as T, + fetchImpl: vi.fn(), + }; + 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).toMatchObject({ + token: "t1", + team_id: "team", + channel_id: "ch1", + user_id: "u1", + command: "/oc_status", + text: "now", + }); + }); + + 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).toMatchObject({ + token: "t2", + command: "/oc_model", + text: "gpt-5", + }); + }); + + 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([["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("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); + expect(result[0]?.managed).toBe(false); + expect(result[0]?.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); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts new file mode 100644 index 0000000..5452540 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -0,0 +1,588 @@ +/** + * Mattermost native slash command support. + * + * Registers custom slash commands via the Mattermost REST API and handles + * incoming command callbacks via an HTTP endpoint on the gateway. + * + * Architecture: + * - On startup, registers commands with MM via POST /api/v4/commands + * - MM sends HTTP POST to callbackUrl when a user invokes a command + * - The callback handler reconstructs the text as `/ ` and + * routes it through the standard inbound reply pipeline + * - On shutdown, cleans up registered commands via DELETE /api/v4/commands/{id} + */ + +import type { MattermostClient } from "./client.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +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; + /** 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: "P" | "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: "P" | "G"; + url: string; + description?: string; + auto_complete: boolean; + auto_complete_desc?: string; + auto_complete_hint?: string; +}; + +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]", + }, + { + 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]", + }, +]; + +// ─── Command registration ──────────────────────────────────────────────────── + +/** + * List existing custom slash commands for a team. + */ +export async function listMattermostCommands( + client: MattermostClient, + teamId: string, +): Promise { + return await client.request( + `/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`, + ); +} + +/** + * Create a custom slash command on a Mattermost team. + */ +export async function createMattermostCommand( + client: MattermostClient, + params: MattermostCommandCreate, +): Promise { + return await client.request("/commands", { + method: "POST", + body: JSON.stringify(params), + }); +} + +/** + * Delete a custom slash command. + */ +export async function deleteMattermostCommand( + client: MattermostClient, + commandId: string, +): Promise { + await client.request>(`/commands/${encodeURIComponent(commandId)}`, { + method: "DELETE", + }); +} + +/** + * Update an existing custom slash command. + */ +export async function updateMattermostCommand( + client: MattermostClient, + params: MattermostCommandUpdate, +): Promise { + return await client.request( + `/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 { + 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(); + 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]; + + // Already registered with the correct callback URL + if (existingCmd && existingCmd.url === callbackUrl) { + log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`); + registered.push({ + id: existingCmd.id, + trigger: spec.trigger, + teamId, + token: existingCmd.token, + managed: false, + }); + continue; + } + + // Exists but points to a different URL: attempt to reconcile by updating + // (useful during callback URL migrations). + if (existingCmd && existingCmd.url !== callbackUrl) { + log?.( + `mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`, + ); + try { + const updated = await updateMattermostCommand(client, { + id: existingCmd.id, + team_id: teamId, + trigger: spec.trigger, + method: "P", + 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, + 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: "P", + 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, + 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 { + 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; + + // 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 { + // 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}`; +} + +// ─── 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 { + return { + native: raw?.native ?? "auto", + nativeSkills: raw?.nativeSkills ?? "auto", + callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH), + callbackUrl: raw?.callbackUrl?.trim() || undefined, + }; +} + +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; +} + +export function collectMattermostSlashCallbackPaths(raw?: Partial) { + const config = resolveSlashCommandConfig(raw); + const paths = new Set([config.callbackPath]); + if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) { + try { + const pathname = new URL(config.callbackUrl).pathname; + if (pathname) { + paths.add(pathname); + } + } catch { + // Ignore invalid callback URLs and keep the normalized callback path only. + } + } + return [...paths]; +} + +/** + * 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}`; +} diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts new file mode 100644 index 0000000..10ad083 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -0,0 +1,265 @@ +import { ServerResponse, type IncomingMessage } from "node:http"; +import { PassThrough } from "node:stream"; +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +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), +})); + +vi.mock("./runtime-api.js", () => { + return { + buildModelsProviderData: mockState.buildModelsProviderData, + createChannelReplyPipeline: 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) => 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("./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", () => ({ + parseSlashCommandPayload: mockState.parseSlashCommandPayload, + resolveCommandText: mockState.resolveCommandText, +})); + +let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler; + +function createRequest(body = "token=valid-token"): IncomingMessage { + const req = new PassThrough(); + const incoming = req as PassThrough & IncomingMessage; + incoming.method = "POST"; + 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, + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + 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(); + ({ 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, + commandTokens: new Set(["valid-token"]), + }); + 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("does not rely on Set.has for command token validation", async () => { + const commandTokens = new Set(["valid-token"]); + const hasSpy = vi.fn(() => { + throw new Error("Set.has should not be used for slash token validation"); + }); + Object.defineProperty(commandTokens, "has", { + value: hasSpy, + configurable: true, + }); + + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens, + }); + const response = createResponse(); + + await handler(createRequest(), response.res); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toContain("Processing"); + expect(hasSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts new file mode 100644 index 0000000..2e3f006 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -0,0 +1,158 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { createSlashCommandHttpHandler } 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; +} { + let body = ""; + const headers = new Map(); + 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, + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + config: {}, +}; + +async function runSlashRequest(params: { + commandTokens: Set; + body: string; + method?: string; +}) { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: params.commandTokens, + }); + const req = createRequest({ method: params.method, body: params.body }); + const response = createResponse(); + await handler(req, response.res); + return response; +} + +describe("slash-http", () => { + it("rejects non-POST methods", async () => { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + }); + 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, + commandTokens: new Set(["valid-token"]), + }); + 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 command tokens are registered", async () => { + const response = await runSlashRequest({ + commandTokens: new Set(), + 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 command tokens", async () => { + const response = await runSlashRequest({ + commandTokens: new Set(["known-token"]), + body: "token=unknown&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("returns 408 when the request body stalls", async () => { + vi.useFakeTimers(); + try { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + }); + const req = createRequest({ autoEnd: false }); + const response = createResponse(); + const pending = handler(req, response.res); + + await vi.advanceTimersByTimeAsync(5_000); + await pending; + + expect(response.res.statusCode).toBe(408); + expect(response.getBody()).toBe("Request body timeout"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts new file mode 100644 index 0000000..9527549 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -0,0 +1,546 @@ +/** + * 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 { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; +import { getMattermostRuntime } from "../runtime.js"; +import { + createMattermostClient, + fetchMattermostChannel, + normalizeMattermostBaseUrl, + sendMattermostTyping, + type MattermostChannel, +} from "./client.js"; +import { + renderMattermostModelSummaryView, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, + resolveMattermostModelPickerEntry, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, + normalizeMattermostAllowList, +} from "./monitor-auth.js"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; +import { + buildModelsProviderData, + createChannelReplyPipeline, + isRequestBodyLimitError, + logTypingFailure, + readRequestBodyWithLimit, + type OpenClawConfig, + type ReplyPayload, + type RuntimeEnv, +} from "./runtime-api.js"; +import { sendMessageMattermost } from "./send.js"; +import { + parseSlashCommandPayload, + resolveCommandText, + type MattermostSlashCommandResponse, +} from "./slash-commands.js"; + +type SlashHttpHandlerParams = { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + /** Expected token from registered commands (for validation). */ + commandTokens: Set; + /** Map from trigger to original command name (for skill commands that start with oc_). */ + triggerMap?: ReadonlyMap; + log?: (msg: string) => void; +}; + +const MAX_BODY_BYTES = 64 * 1024; +const BODY_READ_TIMEOUT_MS = 5_000; + +/** + * Read the full request body as a string. + */ +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return readRequestBodyWithLimit(req, { + maxBytes, + timeoutMs: BODY_READ_TIMEOUT_MS, + }); +} + +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 matchesRegisteredCommandToken( + commandTokens: ReadonlySet, + candidate: string, +): boolean { + for (const token of commandTokens) { + if (safeEqualSecret(candidate, token)) { + return true; + } + } + return false; +} + +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; + commandText: string; + channelId: string; + senderId: string; + senderName: string; + log?: (msg: string) => void; +}): Promise { + 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 ${channelId}: ${String(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 = 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, commandTokens, triggerMap, log } = params; + + return async (req: IncomingMessage, res: ServerResponse): Promise => { + 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); + } 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; + } + + // Validate token — fail closed: reject when no tokens are registered + // (e.g. registration failed or startup was partial) + if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) { + sendJsonResponse(res, 401, { + response_type: "ephemeral", + text: "Unauthorized: invalid command token.", + }); + return; + } + + // Extract command info + const trigger = payload.command.replace(/^\//, "").trim(); + const commandText = resolveCommandText(trigger, payload.text, triggerMap); + const channelId = payload.channel_id; + const senderId = payload.user_id; + const senderName = payload.user_name ?? senderId; + + const client = createMattermostClient({ + baseUrl: account.baseUrl ?? "", + botToken: account.botToken ?? "", + allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + }); + + 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 /${trigger} from ${senderName} in ${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: ${String(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; + 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, + 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 } = createChannelReplyPipeline({ + 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 { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...replyPipeline, + humanDelay, + deliver: async (payload: ReplyPayload) => { + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); + runtime.log?.(`delivered slash reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost slash ${info.kind} reply failed: ${String(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, + }, + }), + }); +} diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts new file mode 100644 index 0000000..75bb36b --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { + activateSlashCommands, + deactivateSlashCommands, + resolveSlashHandlerForToken, +} from "./slash-state.js"; + +function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount { + return { + accountId, + enabled: true, + botTokenSource: "config", + baseUrlSource: "config", + config: {}, + }; +} + +const slashApi = { + cfg: {}, + runtime: { + log: () => {}, + error: () => {}, + exit: () => {}, + }, +} satisfies { + cfg: OpenClawConfig; + runtime: RuntimeEnv; +}; + +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"); + expect(match.accountIds).toEqual(["a1"]); + }); + + 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"); + expect(match.accountIds?.sort()).toEqual(["a1", "a2"]); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts new file mode 100644 index 0000000..793a772 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -0,0 +1,311 @@ +/** + * 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 valid, 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 type { MattermostConfig } from "../types.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import type { OpenClawPluginApi } from "./runtime-api.js"; +import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; +import { createSlashCommandHttpHandler } from "./slash-http.js"; + +// ─── Per-account state ─────────────────────────────────────────────────────── + +export type SlashCommandAccountState = { + /** Tokens from registered commands, used for validation. */ + commandTokens: Set; + /** Registered command IDs for cleanup on shutdown. */ + registeredCommands: MattermostRegisteredCommand[]; + /** Current HTTP handler for this account. */ + handler: ((req: IncomingMessage, res: ServerResponse) => Promise) | 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; +}; + +/** Map from accountId → per-account slash command state. */ +const accountStates = new Map(); + +export function resolveSlashHandlerForToken(token: string): { + kind: "none" | "single" | "ambiguous"; + handler?: (req: IncomingMessage, res: ServerResponse) => Promise; + accountIds?: string[]; +} { + const matches: Array<{ + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + }> = []; + + 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", handler: matches[0]!.handler, accountIds: [matches[0]!.accountId] }; + } + + return { + kind: "ambiguous", + 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; +} + +/** + * Get all active slash command account states. + */ +export function getAllSlashCommandStates(): ReadonlyMap { + return accountStates; +} + +/** + * 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; + 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, + commandTokens: tokenSet, + 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; + accountStates.delete(accountId); + } + } else { + // Deactivate all accounts (full shutdown) + for (const [, state] of accountStates) { + state.commandTokens.clear(); + state.registeredCommands = []; + state.handler = null; + } + 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 registered tokens. + */ +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(); + + const addCallbackPaths = ( + raw: Partial | 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 + | 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 token to route to the right account handler. + // Since each account handler also validates the token, we find the + // account whose token set contains the inbound token and delegate. + + // 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, + // then replay the request to the correct handler. + const chunks: Buffer[] = []; + const MAX_BODY = 64 * 1024; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY) { + res.statusCode = 413; + res.end("Payload Too Large"); + return; + } + chunks.push(chunk as Buffer); + } + const bodyStr = Buffer.concat(chunks).toString("utf8"); + + // Parse just the token to find the right account + 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 + } + + const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const }; + + 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 token matched multiple accounts (${match.accountIds?.join(", ")})`, + ); + res.statusCode = 409; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + response_type: "ephemeral", + text: "Conflict: command token is not unique across accounts.", + }), + ); + return; + } + + const matchedHandler = match.handler!; + + // Replay: create a synthetic readable that re-emits the buffered body + const { Readable } = await import("node:stream"); + 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, + }); + api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`); + } +} diff --git a/extensions/mattermost/src/mattermost/target-resolution.test.ts b/extensions/mattermost/src/mattermost/target-resolution.test.ts new file mode 100644 index 0000000..76ce940 --- /dev/null +++ b/extensions/mattermost/src/mattermost/target-resolution.test.ts @@ -0,0 +1,128 @@ +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", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts new file mode 100644 index 0000000..080f33f --- /dev/null +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -0,0 +1,101 @@ +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(); + +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 ? String((err as { message?: unknown }).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 { + 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 = params.token?.trim() || account?.botToken?.trim(); + 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: account?.config?.allowPrivateNetwork === true, + }); + 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(); +} diff --git a/extensions/mattermost/src/normalize.test.ts b/extensions/mattermost/src/normalize.test.ts new file mode 100644 index 0000000..fb7866b --- /dev/null +++ b/extensions/mattermost/src/normalize.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; + +describe("normalizeMattermostMessagingTarget", () => { + it("returns undefined for empty input", () => { + expect(normalizeMattermostMessagingTarget("")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined(); + }); + + it("normalizes channel: prefix", () => { + expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123"); + expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC"); + }); + + it("normalizes group: prefix to channel:", () => { + expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123"); + }); + + it("normalizes user: prefix", () => { + expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123"); + }); + + it("normalizes mattermost: prefix to user:", () => { + expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123"); + }); + + it("keeps @username targets", () => { + expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice"); + expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice"); + }); + + it("returns undefined for #channel (triggers directory lookup)", () => { + expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined(); + }); + + it("returns undefined for bare names (triggers directory lookup)", () => { + expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined(); + }); + + it("returns undefined for empty prefixed values", () => { + expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("@")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("#")).toBeUndefined(); + }); +}); + +describe("looksLikeMattermostTargetId", () => { + it("returns false for empty input", () => { + expect(looksLikeMattermostTargetId("")).toBe(false); + expect(looksLikeMattermostTargetId(" ")).toBe(false); + }); + + it("recognizes prefixed targets", () => { + expect(looksLikeMattermostTargetId("channel:abc")).toBe(true); + expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true); + expect(looksLikeMattermostTargetId("user:abc")).toBe(true); + expect(looksLikeMattermostTargetId("group:abc")).toBe(true); + expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true); + }); + + it("recognizes @username", () => { + expect(looksLikeMattermostTargetId("@alice")).toBe(true); + }); + + it("does NOT recognize #channel (should go to directory)", () => { + expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false); + expect(looksLikeMattermostTargetId("#off-topic")).toBe(false); + }); + + it("recognizes 26-char alphanumeric Mattermost IDs", () => { + expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true); + expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true); + expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // 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 + }); +}); diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts new file mode 100644 index 0000000..25e3dfc --- /dev/null +++ b/extensions/mattermost/src/normalize.ts @@ -0,0 +1,50 @@ +export function normalizeMattermostMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const lower = trimmed.toLowerCase(); + 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); +} diff --git a/extensions/mattermost/src/plugin-sdk/account-helpers.ts b/extensions/mattermost/src/plugin-sdk/account-helpers.ts new file mode 100644 index 0000000..0a25064 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/account-helpers.ts @@ -0,0 +1,14 @@ +export function createAccountListHelpers(channelId: string) { + return { + listAccountIds: (cfg: any) => { + const accounts = cfg?.channels?.[channelId]?.accounts; + if (accounts && typeof accounts === "object") { + return Object.keys(accounts); + } + return ["default"]; + }, + resolveDefaultAccountId: (cfg: any) => { + return "default"; + }, + }; +} diff --git a/extensions/mattermost/src/plugin-sdk/account-id.ts b/extensions/mattermost/src/plugin-sdk/account-id.ts new file mode 100644 index 0000000..0d17861 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/account-id.ts @@ -0,0 +1,5 @@ +export const DEFAULT_ACCOUNT_ID = "default"; + +export function normalizeAccountId(id: string): string { + return id || "default"; +} diff --git a/extensions/mattermost/src/plugin-sdk/account-resolution.ts b/extensions/mattermost/src/plugin-sdk/account-resolution.ts new file mode 100644 index 0000000..892e63f --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/account-resolution.ts @@ -0,0 +1,14 @@ +export function resolveMergedAccountConfig(params: { + channelConfig?: Record; + accounts?: Record>; + accountId: string; + omitKeys?: string[]; + nestedObjectKeys?: string[]; +}): T { + const { channelConfig = {}, accounts = {}, accountId } = params; + const accountConfig = accounts[accountId] || {}; + return { + ...channelConfig, + ...accountConfig, + } as T; +} diff --git a/extensions/mattermost/src/plugin-sdk/approval-auth-runtime.ts b/extensions/mattermost/src/plugin-sdk/approval-auth-runtime.ts new file mode 100644 index 0000000..2588312 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/approval-auth-runtime.ts @@ -0,0 +1,7 @@ +export function createResolvedApproverActionAuthAdapter() { + return {}; +} + +export function resolveApprovalApprovers() { + return []; +} diff --git a/extensions/mattermost/src/plugin-sdk/channel-policy.ts b/extensions/mattermost/src/plugin-sdk/channel-policy.ts new file mode 100644 index 0000000..ae647ae --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/channel-policy.ts @@ -0,0 +1,3 @@ +export function resolveChannelGroupRequireMention() { + return false; +} diff --git a/extensions/mattermost/src/plugin-sdk/config-runtime.ts b/extensions/mattermost/src/plugin-sdk/config-runtime.ts new file mode 100644 index 0000000..bfc9bb3 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/config-runtime.ts @@ -0,0 +1,39 @@ +export interface OpenClawConfig { + channels?: { + mattermost?: { + enabled?: boolean; + botToken?: string; + baseUrl?: string; + accounts?: Record>; + [key: string]: unknown; + }; + }; + commands?: { + useAccessGroups?: boolean; + native?: boolean | "auto"; + nativeSkills?: boolean | "auto"; + }; + [key: string]: unknown; +} + +export function resolveNativeCommandsEnabled(params: { + providerId: string; + providerSetting?: boolean | "auto"; + globalSetting?: boolean | "auto"; +}): boolean { + if (params.providerSetting === true) return true; + if (params.providerSetting === false) return false; + if (params.globalSetting === true) return true; + return false; +} + +export function resolveNativeSkillsEnabled(params: { + providerId: string; + providerSetting?: boolean | "auto"; + globalSetting?: boolean | "auto"; +}): boolean { + if (params.providerSetting === true) return true; + if (params.providerSetting === false) return false; + if (params.globalSetting === true) return true; + return false; +} diff --git a/extensions/mattermost/src/plugin-sdk/conversation-runtime.ts b/extensions/mattermost/src/plugin-sdk/conversation-runtime.ts new file mode 100644 index 0000000..d437691 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/conversation-runtime.ts @@ -0,0 +1,7 @@ +export async function readChannelAllowFromStore( + channelId: string, + env: Record, + accountId?: string +): Promise { + return []; +} diff --git a/extensions/mattermost/src/plugin-sdk/core.ts b/extensions/mattermost/src/plugin-sdk/core.ts new file mode 100644 index 0000000..ac1d252 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/core.ts @@ -0,0 +1,7 @@ +export * from "./config-runtime.js"; +export * from "./account-id.js"; +export { buildChannelConfigSchema } from "./index.js"; +export { buildDmSessionRoute } from "./index.js"; +export { buildChannelOutboundSessionRoute } from "./index.js"; +export { resolveThreadSessionKeys } from "./index.js"; +export { resolveChannelGroupRequireMention } from "./index.js"; diff --git a/extensions/mattermost/src/plugin-sdk/error-runtime.ts b/extensions/mattermost/src/plugin-sdk/error-runtime.ts new file mode 100644 index 0000000..035911f --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/error-runtime.ts @@ -0,0 +1,10 @@ +export function extractErrorCode(error: unknown): string | undefined { + if (error instanceof Error) { + return error.name; + } + return undefined; +} + +export function formatErrorMessage(error: Error): string { + return error.message; +} diff --git a/extensions/mattermost/src/plugin-sdk/index.ts b/extensions/mattermost/src/plugin-sdk/index.ts new file mode 100644 index 0000000..facd7c9 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/index.ts @@ -0,0 +1,22 @@ +export const DEFAULT_ACCOUNT_ID = "default"; +export function buildChannelConfigSchema() { + return {}; +} +export function buildDmSessionRoute() { + return {}; +} +export function buildChannelOutboundSessionRoute() { + return {}; +} +export function resolveThreadSessionKeys() { + return []; +} +export function createResolvedApproverActionAuthAdapter() { + return {}; +} +export function resolveApprovalApprovers() { + return []; +} +export function resolveChannelGroupRequireMention() { + return false; +} diff --git a/extensions/mattermost/src/plugin-sdk/reply-runtime.ts b/extensions/mattermost/src/plugin-sdk/reply-runtime.ts new file mode 100644 index 0000000..8688440 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/reply-runtime.ts @@ -0,0 +1,9 @@ +export interface ReplyPayload { + text?: string; + interactive?: { + blocks: Array<{ + type: string; + [key: string]: unknown; + }>; + }; +} diff --git a/extensions/mattermost/src/plugin-sdk/retry-runtime.ts b/extensions/mattermost/src/plugin-sdk/retry-runtime.ts new file mode 100644 index 0000000..f21ec5e --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/retry-runtime.ts @@ -0,0 +1,43 @@ +export interface RetryOptions { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + onRetry?: (info: RetryInfo) => void; +} + +export interface RetryInfo { + attempt: number; + maxRetries: number; + delayMs: number; + error: Error; +} + +export async function retryAsync( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const maxRetries = options.maxRetries ?? 3; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt >= maxRetries) { + throw lastError; + } + const delayMs = (options.initialDelayMs ?? 1000) * Math.pow(2, attempt); + const cappedDelay = Math.min(delayMs, options.maxDelayMs ?? 30000); + options.onRetry?.({ + attempt: attempt + 1, + maxRetries, + delayMs: cappedDelay, + error: lastError, + }); + await new Promise((resolve) => setTimeout(resolve, cappedDelay)); + } + } + + throw lastError ?? new Error("Retry failed"); +} diff --git a/extensions/mattermost/src/plugin-sdk/secret-input.ts b/extensions/mattermost/src/plugin-sdk/secret-input.ts new file mode 100644 index 0000000..ef661f2 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/secret-input.ts @@ -0,0 +1,13 @@ +export function normalizeSecretInputString(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim() || undefined; + } + return undefined; +} + +export function normalizeResolvedSecretInputString(params: { + value: unknown; + path: string; +}): string | undefined { + return normalizeSecretInputString(params.value); +} diff --git a/extensions/mattermost/src/plugin-sdk/setup.ts b/extensions/mattermost/src/plugin-sdk/setup.ts new file mode 100644 index 0000000..52c313a --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/setup.ts @@ -0,0 +1 @@ +export { DEFAULT_ACCOUNT_ID } from "./account-id.js"; diff --git a/extensions/mattermost/src/plugin-sdk/ssrf-runtime.ts b/extensions/mattermost/src/plugin-sdk/ssrf-runtime.ts new file mode 100644 index 0000000..ba0e524 --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/ssrf-runtime.ts @@ -0,0 +1,15 @@ +export async function fetchWithSsrFGuard(params: { + url: string; + init?: RequestInit; + auditContext?: string; + policy?: { allowPrivateNetwork?: boolean }; +}): Promise<{ + response: Response; + release: () => Promise; +}> { + const response = await fetch(params.url, params.init); + return { + response, + release: async () => {}, + }; +} diff --git a/extensions/mattermost/src/plugin-sdk/zod.ts b/extensions/mattermost/src/plugin-sdk/zod.ts new file mode 100644 index 0000000..cc4ac0b --- /dev/null +++ b/extensions/mattermost/src/plugin-sdk/zod.ts @@ -0,0 +1,19 @@ +export const z = { + string: () => ({ + nullable: () => z, + optional: () => z, + }), + number: () => ({ + nullable: () => z, + optional: () => z, + }), + array: () => ({ + nullable: () => z, + optional: () => z, + }), + object: (schema: Record) => ({ + passthrough: () => z, + }), + record: () => z, + infer: () => ({} as any), +}; diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts new file mode 100644 index 0000000..ece7358 --- /dev/null +++ b/extensions/mattermost/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts new file mode 100644 index 0000000..1fb88e0 --- /dev/null +++ b/extensions/mattermost/src/runtime.ts @@ -0,0 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "./runtime-api.js"; + +const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = + createPluginRuntimeStore("Mattermost runtime not initialized"); +export { getMattermostRuntime, setMattermostRuntime }; diff --git a/extensions/mattermost/src/secret-contract.ts b/extensions/mattermost/src/secret-contract.ts new file mode 100644 index 0000000..6249e08 --- /dev/null +++ b/extensions/mattermost/src/secret-contract.ts @@ -0,0 +1,54 @@ +import { + collectSimpleChannelFieldAssignments, + getChannelSurface, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + 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, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + 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.", + }); +} diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts new file mode 100644 index 0000000..d8d7aaf --- /dev/null +++ b/extensions/mattermost/src/secret-input.ts @@ -0,0 +1,7 @@ +export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/mattermost/src/security-audit.ts b/extensions/mattermost/src/security-audit.ts new file mode 100644 index 0000000..fc624ff --- /dev/null +++ b/extensions/mattermost/src/security-audit.ts @@ -0,0 +1,385 @@ +import { + resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, +} from "openclaw/plugin-sdk/config-runtime"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +import type { ResolvedMattermostAccount } from "./mattermost/accounts.js"; +import type { MattermostAccountConfig, MattermostConfig } from "./types.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +export type SecurityAuditFinding = { + checkId: string; + severity: "info" | "warn" | "critical"; + title: string; + detail: string; + remediation?: string; +}; + +function normalizeAllowFromList(list: Array | undefined | null): string[] { + if (!Array.isArray(list)) { + return []; + } + return list.map((value) => String(value).trim()).filter(Boolean); +} + +function coerceNativeSetting(value: unknown): boolean | "auto" | undefined { + if (value === true || value === false || value === "auto") { + return value; + } + return undefined; +} + +function isHttpsUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +function isValidMattermostId(id: string): boolean { + // Mattermost IDs are 26-character alphanumeric strings + const normalized = id + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .trim() + .toLowerCase(); + return /^[a-z0-9]{26}$/.test(normalized); +} + +function isMutableAllowEntry(raw: string): boolean { + const text = raw.trim(); + if (!text || text === "*") { + return false; + } + if (isValidMattermostId(text)) { + return false; + } + return true; +} + +function hasEnvVarReference(value: string | undefined): boolean { + if (!value) return false; + // Check for common env var patterns: $VAR, ${VAR}, %VAR% + return /\$\w+|\$\{[^}]+\}|%[^%]+%/.test(value); +} + +function hasHardcodedSecret(value: string | undefined): boolean { + if (!value) return false; + // Mattermost tokens are typically 26+ character alphanumeric + // Pattern: looks like a token but not an env reference + const trimmed = value.trim(); + if (trimmed.length < 20) return false; + // Check if it looks like a token (alphanumeric with possible hyphens/underscores) + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) return false; + // Check for common env var patterns that would indicate it's not hardcoded + if (hasEnvVarReference(trimmed)) return false; + return true; +} + +function sanitizeForLog(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +/** + * Collect security audit findings for Mattermost configuration. + * This function performs comprehensive security checks including: + * - PAT (Personal Access Token) security (env var usage, no hardcoding) + * - HTTPS enforcement verification + * - Input validation audit + * - File download restrictions check + * - Token permission checks + * - Config migration detection + * - Warnings for insecure configurations + */ +export async function collectMattermostSecurityAuditFindings(params: { + cfg: OpenClawConfig; + accountId?: string | null; + account: ResolvedMattermostAccount; +}): Promise { + const findings: SecurityAuditFinding[] = []; + const mmCfg = params.account.config ?? {}; + const accountId = params.accountId?.trim() || params.account.accountId || "default"; + + // ===== 1. PAT SECURITY CHECKS ===== + + // Check 1.1: Bot token should use environment variables + if (params.account.botToken && params.account.botTokenSource === "config") { + const botToken = params.account.botToken; + if (hasHardcodedSecret(botToken)) { + findings.push({ + checkId: "channels.mattermost.pat.hardcoded_token", + severity: "critical", + title: "Hardcoded Mattermost bot token detected", + detail: `The bot token for account "${accountId}" appears to be hardcoded in configuration. Hardcoded credentials pose a security risk and may be exposed in version control or logs.`, + remediation: "Move the token to the MATTERMOST_BOT_TOKEN environment variable or use a secret management system.", + }); + } else if (botToken.length < 20) { + findings.push({ + checkId: "channels.mattermost.pat.short_token", + severity: "warn", + title: "Short Mattermost bot token detected", + detail: `The bot token for account "${accountId}" appears unusually short (${botToken.length} chars). This may indicate an invalid or incomplete token.`, + remediation: "Verify the bot token is complete and valid.", + }); + } + } + + // Check 1.2: No bot token configured + if (!params.account.botToken) { + findings.push({ + checkId: "channels.mattermost.pat.missing_token", + severity: "critical", + title: "No Mattermost bot token configured", + detail: `Account "${accountId}" has no bot token configured. The bot will not be able to authenticate with the Mattermost server.`, + remediation: "Set the MATTERMOST_BOT_TOKEN environment variable or configure channels.mattermost.botToken.", + }); + } + + // ===== 2. HTTPS ENFORCEMENT CHECK ===== + + // Check 2.1: Base URL should use HTTPS + if (params.account.baseUrl) { + if (!isHttpsUrl(params.account.baseUrl)) { + findings.push({ + checkId: "channels.mattermost.https.insecure_url", + severity: "critical", + title: "Mattermost base URL uses HTTP (insecure)", + detail: `The base URL "${sanitizeForLog(params.account.baseUrl)}" for account "${accountId}" uses HTTP instead of HTTPS. This exposes all communications (including authentication tokens) to interception.`, + remediation: "Change the baseUrl to use HTTPS, e.g., https://chat.example.com", + }); + } + } else { + findings.push({ + checkId: "channels.mattermost.https.missing_url", + severity: "critical", + title: "No Mattermost base URL configured", + detail: `Account "${accountId}" has no base URL configured. The bot will not be able to connect to the Mattermost server.`, + remediation: "Set the MATTERMOST_URL environment variable or configure channels.mattermost.baseUrl with a HTTPS URL.", + }); + } + + // ===== 3. INPUT VALIDATION AUDIT ===== + + // Check 3.1: Validate interaction callback base URL + const interactions = mmCfg.interactions as { callbackBaseUrl?: string; allowedSourceIps?: string[] } | undefined; + if (interactions?.callbackBaseUrl) { + if (!isHttpsUrl(interactions.callbackBaseUrl)) { + findings.push({ + checkId: "channels.mattermost.input.insecure_callback", + severity: "critical", + title: "Mattermost interaction callback uses HTTP", + detail: `The interaction callbackBaseUrl "${sanitizeForLog(interactions.callbackBaseUrl)}" uses HTTP. This exposes interaction payloads to interception.`, + remediation: "Configure interaction.callbackBaseUrl to use HTTPS.", + }); + } + + // Check for allowed source IPs + if (!interactions.allowedSourceIps || interactions.allowedSourceIps.length === 0) { + findings.push({ + checkId: "channels.mattermost.input.no_source_ip_restriction", + severity: "warn", + title: "No IP restrictions on Mattermost interaction callbacks", + detail: "Interaction callbacks have no source IP allowlist configured. This may allow spoofed requests from unauthorized sources.", + remediation: "Configure interactions.allowedSourceIps to restrict callback sources to your Mattermost server IP/CIDR.", + }); + } + } + + // ===== 4. FILE DOWNLOAD RESTRICTIONS CHECK ===== + + // Check 4.1: Private network access + if (mmCfg.allowPrivateNetwork === true) { + findings.push({ + checkId: "channels.mattermost.network.private_access_enabled", + severity: "warn", + title: "Private network access enabled for Mattermost", + detail: "The allowPrivateNetwork setting is enabled, allowing the bot to fetch from private/internal IP addresses. This is required for self-hosted Mattermost but increases attack surface.", + remediation: "If not using self-hosted Mattermost on LAN/VPN, set allowPrivateNetwork to false.", + }); + } + + // ===== 5. TOKEN PERMISSION CHECKS ===== + + // Check 5.1: Slash command security + const nativeEnabled = resolveNativeCommandsEnabled({ + providerId: "mattermost", + providerSetting: coerceNativeSetting( + (mmCfg.commands as { native?: unknown } | undefined)?.native, + ), + globalSetting: params.cfg.commands?.native, + }); + const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + providerId: "mattermost", + providerSetting: coerceNativeSetting( + (mmCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills, + ), + globalSetting: params.cfg.commands?.nativeSkills, + }); + const slashCommandEnabled = + nativeEnabled || + nativeSkillsEnabled || + (mmCfg.commands as { enabled?: unknown } | undefined)?.enabled === true; + + if (slashCommandEnabled) { + const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + if (!useAccessGroups) { + findings.push({ + checkId: "channels.mattermost.commands.access_groups_disabled", + severity: "critical", + title: "Mattermost slash commands bypass access groups", + detail: "Mattermost slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted command execution from channels/users you didn't explicitly authorize.", + remediation: "Set commands.useAccessGroups=true (recommended).", + }); + } + + // Check for allowlists on slash commands + const allowFromRaw = mmCfg.allowFrom; + // eslint-disable-next-line no-process-env + const storeAllowFrom = await readChannelAllowFromStore("mattermost", process.env, accountId).catch( + () => [], + ); + const ownerAllowFromConfigured = + normalizeAllowFromList([...(allowFromRaw || []), ...storeAllowFrom]).length > 0; + + if (!ownerAllowFromConfigured) { + findings.push({ + checkId: "channels.mattermost.commands.no_allowlist", + severity: "warn", + title: "Mattermost slash commands have no allowlist", + detail: "Mattermost slash/native commands are enabled, but no owner allowFrom list is configured; commands may be rejected for everyone or accepted from anyone depending on dmPolicy.", + remediation: "Configure channels.mattermost.allowFrom with authorized user IDs, or use pairing to approve users.", + }); + } + } + + // ===== 6. CONFIG MIGRATION DETECTION ===== + + // Check 6.1: Legacy dmPolicy migration needed + const legacyDmPolicy = (mmCfg as { dm?: { policy?: string } }).dm?.policy; + if (legacyDmPolicy !== undefined && mmCfg.dmPolicy === undefined) { + findings.push({ + checkId: "channels.mattermost.config.legacy_dm_policy", + severity: "info", + title: "Legacy dm.policy configuration detected", + detail: "The configuration uses the legacy dm.policy setting which should be migrated to dmPolicy.", + remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.", + }); + } + + // Check 6.2: Legacy allowFrom migration needed + const legacyAllowFrom = (mmCfg as { dm?: { allowFrom?: unknown[] } }).dm?.allowFrom; + if (legacyAllowFrom !== undefined && mmCfg.allowFrom === undefined) { + findings.push({ + checkId: "channels.mattermost.config.legacy_allow_from", + severity: "info", + title: "Legacy dm.allowFrom configuration detected", + detail: "The configuration uses the legacy dm.allowFrom setting which should be migrated to top-level allowFrom.", + remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.", + }); + } + + // ===== 7. MUTABLE ALLOWLIST WARNINGS ===== + + // Check 7.1: Mutable entries in allowFrom without dangerous name matching + const allowFromList = mmCfg.allowFrom || []; + const mutableAllowEntries = normalizeAllowFromList(allowFromList).filter(isMutableAllowEntry); + const dangerousNameMatchingEnabled = mmCfg.dangerouslyAllowNameMatching === true; + + if (mutableAllowEntries.length > 0 && !dangerousNameMatchingEnabled) { + const entriesPreview = mutableAllowEntries.slice(0, 3).join(", "); + const moreCount = mutableAllowEntries.length > 3 ? ` (+${mutableAllowEntries.length - 3} more)` : ""; + findings.push({ + checkId: "channels.mattermost.allowlist.mutable_entries", + severity: "warn", + title: "Mutable allowlist entries detected", + detail: `Found ${mutableAllowEntries.length} mutable (non-ID) entries in allowFrom: "${entriesPreview}${moreCount}". These entries match by username/display name which can change, potentially allowing unauthorized access.`, + remediation: "Option A: Enable channels.mattermost.dangerouslyAllowNameMatching=true as a break-glass measure. Option B: Resolve names to stable Mattermost IDs and update the allowlist entries.", + }); + } + + // ===== 8. ACTION PERMISSIONS CHECK ===== + + const actions = mmCfg.actions || {}; + + // Check 8.1: Delete action enabled + const deleteEnabled = actions.delete ?? true; + if (deleteEnabled !== false) { + findings.push({ + checkId: "channels.mattermost.actions.delete_enabled", + severity: "info", + title: "Message delete action is enabled", + detail: "The delete action is enabled, allowing the bot to delete messages. This is generally safe but increases the attack surface.", + remediation: "If message deletion is not needed, disable with channels.mattermost.actions.delete=false.", + }); + } + + // ===== 9. GROUP POLICY SECURITY ===== + + const groupPolicy = mmCfg.groupPolicy; + if (groupPolicy === "open") { + findings.push({ + checkId: "channels.mattermost.groups.open_policy", + severity: "warn", + title: "Open group policy configured", + detail: "The groupPolicy is set to 'open', allowing any Mattermost user to interact with the bot in group channels. This increases the attack surface.", + remediation: "Consider setting groupPolicy to 'allowlist' and configuring groupAllowFrom with authorized users.", + }); + } + + // ===== 10. DM POLICY SECURITY ===== + + const dmPolicy = mmCfg.dmPolicy; + if (dmPolicy === "open") { + findings.push({ + checkId: "channels.mattermost.dm.open_policy", + severity: "warn", + title: "Open DM policy configured", + detail: "The dmPolicy is set to 'open', allowing any Mattermost user to send direct messages to the bot. This increases the attack surface.", + remediation: "Consider setting dmPolicy to 'allowlist' or 'pairing' to restrict DM access.", + }); + } else if (dmPolicy === "pairing") { + findings.push({ + checkId: "channels.mattermost.dm.pairing_mode", + severity: "info", + title: "DM pairing mode enabled", + detail: "The dmPolicy is set to 'pairing', requiring users to explicitly approve access before sending DMs to the bot. This is the recommended secure configuration.", + }); + } + + return findings; +} + +/** + * Check if an allowlist entry is mutable (uses name/username instead of stable ID). + * Exported for use in doctor.ts + */ +export function isMattermostMutableAllowEntry(raw: string): boolean { + return isMutableAllowEntry(raw); +} + +/** + * Collect security audit findings for all enabled Mattermost accounts. + */ +export async function collectAllMattermostSecurityAuditFindings(params: { + cfg: OpenClawConfig; + listAccounts: (cfg: OpenClawConfig) => ResolvedMattermostAccount[]; +}): Promise { + const accounts = params.listAccounts(params.cfg); + const allFindings: SecurityAuditFinding[] = []; + + for (const account of accounts) { + if (!account.enabled) continue; + + const findings = await collectMattermostSecurityAuditFindings({ + cfg: params.cfg, + accountId: account.accountId, + account, + }); + + allFindings.push(...findings); + } + + return allFindings; +} diff --git a/extensions/mattermost/src/session-route.test.ts b/extensions/mattermost/src/session-route.test.ts new file mode 100644 index 0000000..3ac96f7 --- /dev/null +++ b/extensions/mattermost/src/session-route.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; + +describe("mattermost session route", () => { + it("builds direct-message routes for user targets", () => { + const route = resolveMattermostOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "acct-1", + target: "@user123", + }); + + expect(route).toMatchObject({ + peer: { + kind: "direct", + id: "user123", + }, + from: "mattermost:user123", + to: "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", + }); + + expect(route).toMatchObject({ + peer: { + kind: "channel", + id: "chan123", + }, + from: "mattermost:channel:chan123", + to: "channel:chan123", + threadId: "thread456", + }); + expect(route?.sessionKey).toContain("thread456"); + }); + + it("returns null when the target is empty after normalization", () => { + expect( + resolveMattermostOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "acct-1", + target: "mattermost:", + }), + ).toBeNull(); + }); +}); diff --git a/extensions/mattermost/src/session-route.ts b/extensions/mattermost/src/session-route.ts new file mode 100644 index 0000000..39f12e3 --- /dev/null +++ b/extensions/mattermost/src/session-route.ts @@ -0,0 +1,52 @@ +import { + buildChannelOutboundSessionRoute, + resolveThreadSessionKeys, + stripChannelTargetPrefix, + stripTargetKindPrefix, + type ChannelOutboundSessionRouteParams, +} from "openclaw/plugin-sdk/core"; +import { normalizeOutboundThreadId } from "openclaw/plugin-sdk/routing"; + +export function resolveMattermostOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { + let trimmed = stripChannelTargetPrefix(params.target, "mattermost"); + if (!trimmed) { + return null; + } + const lower = trimmed.toLowerCase(); + const resolvedKind = params.resolvedTarget?.kind; + const isUser = + resolvedKind === "user" || + (resolvedKind !== "channel" && + resolvedKind !== "group" && + (lower.startsWith("user:") || trimmed.startsWith("@"))); + if (trimmed.startsWith("@")) { + trimmed = trimmed.slice(1).trim(); + } + const rawId = stripTargetKindPrefix(trimmed); + if (!rawId) { + return null; + } + const baseRoute = buildChannelOutboundSessionRoute({ + cfg: params.cfg, + agentId: params.agentId, + channel: "mattermost", + accountId: params.accountId, + peer: { + kind: isUser ? "direct" : "channel", + id: rawId, + }, + chatType: isUser ? "direct" : "channel", + from: isUser ? `mattermost:${rawId}` : `mattermost:channel:${rawId}`, + to: isUser ? `user:${rawId}` : `channel:${rawId}`, + }); + const threadId = normalizeOutboundThreadId(params.replyToId ?? params.threadId); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey: baseRoute.baseSessionKey, + threadId, + }); + return { + ...baseRoute, + sessionKey: threadKeys.sessionKey, + ...(threadId !== undefined ? { threadId } : {}), + }; +} diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts new file mode 100644 index 0000000..77a0154 --- /dev/null +++ b/extensions/mattermost/src/setup-core.ts @@ -0,0 +1,95 @@ +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; +import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + type OpenClawConfig, +} from "./runtime-api.js"; +import { + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./setup.accounts.runtime.js"; +import { normalizeMattermostBaseUrl } from "./setup.client.runtime.js"; +import { hasConfiguredSecretInput } from "./setup.secret-input.runtime.js"; + +const channel = "mattermost" as const; + +export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { + const tokenConfigured = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + return tokenConfigured && Boolean(account.baseUrl); +} + +export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { + return resolveMattermostAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); +} + +export const mattermostSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: createSetupInputPresenceValidator({ + defaultAccountOnlyEnvError: "Mattermost env vars can only be used for the default account.", + whenNotUseEnv: [ + { + someOf: ["botToken", "token"], + message: "Mattermost requires --bot-token and --http-url (or --use-env).", + }, + { + someOf: ["httpUrl"], + message: "Mattermost requires --bot-token and --http-url (or --use-env).", + }, + ], + validate: ({ accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (input.httpUrl && !baseUrl) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }); + }, +}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts new file mode 100644 index 0000000..e6de03f --- /dev/null +++ b/extensions/mattermost/src/setup-surface.ts @@ -0,0 +1,128 @@ +import { + createStandardChannelSetupStatus, + formatDocsLink, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; +import { + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + type OpenClawConfig, +} from "./runtime-api.js"; +import { + isMattermostConfigured, + mattermostSetupAdapter, + resolveMattermostAccountWithSecrets, +} from "./setup-core.js"; +import { listMattermostAccountIds } from "./setup.accounts.runtime.js"; +import { normalizeMattermostBaseUrl } from "./setup.client.runtime.js"; +import { hasConfiguredSecretInput } from "./setup.secret-input.runtime.js"; + +const channel = "mattermost" as const; +export { mattermostSetupAdapter } from "./setup-core.js"; + +export const mattermostSetupWizard: ChannelSetupWizard = { + channel, + status: createStandardChannelSetupStatus({ + channelLabel: "Mattermost", + configuredLabel: "configured", + unconfiguredLabel: "needs token + url", + configuredHint: "configured", + unconfiguredHint: "needs setup", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg, accountId }) => + isMattermostConfigured( + resolveMattermostAccountWithSecrets(cfg, accountId ?? DEFAULT_ACCOUNT_ID), + ), + }), + introNote: { + title: "Mattermost bot token", + lines: [ + "1) Mattermost System Console -> Integrations -> Bot Accounts", + "2) Create a bot + copy its token", + "3) Use your server base URL (e.g., https://chat.example.com)", + "Tip: the bot must be a member of any channel you want it to monitor.", + `Docs: ${formatDocsLink("/mattermost", "mattermost")}`, + ], + shouldShow: ({ cfg, accountId }) => + !isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)), + }, + envShortcut: { + prompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", + preferredEnvVar: "MATTERMOST_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => { + if (accountId !== DEFAULT_ACCOUNT_ID) { + return false; + } + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + const hasConfigValues = + hasConfiguredSecretInput(resolvedAccount.config.botToken) || + Boolean(resolvedAccount.config.baseUrl?.trim()); + return Boolean( + process.env.MATTERMOST_BOT_TOKEN?.trim() && + process.env.MATTERMOST_URL?.trim() && + !hasConfigValues, + ); + }, + apply: ({ cfg, accountId }) => + applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: {}, + }), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: channel, + credentialLabel: "bot token", + preferredEnvVar: "MATTERMOST_BOT_TOKEN", + envPrompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", + keepPrompt: "Mattermost bot token already configured. Keep it?", + inputPrompt: "Enter Mattermost bot token", + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + return { + accountConfigured: isMattermostConfigured(resolvedAccount), + hasConfiguredValue: hasConfiguredSecretInput(resolvedAccount.config.botToken), + }; + }, + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "Enter Mattermost base URL", + confirmCurrentValue: false, + currentValue: ({ cfg, accountId }) => + resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ?? + process.env.MATTERMOST_URL?.trim(), + initialValue: ({ cfg, accountId }) => + resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ?? + process.env.MATTERMOST_URL?.trim(), + shouldPrompt: ({ cfg, accountId, credentialValues, currentValue }) => { + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + const tokenConfigured = + Boolean(resolvedAccount.botToken?.trim()) || + hasConfiguredSecretInput(resolvedAccount.config.botToken); + return Boolean(credentialValues.botToken) || !tokenConfigured || !currentValue; + }, + validate: ({ value }) => + normalizeMattermostBaseUrl(value) + ? undefined + : "Mattermost base URL must include a valid base URL.", + normalizeValue: ({ value }) => normalizeMattermostBaseUrl(value) ?? value.trim(), + }, + ], + disable: (cfg: OpenClawConfig) => ({ + ...cfg, + channels: { + ...cfg.channels, + mattermost: { + ...cfg.channels?.mattermost, + enabled: false, + }, + }, + }), +}; diff --git a/extensions/mattermost/src/setup.accounts.runtime.ts b/extensions/mattermost/src/setup.accounts.runtime.ts new file mode 100644 index 0000000..f1bec12 --- /dev/null +++ b/extensions/mattermost/src/setup.accounts.runtime.ts @@ -0,0 +1,5 @@ +export { + listMattermostAccountIds, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; diff --git a/extensions/mattermost/src/setup.client.runtime.ts b/extensions/mattermost/src/setup.client.runtime.ts new file mode 100644 index 0000000..5d43760 --- /dev/null +++ b/extensions/mattermost/src/setup.client.runtime.ts @@ -0,0 +1 @@ +export { normalizeMattermostBaseUrl } from "./mattermost/client.js"; diff --git a/extensions/mattermost/src/setup.secret-input.runtime.ts b/extensions/mattermost/src/setup.secret-input.runtime.ts new file mode 100644 index 0000000..f37a304 --- /dev/null +++ b/extensions/mattermost/src/setup.secret-input.runtime.ts @@ -0,0 +1 @@ +export { hasConfiguredSecretInput } from "./secret-input.js"; diff --git a/extensions/mattermost/src/setup.test.ts b/extensions/mattermost/src/setup.test.ts new file mode 100644 index 0000000..2bf6303 --- /dev/null +++ b/extensions/mattermost/src/setup.test.ts @@ -0,0 +1,356 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js"; +import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js"; + +vi.mock("../../../src/config/bundled-channel-config-runtime.js", () => ({ + getBundledChannelRuntimeMap: () => new Map(), + getBundledChannelConfigSchemaMap: () => new Map(), +})); + +const resolveMattermostAccount = vi.hoisted(() => vi.fn()); +const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value)); +const hasConfiguredSecretInput = vi.hoisted(() => vi.fn((value: unknown) => Boolean(value))); + +vi.mock("./setup.accounts.runtime.js", () => ({ + listMattermostAccountIds: vi.fn((cfg: OpenClawConfig) => { + const accounts = cfg.channels?.mattermost?.accounts; + const ids = accounts ? Object.keys(accounts) : []; + return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID]; + }), + resolveMattermostAccount: (params: Parameters[0]) => { + const mocked = resolveMattermostAccount(params); + return ( + mocked ?? { + accountId: params.accountId ?? DEFAULT_ACCOUNT_ID, + enabled: params.cfg.channels?.mattermost?.enabled !== false, + botToken: + typeof params.cfg.channels?.mattermost?.botToken === "string" + ? params.cfg.channels.mattermost.botToken + : undefined, + baseUrl: normalizeMattermostBaseUrl(params.cfg.channels?.mattermost?.baseUrl), + botTokenSource: + typeof params.cfg.channels?.mattermost?.botToken === "string" ? "config" : "none", + baseUrlSource: params.cfg.channels?.mattermost?.baseUrl ? "config" : "none", + config: params.cfg.channels?.mattermost ?? {}, + } + ); + }, +})); + +vi.mock("./setup.client.runtime.js", () => ({ + normalizeMattermostBaseUrl, +})); + +vi.mock("./setup.secret-input.runtime.js", () => ({ + hasConfiguredSecretInput, +})); + +function createApi( + registrationMode: OpenClawPluginApi["registrationMode"], + registerHttpRoute = vi.fn(), +): OpenClawPluginApi { + return createTestPluginApi({ + id: "mattermost", + name: "Mattermost", + source: "test", + config: {}, + runtime: {} as OpenClawPluginApi["runtime"], + registrationMode, + registerHttpRoute, + }); +} + +let plugin: typeof import("../index.js").default; +let mattermostSetupWizard: typeof import("./setup-surface.js").mattermostSetupWizard; +let isMattermostConfigured: typeof import("./setup-core.js").isMattermostConfigured; +let resolveMattermostAccountWithSecrets: typeof import("./setup-core.js").resolveMattermostAccountWithSecrets; +let mattermostSetupAdapter: typeof import("./setup-core.js").mattermostSetupAdapter; + +describe("mattermost setup", () => { + beforeAll(async () => { + ({ default: plugin } = await import("../index.js")); + ({ mattermostSetupWizard } = await import("./setup-surface.js")); + ({ isMattermostConfigured, resolveMattermostAccountWithSecrets, mattermostSetupAdapter } = + await import("./setup-core.js")); + }); + + beforeEach(() => { + registerEnvDefaults(); + }); + + afterEach(() => { + resolveMattermostAccount.mockReset(); + normalizeMattermostBaseUrl.mockReset(); + normalizeMattermostBaseUrl.mockImplementation((value: string | undefined) => value); + hasConfiguredSecretInput.mockReset(); + hasConfiguredSecretInput.mockImplementation((value: unknown) => Boolean(value)); + vi.unstubAllEnvs(); + }); + + it("reports configuration only when token and base url are both present", () => { + expect( + isMattermostConfigured({ + // pragma: allowlist secret + botToken: "bot-token", + baseUrl: "https://chat.example.com", + config: {}, + } as never), + ).toBe(true); + + expect( + isMattermostConfigured({ + botToken: "", + baseUrl: "https://chat.example.com", + config: { botToken: "secret-ref" }, + } as never), + ).toBe(true); + + expect( + isMattermostConfigured({ + botToken: "", + baseUrl: "", + config: {}, + } as never), + ).toBe(false); + }); + + it("resolves accounts with unresolved secret refs allowed", () => { + resolveMattermostAccount.mockReturnValue({ accountId: "default" }); + + const cfg = { channels: { mattermost: {} } }; + + expect(resolveMattermostAccountWithSecrets(cfg as never, "default")).toEqual({ + accountId: "default", + }); + expect(resolveMattermostAccount).toHaveBeenCalledWith({ + cfg, + accountId: "default", + allowUnresolvedSecretRef: true, + }); + }); + + it("validates env and explicit credential requirements", () => { + const validateInput = mattermostSetupAdapter.validateInput; + expect(validateInput).toBeTypeOf("function"); + + expect( + validateInput!({ + accountId: "secondary", + input: { useEnv: true }, + } as never), + ).toBe("Mattermost env vars can only be used for the default account."); + + normalizeMattermostBaseUrl.mockReturnValue(undefined); + expect( + validateInput!({ + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: false, botToken: "tok", httpUrl: "not-a-url" }, + } as never), + ).toBe("Mattermost requires --bot-token and --http-url (or --use-env)."); + + normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com"); + expect( + validateInput!({ + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: false, botToken: "tok", httpUrl: "https://chat.example.com" }, + } as never), + ).toBeNull(); + }); + + it("applies normalized config for default and named accounts", () => { + normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com"); + const applyAccountConfig = mattermostSetupAdapter.applyAccountConfig; + expect(applyAccountConfig).toBeTypeOf("function"); + + expect( + applyAccountConfig!({ + cfg: { channels: { mattermost: {} } }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + name: "Default", + botToken: "tok", + httpUrl: "https://chat.example.com", + }, + } as never), + ).toEqual({ + channels: { + mattermost: { + enabled: true, + name: "Default", + botToken: "tok", + baseUrl: "https://chat.example.com", + }, + }, + }); + + expect( + applyAccountConfig!({ + cfg: { + channels: { + mattermost: { + name: "Legacy", + }, + }, + }, + accountId: "Work Team", + input: { + name: "Work", + botToken: "tok2", + httpUrl: "https://chat.example.com", + }, + } as never), + ).toMatchObject({ + channels: { + mattermost: { + accounts: { + default: { name: "Legacy" }, + "work-team": { + enabled: true, + name: "Work", + botToken: "tok2", + baseUrl: "https://chat.example.com", + }, + }, + }, + }, + }); + }); + + it.each([ + { name: "skips slash callback registration in setup-only mode", mode: "setup-only" as const }, + { name: "registers slash callback routes in full mode", mode: "full" as const }, + ])("$name", ({ mode }) => { + const registerHttpRoute = vi.fn(); + + plugin.register(createApi(mode, registerHttpRoute)); + + if (mode === "setup-only") { + expect(registerHttpRoute).not.toHaveBeenCalled(); + return; + } + + expect(registerHttpRoute).toHaveBeenCalledTimes(1); + expect(registerHttpRoute).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/channels/mattermost/command", + auth: "plugin", + }), + ); + }); + + it("treats secret-ref tokens plus base url as configured", async () => { + const configured = await mattermostSetupWizard.status.resolveConfigured({ + cfg: { + channels: { + mattermost: { + baseUrl: "https://chat.example.com", + botToken: { + source: "env", + provider: "default", + id: "MATTERMOST_BOT_TOKEN", + }, + }, + }, + } as OpenClawConfig, + }); + + expect(configured).toBe(true); + }); + + it("does not inherit configured state from a sibling when defaultAccount is named", async () => { + const configured = await mattermostSetupWizard.status.resolveConfigured({ + cfg: { + channels: { + mattermost: { + defaultAccount: "work", + accounts: { + alerts: { + baseUrl: "https://chat.example.com", + botToken: { + source: "env", + provider: "default", + id: "MATTERMOST_BOT_TOKEN", + }, + }, + work: {}, + }, + }, + }, + } as OpenClawConfig, + accountId: undefined, + }); + + expect(configured).toBe(false); + }); + + it("shows intro note only when the target account is not configured", () => { + expect( + mattermostSetupWizard.introNote?.shouldShow?.({ + cfg: { + channels: { + mattermost: {}, + }, + } as OpenClawConfig, + accountId: "default", + } as never), + ).toBe(true); + + expect( + mattermostSetupWizard.introNote?.shouldShow?.({ + cfg: { + channels: { + mattermost: { + baseUrl: "https://chat.example.com", + botToken: { + source: "env", + provider: "default", + id: "MATTERMOST_BOT_TOKEN", + }, + }, + }, + } as OpenClawConfig, + accountId: "default", + } as never), + ).toBe(false); + }); + + it("offers env shortcut only for the default account when env is present and config is empty", () => { + // pragma: allowlist secret + vi.stubEnv("MATTERMOST_BOT_TOKEN", "bot-token"); + vi.stubEnv("MATTERMOST_URL", "https://chat.example.com"); + + expect( + mattermostSetupWizard.envShortcut?.isAvailable?.({ + cfg: { channels: { mattermost: {} } } as OpenClawConfig, + accountId: "default", + } as never), + ).toBe(true); + + expect( + mattermostSetupWizard.envShortcut?.isAvailable?.({ + cfg: { channels: { mattermost: {} } } as OpenClawConfig, + accountId: "work", + } as never), + ).toBe(false); + }); + + it("keeps env shortcut as a no-op patch for the selected account", () => { + expect( + mattermostSetupWizard.envShortcut?.apply?.({ + cfg: { channels: { mattermost: { enabled: false } } } as OpenClawConfig, + accountId: "default", + } as never), + ).toEqual({ + channels: { + mattermost: { + enabled: true, + }, + }, + }); + }); +}); + +function registerEnvDefaults() { + vi.unstubAllEnvs(); +} diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts new file mode 100644 index 0000000..6fbbfaf --- /dev/null +++ b/extensions/mattermost/src/types.ts @@ -0,0 +1,111 @@ +import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./runtime-api.js"; +import type { SecretInput } from "./secret-input.js"; + +export type MattermostReplyToMode = "off" | "first" | "all"; +export type MattermostChatTypeKey = "direct" | "channel" | "group"; + +export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; + +export type MattermostAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** + * Break-glass override: allow mutable identity matching (@username/display name) in allowlists. + * Default behavior is ID-only matching. + */ + dangerouslyAllowNameMatching?: boolean; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this Mattermost account. Default: true. */ + enabled?: boolean; + /** Bot token for Mattermost. */ + botToken?: SecretInput; + /** Personal Access Token (PAT) for write operations. Falls back to botToken if not set. */ + pat?: SecretInput; + /** Base URL for the Mattermost server (e.g., https://chat.example.com). */ + baseUrl?: string; + /** + * Controls when channel messages trigger replies. + * - "oncall": only respond when mentioned + * - "onmessage": respond to every channel message + * - "onchar": respond when a trigger character prefixes the message + */ + chatmode?: MattermostChatMode; + /** Prefix characters that trigger onchar mode (default: [">", "!"]). */ + oncharPrefixes?: string[]; + /** Require @mention to respond in channels. Default: true. */ + requireMention?: boolean; + /** Direct message policy (pairing/allowlist/open/disabled). */ + dmPolicy?: DmPolicy; + /** Allowlist for direct messages (user ids or @usernames). */ + allowFrom?: Array; + /** Allowlist for group messages (user ids or @usernames). */ + groupAllowFrom?: Array; + /** Group message policy (allowlist/open/disabled). */ + groupPolicy?: GroupPolicy; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; + /** + * Controls whether channel and group replies are sent as thread replies. + * - "off" (default): only thread-reply when incoming message is already a thread reply + * - "first": reply in a thread under the triggering message + * - "all": always reply in a thread; uses existing thread root or starts a new thread under the message + * Direct messages always behave as "off". + */ + replyToMode?: MattermostReplyToMode; + actions?: { + reactions?: boolean; + edit?: boolean; + delete?: boolean; + }; + /** Native slash command configuration. */ + commands?: { + /** Enable native slash commands. "auto" resolves to false (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 (e.g. behind reverse proxy). */ + callbackUrl?: string; + }; + interactions?: { + /** External base URL used for Mattermost interaction callbacks. */ + callbackBaseUrl?: string; + /** + * IP/CIDR allowlist for callback request sources when Mattermost reaches the gateway + * over a non-loopback path. Keep this narrow to the Mattermost server or trusted ingress. + */ + allowedSourceIps?: string[]; + }; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ + allowPrivateNetwork?: boolean; + /** Retry configuration for DM channel creation */ + dmChannelRetry?: { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds between retries (default: 10000) */ + maxDelayMs?: number; + /** Timeout for each individual request in milliseconds (default: 30000) */ + timeoutMs?: number; + }; +}; + +export type MattermostConfig = { + /** Optional per-account Mattermost configuration (multi-account). */ + accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; +} & MattermostAccountConfig; diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..31f454f --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,49 @@ +{ + "id": "mattermost", + "type": "channel", + "channels": ["mattermost"], + "configSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "baseUrl": { + "type": "string", + "format": "uri", + "description": "Mattermost server URL" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Mattermost server URL (alternative to baseUrl)" + }, + "botToken": { + "type": "string", + "description": "Bot account token" + }, + "token": { + "type": "string", + "description": "API access token (alternative to botToken)" + }, + "pat": { + "type": "string", + "description": "Personal Access Token for write operations" + }, + "team": { + "type": "string", + "description": "Default team name" + }, + "enabled": { + "type": "boolean", + "description": "Enable/disable this channel" + }, + "defaultAccount": { + "type": "string", + "description": "Default account ID" + }, + "accounts": { + "type": "object", + "description": "Account configurations" + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dd866b3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3590 @@ +{ + "name": "@lumbridgecorp/openclaw-mattermost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lumbridgecorp/openclaw-mattermost", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@sinclair/typebox": "0.34.49", + "ws": "^8.20.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", + "@types/node": "^22.0.0", + "@types/ws": "^8.5.10", + "@typescript-eslint/eslint-plugin": "^8.58.1", + "@typescript-eslint/parser": "^8.58.1", + "@vitest/coverage-v8": "^1.5.0", + "eslint": "^10.2.0", + "openclaw": "file:/home/metal/.npm-global/lib/node_modules/openclaw", + "typescript": "^5.4.0", + "vitest": "^1.5.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "openclaw": ">=2026.4.9" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": false + } + } + }, + "../../../.npm-global/lib/node_modules/openclaw": { + "version": "2026.4.9", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "0.18.0", + "@anthropic-ai/vertex-sdk": "^0.14.4", + "@aws-sdk/client-bedrock": "3.1024.0", + "@aws-sdk/client-bedrock-runtime": "3.1024.0", + "@aws-sdk/credential-provider-node": "3.972.29", + "@aws/bedrock-token-generator": "^1.1.0", + "@buape/carbon": "0.14.0", + "@clack/prompts": "^1.2.0", + "@google/genai": "^1.48.0", + "@grammyjs/runner": "^2.0.3", + "@grammyjs/transformer-throttler": "^1.2.1", + "@homebridge/ciao": "^1.3.6", + "@lancedb/lancedb": "^0.27.2", + "@larksuiteoapi/node-sdk": "^1.60.0", + "@line/bot-sdk": "^11.0.0", + "@lydell/node-pty": "1.2.0-beta.10", + "@mariozechner/pi-agent-core": "0.65.2", + "@mariozechner/pi-ai": "0.65.2", + "@mariozechner/pi-coding-agent": "0.65.2", + "@mariozechner/pi-tui": "0.65.2", + "@matrix-org/matrix-sdk-crypto-wasm": "18.0.0", + "@modelcontextprotocol/sdk": "1.29.0", + "@mozilla/readability": "^0.6.0", + "@sinclair/typebox": "0.34.49", + "@slack/bolt": "^4.6.0", + "@slack/web-api": "^7.15.0", + "ajv": "^8.18.0", + "chalk": "^5.6.2", + "chokidar": "^5.0.0", + "cli-highlight": "^2.1.11", + "commander": "^14.0.3", + "croner": "^10.0.1", + "discord-api-types": "^0.38.44", + "dotenv": "^17.4.0", + "express": "^5.2.1", + "file-type": "22.0.0", + "gaxios": "7.1.4", + "google-auth-library": "^10.6.2", + "grammy": "^1.42.0", + "hono": "4.12.12", + "https-proxy-agent": "^9.0.0", + "ipaddr.js": "^2.3.0", + "jimp": "^1.6.0", + "jiti": "^2.6.1", + "json5": "^2.2.3", + "jszip": "^3.10.1", + "linkedom": "^0.18.12", + "long": "^5.3.2", + "markdown-it": "14.1.1", + "matrix-js-sdk": "41.3.0-rc.0", + "mpg123-decoder": "^1.0.3", + "node-edge-tts": "^1.2.10", + "nostr-tools": "^2.23.3", + "openai": "^6.33.0", + "opusscript": "^0.1.1", + "osc-progress": "^0.3.0", + "pdfjs-dist": "^5.6.205", + "playwright-core": "1.59.1", + "proxy-agent": "^8.0.0", + "qrcode-terminal": "^0.12.0", + "sharp": "^0.34.5", + "silk-wasm": "^3.7.1", + "sqlite-vec": "0.1.9", + "tar": "7.5.13", + "tslog": "^4.10.2", + "undici": "8.0.2", + "uuid": "^13.0.0", + "ws": "^8.20.0", + "yaml": "^2.8.3", + "zod": "^4.3.6" + }, + "bin": { + "openclaw": "openclaw.mjs" + }, + "devDependencies": { + "@grammyjs/types": "^3.26.0", + "@lit-labs/signals": "^0.2.0", + "@lit/context": "^1.1.6", + "@types/express": "^5.0.6", + "@types/markdown-it": "^14.1.2", + "@types/node": "^25.5.2", + "@types/qrcode-terminal": "^0.12.2", + "@types/ws": "^8.18.1", + "@typescript/native-preview": "7.0.0-dev.20260406.1", + "@vitest/coverage-v8": "^4.1.2", + "jscpd": "4.0.8", + "jsdom": "^29.0.1", + "lit": "^3.3.2", + "oxfmt": "0.43.0", + "oxlint": "^1.58.0", + "oxlint-tsgolint": "^0.19.0", + "semver": "7.7.4", + "signal-utils": "0.21.1", + "tsdown": "0.21.7", + "tsx": "^4.21.0", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + }, + "engines": { + "node": ">=22.14.0" + }, + "optionalDependencies": { + "@discordjs/opus": "^0.10.0", + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "openshell": "0.1.0" + }, + "peerDependencies": { + "@napi-rs/canvas": "^0.1.89", + "node-llama-cpp": "3.18.1" + }, + "peerDependenciesMeta": { + "node-llama-cpp": { + "optional": true + } + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openclaw": { + "resolved": "../../../.npm-global/lib/node_modules/openclaw", + "link": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..961a2d9 --- /dev/null +++ b/package.json @@ -0,0 +1,78 @@ +{ + "name": "@lumbridgecorp/openclaw-mattermost", + "version": "1.0.0", + "description": "Enhanced Mattermost Extension for OpenClaw - self-hosted Slack-style chat plugin", + "type": "module", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report test-results/e2e-html-report", + "test:integration": "vitest run --config tests/integration/vitest.config.ts", + "test:integration:setup": "docker-compose -f tests/integration/docker-compose.test.yml up -d", + "test:integration:teardown": "docker-compose -f tests/integration/docker-compose.test.yml down -v", + "test:integration:full": "npm run test:integration:setup && sleep 60 && npm run test:integration && npm run test:integration:teardown", + "lint": "eslint extensions/mattermost/src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "0.34.49", + "ws": "^8.20.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", + "@types/node": "^22.0.0", + "@types/ws": "^8.5.10", + "@typescript-eslint/eslint-plugin": "^8.58.1", + "@typescript-eslint/parser": "^8.58.1", + "@vitest/coverage-v8": "^1.5.0", + "eslint": "^10.2.0", + "openclaw": "file:/home/metal/.npm-global/lib/node_modules/openclaw", + "typescript": "^5.4.0", + "vitest": "^1.5.0" + }, + "peerDependencies": { + "openclaw": ">=2026.4.9" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": false + } + }, + "files": [ + "dist/", + "openclaw.plugin.json", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "openclaw": { + "extensions": [ + "./extensions/mattermost/index.ts" + ], + "channel": { + "id": "mattermost", + "label": "Mattermost", + "selectionLabel": "Mattermost (Enhanced)", + "docsPath": "/channels/mattermost", + "docsLabel": "mattermost", + "blurb": "Enhanced self-hosted Slack-style chat; install the plugin to enable.", + "order": 65 + }, + "install": { + "npmSpec": "@lumbridgecorp/openclaw-mattermost", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.9" + } + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e2b5cfb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,105 @@ +import { defineConfig, devices } from "@playwright/test"; +import { TEST_CONFIG } from "./tests/e2e/config.js"; + +/** + * Playwright E2E test configuration for OpenClaw Mattermost Extension + * Tests against Mattermost web UI for end-to-end scenarios + * + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./tests/e2e", + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 1, + + /* Opt out of parallel tests on CI for stability */ + workers: process.env.CI ? 1 : undefined, + + /* Reporter to use */ + reporter: [ + ["html", { outputFolder: "test-results/e2e-html-report" }], + ["list"], + ["json", { outputFile: "test-results/e2e-results.json" }], + ], + + /* Shared settings for all the projects below */ + use: { + /* Base URL for all tests - Mattermost server URL */ + baseURL: TEST_CONFIG.mattermost.baseUrl, + + /* Collect trace when retrying the failed test */ + trace: "on-first-retry", + + /* Capture screenshot on failure */ + screenshot: "only-on-failure", + + /* Record video on retry */ + video: "on-first-retry", + + /* Action timeout - how long each action can take */ + actionTimeout: 15000, + + /* Navigation timeout */ + navigationTimeout: 30000, + + /* Viewport settings */ + viewport: { width: 1280, height: 720 }, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + // Test against mobile viewports + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, + }, + ], + + /* Run local dev server before starting the tests - optional */ + webServer: process.env.E2E_USE_LOCAL_SERVER + ? { + command: "npm run dev", + url: "http://localhost:8065", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + } + : undefined, + + /* Test timeout - overall test timeout */ + timeout: 60000, + + /* Expect timeout - default timeout for expect assertions */ + expect: { + timeout: 10000, + }, + + /* Output directory for test artifacts */ + outputDir: "test-results/e2e", + + /* Global setup and teardown */ + globalSetup: "./tests/e2e/global-setup.ts", + globalTeardown: "./tests/e2e/global-teardown.ts", +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ca73072 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["DOM", "DOM.Iterable", "ES2022", "ScriptHost"], + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmitOnError": true, + "paths": { + "openclaw/plugin-sdk": ["../../.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/index.d.ts"], + "openclaw/plugin-sdk/*": ["../../.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/*.d.ts"] + } + }, + "include": ["extensions/mattermost/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-helpers.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d8d035a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,72 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkDir = path.resolve(__dirname, "extensions/mattermost/src/plugin-sdk"); + +const pluginSdkModules = [ + "error-runtime", + "retry-runtime", + "config-runtime", + "reply-runtime", + "ssrf-runtime", + "zod", + "account-helpers", + "account-id", + "account-resolution", + "secret-input", + "conversation-runtime", + "core", + "setup", + "approval-auth-runtime", + "channel-policy", +]; + +const aliases: Record = { + "openclaw/plugin-sdk": path.join(pluginSdkDir, "index.ts"), +}; + +for (const mod of pluginSdkModules) { + aliases[`openclaw/plugin-sdk/${mod}`] = path.join(pluginSdkDir, `${mod}.ts`); +} + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: [ + "extensions/mattermost/src/**/*.{test,spec}.{ts,js}", + "tests/**/*.{test,spec}.{ts,js}", + ], + exclude: [ + "node_modules/**", + "tests/integration/**", + "tests/e2e/**", + "**/*.config.{ts,js}", + "**/*.d.ts", + ], + deps: { + inline: ["openclaw/plugin-sdk", /openclaw\/plugin-sdk/], + }, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + exclude: [ + "node_modules/**", + "tests/**", + "**/*.config.{ts,js}", + "**/*.d.ts", + ], + }, + }, + resolve: { + alias: aliases, + }, +});