Initial commit: OpenClaw Mattermost Extension
This commit is contained in:
@@ -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 }}
|
||||||
+43
@@ -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/
|
||||||
+50
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
tests/
|
||||||
|
test-results/
|
||||||
|
.sisyphus/
|
||||||
|
src/
|
||||||
|
extensions/mattermost/src/**/*.test.ts
|
||||||
@@ -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
|
||||||
+564
@@ -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!
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
# @lumbridgecorp/openclaw-mattermost
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/package/@lumbridgecorp/openclaw-mattermost"><img src="https://img.shields.io/npm/v/@lumbridgecorp/openclaw-mattermost.svg?style=for-the-badge&color=blue" alt="npm version"></a>
|
||||||
|
<a href="https://git.karti.ai/open-source/openclaw-extentions-mattermost/releases/tag/v1.0.0"><img src="https://img.shields.io/badge/Release-v1.0.0-green.svg?style=for-the-badge" alt="Latest Release"></a>
|
||||||
|
<a href="https://git.karti.ai/open-source/openclaw-extentions-mattermost/actions"><img src="https://img.shields.io/badge/CI-Passing-brightgreen.svg?style=for-the-badge" alt="CI Status"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge" alt="Apache 2.0 License"></a>
|
||||||
|
<a href="https://git.karti.ai/open-source/openclaw-extentions-mattermost/issues"><img src="https://img.shields.io/badge/Issues-Open-orange.svg?style=for-the-badge" alt="Open Issues"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Enhanced Mattermost Extension for OpenClaw</strong><br>
|
||||||
|
A powerful, secure, and feature-rich self-hosted Slack-style chat integration
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Built with ❤️ by <a href="https://github.com/lumbridgecorp">Lumbridge Corp</a>
|
||||||
|
</p>
|
||||||
+864
@@ -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<string, unknown>,
|
||||||
|
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<string, unknown>` | 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<string, unknown>,
|
||||||
|
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<string, unknown>` | 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<string | number>;
|
||||||
|
groupPolicy?: 'open' | 'allowlist';
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
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<string, {
|
||||||
|
requireMention?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 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<string, unknown>;
|
||||||
|
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<T>(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<MattermostUser>('/users/me');
|
||||||
|
|
||||||
|
// Get a channel
|
||||||
|
const channel = await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||||
|
|
||||||
|
// Create a post
|
||||||
|
const post = await client.request<MattermostPost>('/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';
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
collectRuntimeConfigAssignments,
|
||||||
|
secretTargetRegistryEntries,
|
||||||
|
} from "./src/secret-contract.js";
|
||||||
|
|
||||||
|
export { collectMattermostSecurityAuditFindings } from "./src/security-audit.js";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
collectRuntimeConfigAssignments,
|
||||||
|
secretTargetRegistryEntries,
|
||||||
|
} from "./src/secret-contract.js";
|
||||||
|
|
||||||
|
export const defaultMarkdownTableMode = "off";
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "mattermost",
|
||||||
|
"channels": ["mattermost"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
|
||||||
|
import { mattermostPlugin } from "./src/channel.js";
|
||||||
|
|
||||||
|
export default defineSetupPluginEntry(mattermostPlugin);
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -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<typeof mattermostPlugin.actions>["handleAction"]
|
||||||
|
>;
|
||||||
|
type MattermostActionContext = Parameters<MattermostHandleAction>[0];
|
||||||
|
type MattermostSendText = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendText"]>;
|
||||||
|
type MattermostSendTextParams = Parameters<MattermostSendText>[0];
|
||||||
|
type MattermostSendMedia = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendMedia"]>;
|
||||||
|
type MattermostSendMediaParams = Parameters<MattermostSendMedia>[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>,
|
||||||
|
): 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<string, unknown>, 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ResolvedMattermostAccount>({
|
||||||
|
channelKey: "mattermost",
|
||||||
|
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||||
|
resolveDmAllowFrom: (account) => account.config.allowFrom,
|
||||||
|
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||||
|
surface: "Mattermost channels",
|
||||||
|
openScope: "any member",
|
||||||
|
groupPolicyPath: "channels.mattermost.groupPolicy",
|
||||||
|
groupAllowFromPath: "channels.mattermost.groupAllowFrom",
|
||||||
|
policyPathSuffix: "dmPolicy",
|
||||||
|
normalizeDmEntry: (raw) => normalizeAllowEntry(raw),
|
||||||
|
});
|
||||||
|
|
||||||
|
function describeMattermostMessageTool({
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
}: Parameters<
|
||||||
|
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
|
||||||
|
>[0]): ChannelMessageToolDiscovery {
|
||||||
|
const enabledAccounts = (
|
||||||
|
accountId
|
||||||
|
? [resolveMattermostAccount({ cfg, accountId })]
|
||||||
|
: listMattermostAccountIds(cfg).map((listedAccountId) =>
|
||||||
|
resolveMattermostAccount({ cfg, accountId: listedAccountId }),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((account) => account.enabled)
|
||||||
|
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
|
||||||
|
|
||||||
|
const actions: ChannelMessageActionName[] = [];
|
||||||
|
|
||||||
|
if (enabledAccounts.length > 0) {
|
||||||
|
actions.push("send");
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionsConfig = cfg.channels?.mattermost?.actions as
|
||||||
|
| { reactions?: boolean; 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<string, unknown>)
|
||||||
|
: 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<string, unknown>): {
|
||||||
|
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, unknown>): 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<Record<string, unknown>> | undefined {
|
||||||
|
const result = compileMattermostInteractiveReplies({ text: message });
|
||||||
|
const blocks = result.interactive?.blocks;
|
||||||
|
if (!blocks || blocks.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
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<Record<string, unknown>> | undefined,
|
||||||
|
explicitButtons: unknown,
|
||||||
|
): Array<Record<string, unknown>> | 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<string>();
|
||||||
|
const merged: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ResolvedMattermostAccount>({
|
||||||
|
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<ResolvedMattermostAccount> = createChatChannelPlugin({
|
||||||
|
base: {
|
||||||
|
id: "mattermost",
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
setup: mattermostSetupAdapter,
|
||||||
|
setupWizard: mattermostSetupWizard,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "channel", "group", "thread"],
|
||||||
|
reactions: true,
|
||||||
|
threads: true,
|
||||||
|
media: true,
|
||||||
|
nativeCommands: true,
|
||||||
|
},
|
||||||
|
streaming: {
|
||||||
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.mattermost"] },
|
||||||
|
configSchema: MattermostChannelConfigSchema,
|
||||||
|
config: {
|
||||||
|
...mattermostConfigAdapter,
|
||||||
|
isConfigured: (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: "<channelId|user:ID|channel:ID>",
|
||||||
|
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<ResolvedMattermostAccount>({
|
||||||
|
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
|
||||||
|
connected: false,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
lastDisconnect: null,
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
|
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||||
|
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||||
|
connected: snapshot.connected ?? false,
|
||||||
|
baseUrl: snapshot.baseUrl ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) => {
|
||||||
|
const token = account.botToken?.trim();
|
||||||
|
const baseUrl = account.baseUrl?.trim();
|
||||||
|
if (!token || !baseUrl) {
|
||||||
|
return { ok: false, error: "bot token or baseUrl missing" };
|
||||||
|
}
|
||||||
|
return await 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<typeof collectMattermostSlashCallbackPaths>[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 <channelId|@username|user:ID|channel:ID>",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
BlockStreamingCoalesceSchema,
|
||||||
|
DmPolicySchema,
|
||||||
|
GroupPolicySchema,
|
||||||
|
MarkdownConfigSchema,
|
||||||
|
requireOpenAllowFrom,
|
||||||
|
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||||
@@ -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<string | number>;
|
||||||
|
ctx: z.RefinementCtx;
|
||||||
|
}) {
|
||||||
|
requireOpenAllowFrom({
|
||||||
|
policy: params.policy,
|
||||||
|
allowFrom: params.allowFrom,
|
||||||
|
ctx: params.ctx,
|
||||||
|
path: ["allowFrom"],
|
||||||
|
message:
|
||||||
|
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DmChannelRetrySchema = z
|
||||||
|
.object({
|
||||||
|
/** Maximum number of retry attempts for DM channel creation (default: 3) */
|
||||||
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||||
|
/** Initial delay in milliseconds before first retry (default: 1000) */
|
||||||
|
initialDelayMs: z.number().int().min(100).max(60000).optional(),
|
||||||
|
/** Maximum delay in milliseconds between retries (default: 10000) */
|
||||||
|
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
|
||||||
|
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
|
||||||
|
timeoutMs: z.number().int().min(5000).max(120000).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
|
||||||
|
return data.initialDelayMs <= data.maxDelayMs;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "initialDelayMs must be less than or equal to maxDelayMs",
|
||||||
|
path: ["initialDelayMs"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const MattermostSlashCommandsSchema = z
|
||||||
|
.object({
|
||||||
|
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||||
|
native: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||||
|
/** Also register skill-based commands. */
|
||||||
|
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||||
|
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||||
|
callbackPath: z.string().optional(),
|
||||||
|
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||||
|
callbackUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | number>;
|
||||||
|
ctx: z.RefinementCtx;
|
||||||
|
}) {
|
||||||
|
requireOpenAllowFrom({
|
||||||
|
policy: params.policy,
|
||||||
|
allowFrom: params.allowFrom,
|
||||||
|
ctx: params.ctx,
|
||||||
|
path: ["allowFrom"],
|
||||||
|
message:
|
||||||
|
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DmChannelRetrySchema = z
|
||||||
|
.object({
|
||||||
|
/** Maximum number of retry attempts for DM channel creation (default: 3) */
|
||||||
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||||
|
/** Initial delay in milliseconds before first retry (default: 1000) */
|
||||||
|
initialDelayMs: z.number().int().min(100).max(60000).optional(),
|
||||||
|
/** Maximum delay in milliseconds between retries (default: 10000) */
|
||||||
|
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
|
||||||
|
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
|
||||||
|
timeoutMs: z.number().int().min(5000).max(120000).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
|
||||||
|
return data.initialDelayMs <= data.maxDelayMs;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "initialDelayMs must be less than or equal to maxDelayMs",
|
||||||
|
path: ["initialDelayMs"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const MattermostSlashCommandsSchema = z
|
||||||
|
.object({
|
||||||
|
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||||
|
native: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||||
|
/** Also register skill-based commands. */
|
||||||
|
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||||
|
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||||
|
callbackPath: z.string().optional(),
|
||||||
|
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||||
|
callbackUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: 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<string, unknown>;
|
||||||
|
pathPrefix: string;
|
||||||
|
changes: string[];
|
||||||
|
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||||
|
let changed = false;
|
||||||
|
let updated: Record<string, unknown> = params.entry;
|
||||||
|
const rawDm = updated.dm;
|
||||||
|
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : 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.<id>.dm.policy and .dm.allowFrom are legacy; use channels.mattermost.accounts.<id>.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<string, unknown> | 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<OpenClawConfig["channels"]>["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<string, unknown> | 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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createErrorContext(
|
||||||
|
operation: string,
|
||||||
|
partial?: Partial<Omit<ErrorContext, "operation" | "timestamp">>
|
||||||
|
): 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<string, unknown> {
|
||||||
|
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<string, ErrorCode> = {
|
||||||
|
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<number, string> = {
|
||||||
|
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<RetryConfig> = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
onRetry: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function withRetry<T>(
|
||||||
|
operation: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
config: RetryConfig = {},
|
||||||
|
context?: Partial<ErrorContext>
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorHandler = (error: MattermostError | Error) => void;
|
||||||
|
|
||||||
|
export class ErrorBoundary {
|
||||||
|
private handlers: Set<ErrorHandler> = 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<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||||
|
fn: T,
|
||||||
|
operation: string,
|
||||||
|
context?: Partial<ErrorContext>
|
||||||
|
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||||
|
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||||
|
try {
|
||||||
|
return await fn(...args) as ReturnType<T>;
|
||||||
|
} 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<ErrorContext>
|
||||||
|
): 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<ErrorContext>
|
||||||
|
): Promise<MattermostError> {
|
||||||
|
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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<unknown> };
|
||||||
|
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:<script>alert('xss')</script>]]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = compileMattermostInteractiveReplies(payload);
|
||||||
|
const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<{ value: string }> };
|
||||||
|
expect(buttons.buttons[0].value).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMattermostInteractiveRepliesEnabled", () => {
|
||||||
|
it("should return true when interactiveReplies in array capabilities", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
mattermost: {
|
||||||
|
capabilities: ["interactiveReplies"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isMattermostInteractiveRepliesEnabled({ cfg });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when interactiveReplies in object capabilities", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
mattermost: {
|
||||||
|
capabilities: {
|
||||||
|
interactiveReplies: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isMattermostInteractiveRepliesEnabled({ cfg });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when capabilities is empty", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
mattermost: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isMattermostInteractiveRepliesEnabled({ cfg });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when interactiveReplies is false", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
mattermost: {
|
||||||
|
capabilities: {
|
||||||
|
interactiveReplies: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isMattermostInteractiveRepliesEnabled({ cfg });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeButtonValue", () => {
|
||||||
|
it("should remove dangerous characters", () => {
|
||||||
|
const value = "<script>alert('xss')</script>";
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReplyPayload["interactive"]>["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<ReplyPayload["interactive"]>["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<ReplyPayload["interactive"]>["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<ReplyPayload["interactive"]>["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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MattermostAccountConfig>({
|
||||||
|
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
|
||||||
|
accounts: cfg.channels?.mattermost?.accounts as
|
||||||
|
| Record<string, Partial<MattermostAccountConfig>>
|
||||||
|
| undefined,
|
||||||
|
accountId,
|
||||||
|
omitKeys: ["defaultAccount"],
|
||||||
|
nestedObjectKeys: ["commands"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||||
|
if (config.chatmode === "oncall") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (config.chatmode === "onmessage") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (config.chatmode === "onchar") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return config.requireMention;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMattermostAccount(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
allowUnresolvedSecretRef?: boolean;
|
||||||
|
}): ResolvedMattermostAccount {
|
||||||
|
const accountId = normalizeAccountId(
|
||||||
|
params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
|
||||||
|
);
|
||||||
|
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||||
|
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||||
|
const accountEnabled = merged.enabled !== false;
|
||||||
|
const enabled = baseEnabled && accountEnabled;
|
||||||
|
|
||||||
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
|
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
accountId?: string | null;
|
||||||
|
fetchImpl?: MattermostFetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
|
||||||
|
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
async function resolveBotUserId(
|
||||||
|
client: MattermostClient,
|
||||||
|
cacheKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const 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<EditMessageResult> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<DeleteMessageResult> {
|
||||||
|
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<DeleteMessageResult> {
|
||||||
|
try {
|
||||||
|
await client.request<unknown>(`/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<DownloadFileResult> {
|
||||||
|
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<void>((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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<typeof fetch>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockClient() {
|
||||||
|
return createMattermostClient({
|
||||||
|
baseUrl: "https://mattermost.example.com",
|
||||||
|
// 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<T>(run: Promise<T>): Promise<T> {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
return await run;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressUnhandled<T>(run: Promise<T>): Promise<T> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof updateMattermostPost>[2],
|
||||||
|
response?: { status?: number; body?: unknown; contentType?: string },
|
||||||
|
) {
|
||||||
|
const { client, calls } = createTestClient(response ?? { body: { id: "post1" } });
|
||||||
|
await updateMattermostPost(client, "post1", update);
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
body: JSON.parse(calls[0].init?.body as string) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<typeof fetch>(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<unknown>("/anything", { method: "DELETE" });
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createMattermostPost ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createMattermostPost", () => {
|
||||||
|
it("sends channel_id and message", async () => {
|
||||||
|
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: "http://localhost:8065",
|
||||||
|
botToken: "tok",
|
||||||
|
fetchImpl: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMattermostPost(client, {
|
||||||
|
channelId: "ch123",
|
||||||
|
message: "Hello world",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = 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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Response>;
|
||||||
|
|
||||||
|
export type MattermostClient = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
token: string;
|
||||||
|
request: <T>(path: string, init?: RequestInit, operation?: string) => Promise<T>;
|
||||||
|
/** 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<typeof MattermostPostSchema>;
|
||||||
|
|
||||||
|
export type MattermostFileInfo = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
mime_type?: string | null;
|
||||||
|
size?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||||
|
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMattermostApiUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
operation = "build-url",
|
||||||
|
context?: Partial<ErrorContext>
|
||||||
|
): 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<string> {
|
||||||
|
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<ErrorContext>
|
||||||
|
): 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<ErrorContext> = {
|
||||||
|
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 <T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
operation = "api-request"
|
||||||
|
): Promise<T> => {
|
||||||
|
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> = {
|
||||||
|
...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<ErrorContext>
|
||||||
|
): Promise<MattermostUser> {
|
||||||
|
return await client.request<MattermostUser>("/users/me", undefined, "fetch-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMattermostUser(
|
||||||
|
client: MattermostClient,
|
||||||
|
userId: string,
|
||||||
|
context?: Partial<ErrorContext>
|
||||||
|
): Promise<MattermostUser> {
|
||||||
|
return await client.request<MattermostUser>(
|
||||||
|
`/users/${userId}`,
|
||||||
|
undefined,
|
||||||
|
"fetch-user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMattermostUserByUsername(
|
||||||
|
client: MattermostClient,
|
||||||
|
username: string,
|
||||||
|
): Promise<MattermostUser> {
|
||||||
|
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMattermostChannel(
|
||||||
|
client: MattermostClient,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<MattermostChannel> {
|
||||||
|
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMattermostChannelByName(
|
||||||
|
client: MattermostClient,
|
||||||
|
teamId: string,
|
||||||
|
channelName: string,
|
||||||
|
): Promise<MattermostChannel> {
|
||||||
|
return await client.request<MattermostChannel>(
|
||||||
|
`/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMattermostTyping(
|
||||||
|
client: MattermostClient,
|
||||||
|
params: { channelId: string; parentId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
channel_id: params.channelId,
|
||||||
|
};
|
||||||
|
const parentId = params.parentId?.trim();
|
||||||
|
if (parentId) {
|
||||||
|
payload.parent_id = parentId;
|
||||||
|
}
|
||||||
|
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMattermostDirectChannel(
|
||||||
|
client: MattermostClient,
|
||||||
|
userIds: string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MattermostChannel> {
|
||||||
|
return await client.request<MattermostChannel>("/channels/direct", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(userIds),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateDmChannelRetryOptions = {
|
||||||
|
/** Maximum number of retry attempts (default: 3) */
|
||||||
|
maxRetries?: number;
|
||||||
|
/** Initial delay in milliseconds (default: 1000) */
|
||||||
|
initialDelayMs?: number;
|
||||||
|
/** Maximum delay in milliseconds (default: 10000) */
|
||||||
|
maxDelayMs?: number;
|
||||||
|
/** Timeout for each individual request in milliseconds (default: 30000) */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Optional logger for retry events */
|
||||||
|
onRetry?: (attempt: number, delayMs: number, error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createMattermostDirectChannelWithRetry(
|
||||||
|
client: MattermostClient,
|
||||||
|
userIds: string[],
|
||||||
|
options: CreateDmChannelRetryOptions = {},
|
||||||
|
context?: Partial<ErrorContext>
|
||||||
|
): Promise<MattermostChannel> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
initialDelayMs = 1000,
|
||||||
|
maxDelayMs = 10000,
|
||||||
|
timeoutMs = 30000,
|
||||||
|
onRetry,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const operationContext: Partial<ErrorContext> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<MattermostPost> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
channel_id: params.channelId,
|
||||||
|
message: params.message,
|
||||||
|
};
|
||||||
|
if (params.rootId) {
|
||||||
|
payload.root_id = params.rootId;
|
||||||
|
}
|
||||||
|
if (params.fileIds?.length) {
|
||||||
|
payload.file_ids = params.fileIds;
|
||||||
|
}
|
||||||
|
if (params.props) {
|
||||||
|
payload.props = params.props;
|
||||||
|
}
|
||||||
|
return await client.request<MattermostPost>("/posts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MattermostTeam = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMattermostUserTeams(
|
||||||
|
client: MattermostClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<MattermostTeam[]> {
|
||||||
|
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMattermostPost(
|
||||||
|
client: MattermostClient,
|
||||||
|
postId: string,
|
||||||
|
params: {
|
||||||
|
message?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<MattermostPost> {
|
||||||
|
const payload: Record<string, unknown> = { id: postId };
|
||||||
|
if (params.message !== undefined) {
|
||||||
|
payload.message = params.message;
|
||||||
|
}
|
||||||
|
if (params.props !== undefined) {
|
||||||
|
payload.props = params.props;
|
||||||
|
}
|
||||||
|
return await client.request<MattermostPost>(`/posts/${postId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMattermostFile(
|
||||||
|
client: MattermostClient,
|
||||||
|
params: {
|
||||||
|
channelId: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
fileName: string;
|
||||||
|
contentType?: string;
|
||||||
|
},
|
||||||
|
): Promise<MattermostFileInfo> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
const clients: MattermostClient[] = [];
|
||||||
|
for (const id of accountIds) {
|
||||||
|
const client = buildClient({ cfg: params.cfg, accountId: id });
|
||||||
|
if (client && !seen.has(client.token)) {
|
||||||
|
seen.add(client.token);
|
||||||
|
clients.push(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List channels (public + private) visible to any configured bot account.
|
||||||
|
*
|
||||||
|
* NOTE: Uses per_page=200 which covers most instances. Mattermost does not
|
||||||
|
* return a "has more" indicator, so very large instances (200+ channels per bot)
|
||||||
|
* may see incomplete results. Pagination can be added if needed.
|
||||||
|
*/
|
||||||
|
export async function listMattermostDirectoryGroups(
|
||||||
|
params: MattermostDirectoryParams,
|
||||||
|
): Promise<ChannelDirectoryEntry[]> {
|
||||||
|
const clients = buildClients(params);
|
||||||
|
if (!clients.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const q = params.query?.trim().toLowerCase() || "";
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const entries: ChannelDirectoryEntry[] = [];
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
const me = await fetchMattermostMe(client);
|
||||||
|
const channels = await client.request<MattermostChannel[]>(
|
||||||
|
`/users/${me.id}/channels?per_page=200`,
|
||||||
|
);
|
||||||
|
for (const ch of channels) {
|
||||||
|
if (ch.type !== "O" && ch.type !== "P") continue;
|
||||||
|
if (seenIds.has(ch.id)) continue;
|
||||||
|
if (q) {
|
||||||
|
const name = (ch.name ?? "").toLowerCase();
|
||||||
|
const display = (ch.display_name ?? "").toLowerCase();
|
||||||
|
if (!name.includes(q) && !display.includes(q)) continue;
|
||||||
|
}
|
||||||
|
seenIds.add(ch.id);
|
||||||
|
entries.push({
|
||||||
|
kind: "group" as const,
|
||||||
|
id: `channel:${ch.id}`,
|
||||||
|
name: ch.name ?? undefined,
|
||||||
|
handle: ch.display_name ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Token may be expired/revoked — skip this account and try others
|
||||||
|
console.debug?.(
|
||||||
|
"[mattermost-directory] listGroups: skipping account:",
|
||||||
|
(err as Error)?.message,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List team members as peer directory entries.
|
||||||
|
*
|
||||||
|
* Uses only the first available client since all bots in a team see the same
|
||||||
|
* user list (unlike channels where membership varies). Uses the first team
|
||||||
|
* returned — multi-team setups will only see members from that team.
|
||||||
|
*
|
||||||
|
* NOTE: per_page=200 for member listing; same pagination caveat as groups.
|
||||||
|
*/
|
||||||
|
export async function listMattermostDirectoryPeers(
|
||||||
|
params: MattermostDirectoryParams,
|
||||||
|
): Promise<ChannelDirectoryEntry[]> {
|
||||||
|
const clients = buildClients(params);
|
||||||
|
if (!clients.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// All bots see the same user list, so one client suffices (unlike channels
|
||||||
|
// where private channel membership varies per bot).
|
||||||
|
const client = clients[0];
|
||||||
|
try {
|
||||||
|
const me = await fetchMattermostMe(client);
|
||||||
|
const teams = await client.request<{ id: string }[]>("/users/me/teams");
|
||||||
|
if (!teams.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Uses first team — multi-team setups may need iteration in the future
|
||||||
|
const teamId = teams[0].id;
|
||||||
|
const q = params.query?.trim().toLowerCase() || "";
|
||||||
|
|
||||||
|
let users: MattermostUser[];
|
||||||
|
if (q) {
|
||||||
|
users = await client.request<MattermostUser[]>("/users/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ term: q, team_id: teamId }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const members = await client.request<{ user_id: string }[]>(
|
||||||
|
`/teams/${teamId}/members?per_page=200`,
|
||||||
|
);
|
||||||
|
const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id);
|
||||||
|
if (!userIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
users = await client.request<MattermostUser[]>("/users/ids", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(userIds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = users
|
||||||
|
.filter((u) => u.id !== me.id)
|
||||||
|
.map((u) => ({
|
||||||
|
kind: "user" as const,
|
||||||
|
id: `user:${u.id}`,
|
||||||
|
name: u.username ?? undefined,
|
||||||
|
handle:
|
||||||
|
[u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined,
|
||||||
|
}));
|
||||||
|
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
|
||||||
|
} catch (err) {
|
||||||
|
console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
const keys = Object.keys(ctx).filter((k) => k !== "_token");
|
||||||
|
// Reverse the key order to simulate reordering
|
||||||
|
for (const key of keys.reverse()) {
|
||||||
|
reordered[key] = ctx[key];
|
||||||
|
}
|
||||||
|
expect(verifyInteractionToken(reordered, token)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createMattermostInteractionHandler", () => {
|
||||||
|
function setInteractionRuntime(
|
||||||
|
enqueueSystemEvent: (
|
||||||
|
text: string,
|
||||||
|
options: { sessionKey?: string | null; sessionId?: string | null; userId?: string | null },
|
||||||
|
) => boolean = () => true,
|
||||||
|
) {
|
||||||
|
setMattermostRuntime({
|
||||||
|
system: {
|
||||||
|
enqueueSystemEvent,
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMattermostClientMock(
|
||||||
|
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
|
||||||
|
): MattermostClient {
|
||||||
|
return {
|
||||||
|
baseUrl: "https://chat.example.com",
|
||||||
|
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||||
|
// pragma: allowlist secret
|
||||||
|
token: "bot-token",
|
||||||
|
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
|
||||||
|
fetchImpl: vi.fn<typeof fetch>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setInteractionRuntime();
|
||||||
|
// pragma: allowlist secret
|
||||||
|
setInteractionSecret("acct", "bot-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
function createReq(params: {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
remoteAddress?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): IncomingMessage {
|
||||||
|
const body = params.body === undefined ? "" : JSON.stringify(params.body);
|
||||||
|
const listeners = new Map<string, Array<(...args: unknown[]) => 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<string, string>; body: string } {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
body: "",
|
||||||
|
setHeader(name: string, value: string | number | readonly string[]) {
|
||||||
|
res.headers[name] = Array.isArray(value) ? value.join(",") : String(value);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
end(
|
||||||
|
chunk?: string | Buffer | Uint8Array,
|
||||||
|
_encoding?: BufferEncoding | (() => void),
|
||||||
|
cb?: () => void,
|
||||||
|
) {
|
||||||
|
res.body = chunk ? String(chunk) : "";
|
||||||
|
cb?.();
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
} as ServerResponse & { headers: Record<string, string>; body: string };
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionContext(actionId = "approve", channelId = "chan-1") {
|
||||||
|
const context = { action_id: actionId, __openclaw_channel_id: channelId };
|
||||||
|
return { context, token: generateInteractionToken(context, "acct") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInteractionBody(params: {
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
token: string;
|
||||||
|
channelId?: string;
|
||||||
|
postId?: string;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
user_id: params.userId ?? "user-1",
|
||||||
|
...(params.userName ? { user_name: params.userName } : {}),
|
||||||
|
channel_id: params.channelId ?? "chan-1",
|
||||||
|
post_id: params.postId ?? "post-1",
|
||||||
|
context: { ...params.context, _token: params.token },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHandler(
|
||||||
|
handler: ReturnType<typeof createMattermostInteractionHandler>,
|
||||||
|
params: {
|
||||||
|
body: unknown;
|
||||||
|
remoteAddress?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const req = createReq({
|
||||||
|
remoteAddress: params.remoteAddress,
|
||||||
|
headers: params.headers,
|
||||||
|
body: params.body,
|
||||||
|
});
|
||||||
|
const res = createRes();
|
||||||
|
await handler(req, res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectForbiddenResponse(
|
||||||
|
res: ServerResponse & { body: string },
|
||||||
|
expectedMessage: string,
|
||||||
|
) {
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body).toContain(expectedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccessfulApprovalUpdate(
|
||||||
|
res: ServerResponse & { body: string },
|
||||||
|
requestLog?: Array<{ path: string; method?: string }>,
|
||||||
|
) {
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toBe("{}");
|
||||||
|
if (requestLog) {
|
||||||
|
expect(requestLog).toEqual([
|
||||||
|
{ path: "/posts/post-1", method: undefined },
|
||||||
|
{ path: "/posts/post-1", method: "PUT" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionPost(params?: {
|
||||||
|
actionId?: string;
|
||||||
|
actionName?: string;
|
||||||
|
channelId?: string;
|
||||||
|
rootId?: string;
|
||||||
|
}): MattermostPost {
|
||||||
|
return {
|
||||||
|
id: "post-1",
|
||||||
|
channel_id: params?.channelId ?? "chan-1",
|
||||||
|
...(params?.rootId ? { root_id: params.rootId } : {}),
|
||||||
|
message: "Choose",
|
||||||
|
props: {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: params?.actionId ?? "approve",
|
||||||
|
name: params?.actionName ?? "Approve",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUnusedInteractionHandler() {
|
||||||
|
return createMattermostInteractionHandler({
|
||||||
|
client: createMattermostClientMock(async () => ({ message: "unused" })),
|
||||||
|
botUserId: "bot",
|
||||||
|
accountId: "acct",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runApproveInteraction(params?: {
|
||||||
|
actionName?: string;
|
||||||
|
allowedSourceIps?: string[];
|
||||||
|
trustedProxies?: string[];
|
||||||
|
remoteAddress?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const { context, token } = createActionContext();
|
||||||
|
const requestLog: Array<{ path: string; method?: string }> = [];
|
||||||
|
const handler = createMattermostInteractionHandler({
|
||||||
|
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
|
||||||
|
requestLog.push({ path, method: init?.method });
|
||||||
|
if (init?.method === "PUT") {
|
||||||
|
return { id: "post-1" };
|
||||||
|
}
|
||||||
|
return createActionPost({ actionName: params?.actionName });
|
||||||
|
}),
|
||||||
|
botUserId: "bot",
|
||||||
|
accountId: "acct",
|
||||||
|
allowedSourceIps: params?.allowedSourceIps,
|
||||||
|
trustedProxies: params?.trustedProxies,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHandler(handler, {
|
||||||
|
remoteAddress: params?.remoteAddress,
|
||||||
|
headers: params?.headers,
|
||||||
|
body: createInteractionBody({ context, token, userName: "alice" }),
|
||||||
|
});
|
||||||
|
return { res, requestLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInvalidActionRequest(actionId: string) {
|
||||||
|
const { context, token } = createActionContext();
|
||||||
|
const handler = createMattermostInteractionHandler({
|
||||||
|
client: createMattermostClientMock(async () =>
|
||||||
|
createActionPost({ actionId, actionName: actionId }),
|
||||||
|
),
|
||||||
|
botUserId: "bot",
|
||||||
|
accountId: "acct",
|
||||||
|
});
|
||||||
|
|
||||||
|
return await runHandler(handler, {
|
||||||
|
body: createInteractionBody({ context, token }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts callback requests from an allowlisted source IP", async () => {
|
||||||
|
const { res, requestLog } = await runApproveInteraction({
|
||||||
|
allowedSourceIps: ["198.51.100.8"],
|
||||||
|
remoteAddress: "198.51.100.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostInteractionResponse = {
|
||||||
|
update?: {
|
||||||
|
message: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Callback URL registry ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const callbackUrls = new Map<string, string>();
|
||||||
|
|
||||||
|
export function setInteractionCallbackUrl(accountId: string, url: string): void {
|
||||||
|
callbackUrls.set(accountId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInteractionCallbackUrl(accountId: string): string | undefined {
|
||||||
|
return callbackUrls.get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionCallbackConfig = Pick<OpenClawConfig, "gateway" | "channels"> & {
|
||||||
|
interactions?: {
|
||||||
|
callbackBaseUrl?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveInteractionCallbackPath(accountId: string): string {
|
||||||
|
return `/mattermost/interactions/${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWildcardBindHost(rawHost: string): boolean {
|
||||||
|
const trimmed = rawHost.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||||
|
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCallbackBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return 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<string, string>();
|
||||||
|
let defaultInteractionSecret: string | undefined;
|
||||||
|
|
||||||
|
function deriveInteractionSecret(botToken: string): string {
|
||||||
|
return createHmac("sha256", "openclaw-mattermost-interactions").update(botToken).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInteractionSecret(accountIdOrBotToken: string, botToken?: string): void {
|
||||||
|
if (typeof botToken === "string") {
|
||||||
|
interactionSecrets.set(accountIdOrBotToken, deriveInteractionSecret(botToken));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Backward-compatible fallback for call sites/tests that only pass botToken.
|
||||||
|
defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInteractionSecret(accountId?: string): string {
|
||||||
|
const scoped = accountId ? interactionSecrets.get(accountId) : undefined;
|
||||||
|
if (scoped) {
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
if (defaultInteractionSecret) {
|
||||||
|
return defaultInteractionSecret;
|
||||||
|
}
|
||||||
|
// Fallback for single-account runtimes that only registered scoped secrets.
|
||||||
|
if (interactionSecrets.size === 1) {
|
||||||
|
const first = interactionSecrets.values().next().value;
|
||||||
|
if (typeof first === "string") {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Interaction secret not initialized — call setInteractionSecret(accountId, botToken) first",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeInteractionContext(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => canonicalizeInteractionContext(item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.filter(([, entryValue]) => entryValue !== undefined)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateInteractionToken(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
accountId?: string,
|
||||||
|
): string {
|
||||||
|
const secret = getInteractionSecret(accountId);
|
||||||
|
const payload = JSON.stringify(canonicalizeInteractionContext(context));
|
||||||
|
return createHmac("sha256", secret).update(payload).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyInteractionToken(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
token: string,
|
||||||
|
accountId?: string,
|
||||||
|
): boolean {
|
||||||
|
const expected = generateInteractionToken(context, accountId);
|
||||||
|
return safeEqualSecret(expected, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button builder helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MattermostButton = {
|
||||||
|
id: string;
|
||||||
|
type: "button" | "select";
|
||||||
|
name: string;
|
||||||
|
style?: "default" | "primary" | "danger";
|
||||||
|
integration: {
|
||||||
|
url: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostAttachment = {
|
||||||
|
text?: string;
|
||||||
|
actions?: MattermostButton[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Mattermost `props.attachments` with interactive buttons.
|
||||||
|
*
|
||||||
|
* Each button includes an HMAC token in its integration context so the
|
||||||
|
* callback handler can verify the request originated from a legitimate
|
||||||
|
* button click (Mattermost's recommended security pattern).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Sanitize a button ID so Mattermost's action router can match it.
|
||||||
|
* Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}`
|
||||||
|
* and IDs containing hyphens or underscores break the server-side routing.
|
||||||
|
* See: https://github.com/mattermost/mattermost/issues/25747
|
||||||
|
*/
|
||||||
|
function sanitizeActionId(id: string): string {
|
||||||
|
return id.replace(/[-_]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildButtonAttachments(params: {
|
||||||
|
callbackUrl: string;
|
||||||
|
accountId?: string;
|
||||||
|
buttons: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
style?: "default" | "primary" | "danger";
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
text?: string;
|
||||||
|
}): MattermostAttachment[] {
|
||||||
|
const actions: MattermostButton[] = params.buttons.map((btn) => {
|
||||||
|
const safeId = sanitizeActionId(btn.id);
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
action_id: safeId,
|
||||||
|
...btn.context,
|
||||||
|
};
|
||||||
|
const token = generateInteractionToken(context, params.accountId);
|
||||||
|
return {
|
||||||
|
id: safeId,
|
||||||
|
type: "button" as const,
|
||||||
|
name: btn.name,
|
||||||
|
style: btn.style,
|
||||||
|
integration: {
|
||||||
|
url: params.callbackUrl,
|
||||||
|
context: {
|
||||||
|
...context,
|
||||||
|
_token: token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: params.text ?? "",
|
||||||
|
actions,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildButtonProps(params: {
|
||||||
|
callbackUrl: string;
|
||||||
|
accountId?: string;
|
||||||
|
channelId: string;
|
||||||
|
buttons: Array<unknown>;
|
||||||
|
text?: string;
|
||||||
|
}): Record<string, unknown> | undefined {
|
||||||
|
const rawButtons = params.buttons.flatMap((item) =>
|
||||||
|
Array.isArray(item) ? item : [item],
|
||||||
|
) as MattermostInteractiveButtonInput[];
|
||||||
|
|
||||||
|
const buttons = rawButtons
|
||||||
|
.map((btn) => ({
|
||||||
|
id: 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<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Request body read timeout"));
|
||||||
|
}, INTERACTION_BODY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
|
||||||
|
req.destroy();
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Request body too large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createMattermostInteractionHandler(params: {
|
||||||
|
client: MattermostClient;
|
||||||
|
botUserId: string;
|
||||||
|
accountId: string;
|
||||||
|
allowedSourceIps?: string[];
|
||||||
|
trustedProxies?: string[];
|
||||||
|
allowRealIpFallback?: boolean;
|
||||||
|
resolveSessionKey?: (params: {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
post: MattermostPost;
|
||||||
|
}) => Promise<string>;
|
||||||
|
handleInteraction?: (opts: {
|
||||||
|
payload: MattermostInteractionPayload;
|
||||||
|
userName: string;
|
||||||
|
actionId: string;
|
||||||
|
actionName: string;
|
||||||
|
originalMessage: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
post: MattermostPost;
|
||||||
|
}) => Promise<MattermostInteractionResponse | null>;
|
||||||
|
authorizeButtonClick?: (opts: {
|
||||||
|
payload: MattermostInteractionPayload;
|
||||||
|
post: MattermostPost;
|
||||||
|
}) => Promise<MattermostInteractionAuthorizationResult>;
|
||||||
|
dispatchButtonClick?: (opts: {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
actionId: string;
|
||||||
|
actionName: string;
|
||||||
|
postId: string;
|
||||||
|
post: MattermostPost;
|
||||||
|
}) => Promise<void>;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||||
|
const { client, accountId, log } = params;
|
||||||
|
const core = getMattermostRuntime();
|
||||||
|
|
||||||
|
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<MattermostPost>(`/posts/${payload.post_id}`);
|
||||||
|
const postChannelId = originalPost.channel_id?.trim();
|
||||||
|
if (!postChannelId || postChannelId !== payload.channel_id) {
|
||||||
|
log?.(
|
||||||
|
`mattermost interaction: post channel mismatch payload=${payload.channel_id} post=${postChannelId ?? "<missing>"}`,
|
||||||
|
);
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Post/channel mismatch" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalMessage = originalPost.message ?? "";
|
||||||
|
|
||||||
|
// Ensure the callback can only target an action that exists on the original post.
|
||||||
|
const postAttachments = Array.isArray(originalPost?.props?.attachments)
|
||||||
|
? (originalPost.props.attachments as Array<{
|
||||||
|
actions?: Array<{ id?: string; name?: string }>;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
for (const att of postAttachments) {
|
||||||
|
const match = att.actions?.find((a) => a.id === actionId);
|
||||||
|
if (match?.name) {
|
||||||
|
clickedButtonName = match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clickedButtonName === null) {
|
||||||
|
log?.(`mattermost interaction: action ${actionId} not found in post ${payload.post_id}`);
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Unknown action" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: failed to validate post ${payload.post_id}: ${String(err)}`);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Failed to validate interaction" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalPost) {
|
||||||
|
log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Failed to load interaction post" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.(
|
||||||
|
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||||
|
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (params.authorizeButtonClick) {
|
||||||
|
try {
|
||||||
|
const authorization = await params.authorizeButtonClick({
|
||||||
|
payload,
|
||||||
|
post: originalPost,
|
||||||
|
});
|
||||||
|
if (!authorization.ok) {
|
||||||
|
res.statusCode = authorization.statusCode ?? 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(
|
||||||
|
JSON.stringify(
|
||||||
|
authorization.response ?? {
|
||||||
|
ephemeral_text: "You are not allowed to use this action here.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.handleInteraction) {
|
||||||
|
try {
|
||||||
|
const response = await params.handleInteraction({
|
||||||
|
payload,
|
||||||
|
userName,
|
||||||
|
actionId,
|
||||||
|
actionName: clickedButtonName,
|
||||||
|
originalMessage,
|
||||||
|
context: contextWithoutToken,
|
||||||
|
post: originalPost,
|
||||||
|
});
|
||||||
|
if (response !== null) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(response));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: custom handler failed: ${String(err)}`);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Interaction handler failed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch as system event so the agent can handle it.
|
||||||
|
// Wrapped in try/catch — the post update below must still run even if
|
||||||
|
// system event dispatch fails (e.g. missing sessionKey or channel lookup).
|
||||||
|
try {
|
||||||
|
const eventLabel =
|
||||||
|
`Mattermost button click: action="${actionId}" ` +
|
||||||
|
`by ${payload.user_name ?? payload.user_id} ` +
|
||||||
|
`in channel ${payload.channel_id}`;
|
||||||
|
|
||||||
|
const sessionKey = params.resolveSessionKey
|
||||||
|
? await params.resolveSessionKey({
|
||||||
|
channelId: payload.channel_id,
|
||||||
|
userId: payload.user_id,
|
||||||
|
post: originalPost,
|
||||||
|
})
|
||||||
|
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
|
||||||
|
|
||||||
|
core.system.enqueueSystemEvent(eventLabel, {
|
||||||
|
sessionKey,
|
||||||
|
contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the post via API to replace buttons with a completion indicator.
|
||||||
|
try {
|
||||||
|
await updateMattermostPost(client, payload.post_id, {
|
||||||
|
message: originalMessage,
|
||||||
|
props: {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
text: `✓ **${clickedButtonName}** selected by @${userName}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with empty JSON — the post update is handled above
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end("{}");
|
||||||
|
|
||||||
|
// Dispatch a synthetic inbound message so the agent responds to the button click.
|
||||||
|
if (params.dispatchButtonClick) {
|
||||||
|
try {
|
||||||
|
await params.dispatchButtonClick({
|
||||||
|
channelId: payload.channel_id,
|
||||||
|
userId: payload.user_id,
|
||||||
|
userName,
|
||||||
|
actionId,
|
||||||
|
actionName: clickedButtonName,
|
||||||
|
postId: payload.post_id,
|
||||||
|
post: originalPost,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, Set<string>>([
|
||||||
|
["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])],
|
||||||
|
["openai", new Set(["gpt-4.1", "gpt-5"])],
|
||||||
|
]),
|
||||||
|
providers: ["anthropic", "openai"],
|
||||||
|
resolvedDefault: {
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
},
|
||||||
|
modelNames: new Map<string, string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Mattermost model picker", () => {
|
||||||
|
it("resolves bare /model and /models entry points", () => {
|
||||||
|
expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" });
|
||||||
|
expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" });
|
||||||
|
expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({
|
||||||
|
kind: "models",
|
||||||
|
provider: "openai",
|
||||||
|
});
|
||||||
|
expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the allowed model refs set", () => {
|
||||||
|
expect(buildMattermostAllowedModelRefs(data)).toEqual(
|
||||||
|
new Set([
|
||||||
|
"anthropic/claude-opus-4-5",
|
||||||
|
"anthropic/claude-sonnet-4-5",
|
||||||
|
"openai/gpt-4.1",
|
||||||
|
"openai/gpt-5",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the summary view with a browse button", () => {
|
||||||
|
const view = renderMattermostModelSummaryView({
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
currentModel: "openai/gpt-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.text).toContain("Current: openai/gpt-5");
|
||||||
|
expect(view.text).toContain("Tap below to browse models");
|
||||||
|
expect(view.text).toContain("/oc_model <provider/model> to switch");
|
||||||
|
expect(view.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<string, Set<string>>([
|
||||||
|
["anthropic", new Set(["claude-opus-4-5"])],
|
||||||
|
["openai", new Set(["gpt-5"])],
|
||||||
|
]),
|
||||||
|
providers: ["anthropic", "openai"],
|
||||||
|
resolvedDefault: {
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5",
|
||||||
|
},
|
||||||
|
modelNames: new Map<string, string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMattermostModelPickerCurrentModel({
|
||||||
|
cfg,
|
||||||
|
route: {
|
||||||
|
agentId: "support",
|
||||||
|
sessionKey: "agent:support:main",
|
||||||
|
},
|
||||||
|
data: providerData,
|
||||||
|
}),
|
||||||
|
).toBe("openai/gpt-5");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T>(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||||
|
const safePage = Math.max(1, Math.min(normalizePage(page), totalPages));
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
return {
|
||||||
|
items: items.slice(start, start + pageSize),
|
||||||
|
page: safePage,
|
||||||
|
totalPages,
|
||||||
|
hasPrev: safePage > 1,
|
||||||
|
hasNext: safePage < totalPages,
|
||||||
|
totalItems: items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContext(state: MattermostModelPickerState): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
[MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButtonId(state: MattermostModelPickerState): string {
|
||||||
|
const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12);
|
||||||
|
return `${ACTION_IDS[state.action]}${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButton(params: {
|
||||||
|
action: MattermostModelPickerState["action"];
|
||||||
|
ownerUserId: string;
|
||||||
|
text: string;
|
||||||
|
provider?: string;
|
||||||
|
page?: number;
|
||||||
|
model?: string;
|
||||||
|
style?: "default" | "primary" | "danger";
|
||||||
|
}): MattermostInteractiveButtonInput {
|
||||||
|
const baseState =
|
||||||
|
params.action === "providers" || params.action === "back"
|
||||||
|
? {
|
||||||
|
action: params.action,
|
||||||
|
ownerUserId: params.ownerUserId,
|
||||||
|
}
|
||||||
|
: params.action === "list"
|
||||||
|
? {
|
||||||
|
action: "list" as const,
|
||||||
|
ownerUserId: params.ownerUserId,
|
||||||
|
provider: normalizeProviderId(params.provider ?? ""),
|
||||||
|
page: normalizePage(params.page),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
action: "select" as const,
|
||||||
|
ownerUserId: params.ownerUserId,
|
||||||
|
provider: normalizeProviderId(params.provider ?? ""),
|
||||||
|
page: normalizePage(params.page),
|
||||||
|
model: 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<string>())].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<string, unknown>,
|
||||||
|
): 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<string> {
|
||||||
|
const refs = new Set<string>();
|
||||||
|
for (const provider of data.providers) {
|
||||||
|
for (const model of data.byProvider.get(provider) ?? []) {
|
||||||
|
refs.add(`${provider}/${model}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMattermostModelPickerCurrentModel(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
route: { agentId: string; sessionKey: string };
|
||||||
|
data: ModelsProviderData;
|
||||||
|
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 <provider/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 | number>): string[] {
|
||||||
|
const normalized = entries
|
||||||
|
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
|
||||||
|
.filter(Boolean);
|
||||||
|
return Array.from(new Set(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMattermostEffectiveAllowFromLists(params: {
|
||||||
|
allowFrom?: Array<string | number> | null;
|
||||||
|
groupAllowFrom?: Array<string | number> | null;
|
||||||
|
storeAllowFrom?: Array<string | number> | 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<string | number> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<NonNullable<OpenClawConfig["agents"]>["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");
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||||
|
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||||
|
|
||||||
|
const resolveMattermostMedia = async (
|
||||||
|
fileIds?: string[] | null,
|
||||||
|
): Promise<MattermostMediaInfo[]> => {
|
||||||
|
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<MattermostChannel | null> => {
|
||||||
|
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<MattermostUser | null> => {
|
||||||
|
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<unknown>,
|
||||||
|
): Record<string, unknown> | undefined =>
|
||||||
|
buildButtonProps({
|
||||||
|
callbackUrl,
|
||||||
|
accountId,
|
||||||
|
channelId,
|
||||||
|
buttons,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateModelPickerPost = async (params: {
|
||||||
|
channelId: string;
|
||||||
|
postId: string;
|
||||||
|
message: string;
|
||||||
|
buttons?: Array<unknown>;
|
||||||
|
}): Promise<MattermostInteractionResponse> => {
|
||||||
|
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
await updateMattermostPost(client, params.postId, {
|
||||||
|
message: params.message,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolveMattermostMedia,
|
||||||
|
sendTypingIndicator,
|
||||||
|
resolveChannelInfo,
|
||||||
|
resolveUserInfo,
|
||||||
|
updateModelPickerPost,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof import("./client.js")>("./client.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchMattermostUserTeams,
|
||||||
|
normalizeMattermostBaseUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./slash-commands.js", () => ({
|
||||||
|
DEFAULT_COMMAND_SPECS: [
|
||||||
|
{ trigger: "ping", description: "ping" },
|
||||||
|
{ trigger: "ping", description: "duplicate" },
|
||||||
|
],
|
||||||
|
isSlashCommandsEnabled,
|
||||||
|
registerSlashCommands,
|
||||||
|
resolveCallbackUrl,
|
||||||
|
resolveSlashCommandConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./slash-state.js", () => ({
|
||||||
|
activateSlashCommands,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
return commands.filter((cmd) => {
|
||||||
|
const key = cmd.trigger.trim();
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTriggerMap(commands: MattermostCommandSpec[]): Map<string, string> {
|
||||||
|
const triggerMap = new Map<string, string>();
|
||||||
|
for (const cmd of commands) {
|
||||||
|
if (cmd.originalName) {
|
||||||
|
triggerMap.set(cmd.trigger, cmd.originalName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return triggerMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnOnSuspiciousCallbackUrl(params: {
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
baseUrl: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const mmHost = new URL(normalizeMattermostBaseUrl(params.baseUrl) ?? params.baseUrl).hostname;
|
||||||
|
const callbackHost = new URL(params.callbackUrl).hostname;
|
||||||
|
|
||||||
|
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
||||||
|
params.runtime.error?.(
|
||||||
|
`mattermost: slash commands callbackUrl resolved to ${params.callbackUrl} (loopback) while baseUrl is ${params.baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs and let the downstream registration fail naturally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerSlashCommandsAcrossTeams(params: {
|
||||||
|
client: MattermostClient;
|
||||||
|
teams: Array<{ id: string }>;
|
||||||
|
botUserId: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
commands: MattermostCommandSpec[];
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}): Promise<{
|
||||||
|
registered: MattermostRegisteredCommand[];
|
||||||
|
teamRegistrationFailures: number;
|
||||||
|
}> {
|
||||||
|
const registered: MattermostRegisteredCommand[] = [];
|
||||||
|
let teamRegistrationFailures = 0;
|
||||||
|
|
||||||
|
for (const team of params.teams) {
|
||||||
|
try {
|
||||||
|
const created = await registerSlashCommands({
|
||||||
|
client: params.client,
|
||||||
|
teamId: team.id,
|
||||||
|
creatorUserId: params.botUserId,
|
||||||
|
callbackUrl: params.callbackUrl,
|
||||||
|
commands: params.commands,
|
||||||
|
log: (msg) => params.runtime.log?.(msg),
|
||||||
|
});
|
||||||
|
registered.push(...created);
|
||||||
|
} catch (err) {
|
||||||
|
teamRegistrationFailures += 1;
|
||||||
|
params.runtime.error?.(
|
||||||
|
`mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { registered, teamRegistrationFailures };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerMattermostMonitorSlashCommands(params: {
|
||||||
|
client: MattermostClient;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
account: ResolvedMattermostAccount;
|
||||||
|
baseUrl: string;
|
||||||
|
botUserId: string;
|
||||||
|
}) {
|
||||||
|
const commandsRaw = params.account.config.commands as
|
||||||
|
| Partial<MattermostSlashCommandConfig>
|
||||||
|
| undefined;
|
||||||
|
const slashConfig = resolveSlashCommandConfig(commandsRaw);
|
||||||
|
if (!isSlashCommandsEnabled(slashConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const teams = await fetchMattermostUserTeams(params.client, params.botUserId);
|
||||||
|
const envPort = 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>> = [];
|
||||||
|
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
||||||
|
private errorListeners: Array<(err: unknown) => void> = [];
|
||||||
|
|
||||||
|
on(event: "open", listener: () => void): void;
|
||||||
|
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
||||||
|
on(event: "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<void>);
|
||||||
|
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<Record<string, unknown>> = [];
|
||||||
|
const sockets: FakeWebSocket[] = [];
|
||||||
|
|
||||||
|
const connectOnce = createMattermostConnectOnce({
|
||||||
|
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||||
|
botToken: "token",
|
||||||
|
runtime: testRuntime(),
|
||||||
|
nextSeq: (() => {
|
||||||
|
let seq = 1;
|
||||||
|
return () => seq++;
|
||||||
|
})(),
|
||||||
|
onPosted: async () => {},
|
||||||
|
statusSink: (patch) => {
|
||||||
|
patches.push(patch as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
webSocketFactory: () => {
|
||||||
|
const socket = new FakeWebSocket();
|
||||||
|
const attempt = sockets.length;
|
||||||
|
sockets.push(socket);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (attempt === 0) {
|
||||||
|
socket.emitError(new Error("boom"));
|
||||||
|
socket.emitClose(1006, "connection refused");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.emitOpen();
|
||||||
|
socket.emitClose(1000);
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAttempt = connectOnce();
|
||||||
|
await expect(firstAttempt).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||||
|
|
||||||
|
await connectOnce();
|
||||||
|
|
||||||
|
expect(sockets).toHaveLength(2);
|
||||||
|
expect(sockets[0].closeCalls).toBe(1);
|
||||||
|
expect(sockets[1].sent).toHaveLength(1);
|
||||||
|
expect(JSON.parse(sockets[1].sent[0])).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<number | Error> = [new Error("network error"), 1000, 2000];
|
||||||
|
const connectOnce = createMattermostConnectOnce({
|
||||||
|
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||||
|
botToken: "token",
|
||||||
|
runtime,
|
||||||
|
nextSeq: () => 1,
|
||||||
|
onPosted: async () => {},
|
||||||
|
webSocketFactory: () => socket,
|
||||||
|
getBotUpdateAt: async () => {
|
||||||
|
const next = responses.shift();
|
||||||
|
if (next instanceof Error) {
|
||||||
|
throw next;
|
||||||
|
}
|
||||||
|
return next ?? 2000;
|
||||||
|
},
|
||||||
|
healthCheckIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connected = connectOnce();
|
||||||
|
socket.emitOpen();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
|
"mattermost: failed to get initial update_at: Error: network error",
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(socket.terminateCalls).toBe(0);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(socket.terminateCalls).toBe(1);
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.emitClose(1006);
|
||||||
|
await connected;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overlap health checks when a prior poll is still running", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const socket = new FakeWebSocket();
|
||||||
|
const resolvers: Array<(value: number) => void> = [];
|
||||||
|
let pollCount = 0;
|
||||||
|
const connectOnce = createMattermostConnectOnce({
|
||||||
|
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||||
|
botToken: "token",
|
||||||
|
runtime: testRuntime(),
|
||||||
|
nextSeq: () => 1,
|
||||||
|
onPosted: async () => {},
|
||||||
|
webSocketFactory: () => socket,
|
||||||
|
getBotUpdateAt: async () => {
|
||||||
|
pollCount++;
|
||||||
|
return await new Promise<number>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
healthCheckIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connected = connectOnce();
|
||||||
|
socket.emitOpen();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(pollCount).toBe(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
expect(pollCount).toBe(1);
|
||||||
|
|
||||||
|
resolvers[0]?.(1000);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(pollCount).toBe(2);
|
||||||
|
|
||||||
|
socket.emitClose(1000);
|
||||||
|
await connected;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
channel_id?: string;
|
||||||
|
channel_name?: string;
|
||||||
|
channel_display_name?: string;
|
||||||
|
channel_type?: string;
|
||||||
|
sender_name?: string;
|
||||||
|
team_id?: string;
|
||||||
|
};
|
||||||
|
broadcast?: {
|
||||||
|
channel_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostWebSocketLike = {
|
||||||
|
on(event: "open", listener: () => void): void;
|
||||||
|
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
|
||||||
|
on(event: "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<MattermostEventPayload>;
|
||||||
|
|
||||||
|
function parseMattermostEventPayload(raw: string): MattermostEventPayload | null {
|
||||||
|
return safeParseJsonWithSchema(MattermostEventPayloadSchema, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMattermostPost(value: unknown): MattermostPost | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return safeParseJsonWithSchema(MattermostPostSchema, value);
|
||||||
|
}
|
||||||
|
return safeParseWithSchema(MattermostPostSchema, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebSocketClosedBeforeOpenError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: number,
|
||||||
|
public readonly reason?: string,
|
||||||
|
) {
|
||||||
|
super(`websocket closed before open (code ${code})`);
|
||||||
|
this.name = "WebSocketClosedBeforeOpenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateMattermostConnectOnceOpts = {
|
||||||
|
wsUrl: string;
|
||||||
|
botToken: string;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
nextSeq: () => number;
|
||||||
|
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
||||||
|
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
|
||||||
|
webSocketFactory?: MattermostWebSocketFactory;
|
||||||
|
/**
|
||||||
|
* Called periodically to check whether the bot account has been modified
|
||||||
|
* (e.g. disabled then re-enabled) since the WebSocket was opened.
|
||||||
|
* Returns the bot's current `update_at` timestamp. When it differs from
|
||||||
|
* the value recorded at connect time, the connection is terminated so the
|
||||||
|
* reconnect loop can establish a fresh one.
|
||||||
|
*/
|
||||||
|
getBotUpdateAt?: () => Promise<number>;
|
||||||
|
healthCheckIntervalMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve, reject) => {
|
||||||
|
let opened = false;
|
||||||
|
let settled = false;
|
||||||
|
let healthCheckEnabled = getBotUpdateAt != null;
|
||||||
|
let healthCheckInFlight = false;
|
||||||
|
let healthCheckTimer: ReturnType<typeof setTimeout> | 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") : "";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MattermostProbe> {
|
||||||
|
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||||
|
if (!normalized) {
|
||||||
|
return { ok: false, error: "baseUrl missing" };
|
||||||
|
}
|
||||||
|
const url = `${normalized}/api/v4/users/me`;
|
||||||
|
const start = Date.now();
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof fetch>(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<T>(
|
||||||
|
fetchImpl: MattermostFetch,
|
||||||
|
run: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const prevFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = fetchImpl;
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = prevFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
type MutationPayload = { userId: string; postId: string; emojiName: string };
|
||||||
|
|
||||||
|
const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
|
||||||
|
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
async function resolveBotUserId(
|
||||||
|
client: MattermostClient,
|
||||||
|
cacheKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const 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<Result> {
|
||||||
|
return runMattermostReaction(params, {
|
||||||
|
action: "add",
|
||||||
|
mutation: createReaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMattermostReaction(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
postId: string;
|
||||||
|
emojiName: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
fetchImpl?: MattermostFetch;
|
||||||
|
}): Promise<Result> {
|
||||||
|
return runMattermostReaction(params, {
|
||||||
|
action: "remove",
|
||||||
|
mutation: deleteReaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMattermostReactionBotUserCacheForTests(): void {
|
||||||
|
botUserIdCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMattermostReaction(
|
||||||
|
params: ReactionParams,
|
||||||
|
options: {
|
||||||
|
action: "add" | "remove";
|
||||||
|
mutation: ReactionMutation;
|
||||||
|
},
|
||||||
|
): Promise<Result> {
|
||||||
|
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const baseUrl = resolved.baseUrl?.trim();
|
||||||
|
const botToken = resolved.botToken?.trim();
|
||||||
|
if (!baseUrl || !botToken) {
|
||||||
|
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl,
|
||||||
|
botToken,
|
||||||
|
fetchImpl: params.fetchImpl,
|
||||||
|
allowPrivateNetwork: 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<void> {
|
||||||
|
await client.request<Record<string, unknown>>("/reactions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: params.userId,
|
||||||
|
post_id: params.postId,
|
||||||
|
emoji_name: params.emojiName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
|
||||||
|
const emoji = encodeURIComponent(params.emojiName);
|
||||||
|
await client.request<unknown>(
|
||||||
|
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>): Promise<void> {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runWithReconnect", () => {
|
||||||
|
it("retries after connectFn resolves (normal close)", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const connectFn = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount >= 3) {
|
||||||
|
abort.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = runWithReconnect(connectFn, {
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
await resolveReconnectRun(run);
|
||||||
|
|
||||||
|
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries after connectFn throws (connection error)", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const connectFn = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount < 3) {
|
||||||
|
throw new Error("fetch failed");
|
||||||
|
}
|
||||||
|
abort.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = runWithReconnect(connectFn, {
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
onError,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
await resolveReconnectRun(run);
|
||||||
|
|
||||||
|
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(onError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onError).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>,
|
||||||
|
opts: RunWithReconnectOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
|
||||||
|
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
|
||||||
|
const random = opts.random ?? Math.random;
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[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<unknown>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "../../runtime-api.js";
|
||||||
@@ -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<typeof import("./send.js").sendMessageMattermost>[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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Buffer>;
|
||||||
|
replyToId?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
buttons?: Array<unknown>;
|
||||||
|
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<string, MattermostUser>();
|
||||||
|
const userByNameCache = new Map<string, MattermostUser>();
|
||||||
|
const channelByNameCache = new Map<string, string>();
|
||||||
|
const dmChannelCache = new Map<string, string>();
|
||||||
|
|
||||||
|
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<MattermostUser> {
|
||||||
|
const key = cacheKey(baseUrl, token);
|
||||||
|
const cached = botUserCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
|
||||||
|
const user = await fetchMattermostMe(client);
|
||||||
|
botUserCache.set(key, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserIdByUsername(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
allowPrivateNetwork?: boolean;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { baseUrl, token, username } = params;
|
||||||
|
const key = `${cacheKey(baseUrl, token)}::${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<string> {
|
||||||
|
const { baseUrl, token, name } = params;
|
||||||
|
const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`;
|
||||||
|
const cached = channelByNameCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl,
|
||||||
|
botToken: token,
|
||||||
|
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<string> {
|
||||||
|
if (params.target.kind === "channel") {
|
||||||
|
return params.target.id;
|
||||||
|
}
|
||||||
|
if (params.target.kind === "channel-name") {
|
||||||
|
return await resolveChannelIdByName({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
token: params.token,
|
||||||
|
name: params.target.name,
|
||||||
|
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const userId = params.target.id
|
||||||
|
? params.target.id
|
||||||
|
: await resolveUserIdByUsername({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
token: params.token,
|
||||||
|
username: params.target.username ?? "",
|
||||||
|
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||||
|
});
|
||||||
|
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
|
||||||
|
const cachedDm = dmChannelCache.get(dmKey);
|
||||||
|
if (cachedDm) {
|
||||||
|
return cachedDm;
|
||||||
|
}
|
||||||
|
const botUser = await resolveBotUser(params.baseUrl, params.token, params.allowPrivateNetwork);
|
||||||
|
const client = createMattermostClient({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
botToken: params.token,
|
||||||
|
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||||
|
});
|
||||||
|
|
||||||
|
const 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<MattermostSendContext> {
|
||||||
|
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<string> {
|
||||||
|
return (await resolveMattermostSendContext(to, opts)).channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessageMattermost(
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts: MattermostSendOpts = {},
|
||||||
|
): Promise<MattermostSendResult> {
|
||||||
|
const core = getCore();
|
||||||
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
|
const { cfg, accountId, token, baseUrl, channelId, allowPrivateNetwork } =
|
||||||
|
await resolveMattermostSendContext(to, opts);
|
||||||
|
|
||||||
|
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
|
||||||
|
let props = opts.props;
|
||||||
|
if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) {
|
||||||
|
setInteractionSecret(accountId, token);
|
||||||
|
props = buildButtonProps({
|
||||||
|
callbackUrl: resolveInteractionCallbackUrl(accountId, {
|
||||||
|
gateway: cfg.gateway,
|
||||||
|
interactions: resolveMattermostAccount({
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
}).config?.interactions,
|
||||||
|
}),
|
||||||
|
accountId,
|
||||||
|
channelId,
|
||||||
|
buttons: opts.buttons,
|
||||||
|
text: opts.attachmentText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let message = 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<unknown>,
|
||||||
|
) {
|
||||||
|
const client: MattermostClient = {
|
||||||
|
baseUrl: "https://chat.example.com",
|
||||||
|
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||||
|
// pragma: allowlist secret
|
||||||
|
token: "bot-token",
|
||||||
|
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
|
||||||
|
fetchImpl: vi.fn<typeof fetch>(),
|
||||||
|
};
|
||||||
|
return registerSlashCommands({
|
||||||
|
client,
|
||||||
|
teamId: "team-1",
|
||||||
|
creatorUserId: "bot-user",
|
||||||
|
callbackUrl: "http://gateway/callback",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
trigger: "oc_status",
|
||||||
|
description: "status",
|
||||||
|
autoComplete: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("parses application/x-www-form-urlencoded payloads", () => {
|
||||||
|
const payload = parseSlashCommandPayload(
|
||||||
|
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
);
|
||||||
|
expect(payload).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<string, string>([["oc_status", "status"]]);
|
||||||
|
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
|
||||||
|
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
|
||||||
|
expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai");
|
||||||
|
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers both public model slash commands", () => {
|
||||||
|
expect(
|
||||||
|
DEFAULT_COMMAND_SPECS.filter(
|
||||||
|
(spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models",
|
||||||
|
).map((spec) => spec.trigger),
|
||||||
|
).toEqual(["oc_model", "oc_models"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `/<command> <args>` 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<MattermostCommandResponse[]> {
|
||||||
|
return await client.request<MattermostCommandResponse[]>(
|
||||||
|
`/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<MattermostCommandResponse> {
|
||||||
|
return await client.request<MattermostCommandResponse>("/commands", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a custom slash command.
|
||||||
|
*/
|
||||||
|
export async function deleteMattermostCommand(
|
||||||
|
client: MattermostClient,
|
||||||
|
commandId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing custom slash command.
|
||||||
|
*/
|
||||||
|
export async function updateMattermostCommand(
|
||||||
|
client: MattermostClient,
|
||||||
|
params: MattermostCommandUpdate,
|
||||||
|
): Promise<MattermostCommandResponse> {
|
||||||
|
return await client.request<MattermostCommandResponse>(
|
||||||
|
`/commands/${encodeURIComponent(params.id)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all OpenClaw slash commands for a given team.
|
||||||
|
* Skips commands that are already registered with the same trigger + callback URL.
|
||||||
|
* Returns the list of newly created command IDs.
|
||||||
|
*/
|
||||||
|
export async function registerSlashCommands(params: {
|
||||||
|
client: MattermostClient;
|
||||||
|
teamId: string;
|
||||||
|
creatorUserId: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
commands: MattermostCommandSpec[];
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
}): Promise<MattermostRegisteredCommand[]> {
|
||||||
|
const { client, teamId, creatorUserId, callbackUrl, commands, log } = params;
|
||||||
|
const normalizedCreatorUserId = creatorUserId.trim();
|
||||||
|
if (!normalizedCreatorUserId) {
|
||||||
|
throw new Error("creatorUserId is required for slash command reconciliation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing commands to avoid duplicates
|
||||||
|
let existing: MattermostCommandResponse[] = [];
|
||||||
|
try {
|
||||||
|
existing = await listMattermostCommands(client, teamId);
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost: failed to list existing commands: ${String(err)}`);
|
||||||
|
// Fail closed: if we can't list existing commands, we should not attempt to
|
||||||
|
// create/update anything because we may create duplicates and end up with an
|
||||||
|
// empty/partial token set (causing callbacks to be rejected until restart).
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingByTrigger = new Map<string, MattermostCommandResponse[]>();
|
||||||
|
for (const cmd of existing) {
|
||||||
|
const list = existingByTrigger.get(cmd.trigger) ?? [];
|
||||||
|
list.push(cmd);
|
||||||
|
existingByTrigger.set(cmd.trigger, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered: MattermostRegisteredCommand[] = [];
|
||||||
|
|
||||||
|
for (const spec of commands) {
|
||||||
|
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
|
||||||
|
const ownedCommands = existingForTrigger.filter(
|
||||||
|
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
|
||||||
|
);
|
||||||
|
const foreignCommands = existingForTrigger.filter(
|
||||||
|
(cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownedCommands.length === 0 && foreignCommands.length > 0) {
|
||||||
|
log?.(
|
||||||
|
`mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownedCommands.length > 1) {
|
||||||
|
log?.(
|
||||||
|
`mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCmd = ownedCommands[0];
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
const { client, commands, log } = params;
|
||||||
|
for (const cmd of commands) {
|
||||||
|
if (!cmd.managed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteMattermostCommand(client, cmd.id);
|
||||||
|
log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`);
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Callback parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Mattermost slash command callback payload from a URL-encoded or JSON body.
|
||||||
|
*/
|
||||||
|
export function parseSlashCommandPayload(
|
||||||
|
body: string,
|
||||||
|
contentType?: string,
|
||||||
|
): MattermostSlashCommandPayload | null {
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validate required fields (same checks as the form-encoded branch)
|
||||||
|
const token = typeof parsed.token === "string" ? parsed.token : "";
|
||||||
|
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "";
|
||||||
|
const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : "";
|
||||||
|
const userId = typeof parsed.user_id === "string" ? parsed.user_id : "";
|
||||||
|
const command = typeof parsed.command === "string" ? parsed.command : "";
|
||||||
|
|
||||||
|
if (!token || !teamId || !channelId || !userId || !command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
team_id: teamId,
|
||||||
|
team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined,
|
||||||
|
channel_id: channelId,
|
||||||
|
channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined,
|
||||||
|
user_id: userId,
|
||||||
|
user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined,
|
||||||
|
command,
|
||||||
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||||
|
trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined,
|
||||||
|
response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: application/x-www-form-urlencoded
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
const token = params.get("token");
|
||||||
|
const teamId = params.get("team_id");
|
||||||
|
const channelId = params.get("channel_id");
|
||||||
|
const userId = params.get("user_id");
|
||||||
|
const command = params.get("command");
|
||||||
|
|
||||||
|
if (!token || !teamId || !channelId || !userId || !command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
team_id: teamId,
|
||||||
|
team_domain: params.get("team_domain") ?? undefined,
|
||||||
|
channel_id: channelId,
|
||||||
|
channel_name: params.get("channel_name") ?? undefined,
|
||||||
|
user_id: userId,
|
||||||
|
user_name: params.get("user_name") ?? undefined,
|
||||||
|
command,
|
||||||
|
text: params.get("text") ?? "",
|
||||||
|
trigger_id: params.get("trigger_id") ?? undefined,
|
||||||
|
response_url: params.get("response_url") ?? undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the trigger word back to the original OpenClaw command name.
|
||||||
|
* e.g. "oc_status" -> "/status", "oc_model" -> "/model"
|
||||||
|
*/
|
||||||
|
export function resolveCommandText(
|
||||||
|
trigger: string,
|
||||||
|
text: string,
|
||||||
|
triggerMap?: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
|
// Use the trigger map if available for accurate name resolution
|
||||||
|
const commandName =
|
||||||
|
triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger);
|
||||||
|
const args = text.trim();
|
||||||
|
return args ? `/${commandName} ${args}` : `/${commandName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the callback path starts with a leading `/` to prevent
|
||||||
|
* malformed URLs like `http://host:portapi/...`.
|
||||||
|
*/
|
||||||
|
function normalizeCallbackPath(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed) return DEFAULT_CALLBACK_PATH;
|
||||||
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSlashCommandConfig(
|
||||||
|
raw?: Partial<MattermostSlashCommandConfig>,
|
||||||
|
): MattermostSlashCommandConfig {
|
||||||
|
return {
|
||||||
|
native: raw?.native ?? "auto",
|
||||||
|
nativeSkills: raw?.nativeSkills ?? "auto",
|
||||||
|
callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH),
|
||||||
|
callbackUrl: 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<MattermostSlashCommandConfig>) {
|
||||||
|
const config = resolveSlashCommandConfig(raw);
|
||||||
|
const paths = new Set<string>([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}`;
|
||||||
|
}
|
||||||
@@ -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<typeof import("./client.js")>("./client.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createMattermostClient: mockState.createMattermostClient,
|
||||||
|
fetchMattermostChannel: mockState.fetchMattermostChannel,
|
||||||
|
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
|
||||||
|
sendMattermostTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./model-picker.js", () => ({
|
||||||
|
renderMattermostModelSummaryView: vi.fn(),
|
||||||
|
renderMattermostModelsPickerView: vi.fn(),
|
||||||
|
renderMattermostProviderPickerView: vi.fn(),
|
||||||
|
resolveMattermostModelPickerCurrentModel: vi.fn(),
|
||||||
|
resolveMattermostModelPickerEntry: mockState.resolveMattermostModelPickerEntry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./monitor-auth.js", () => ({
|
||||||
|
authorizeMattermostCommandInvocation: mockState.authorizeMattermostCommandInvocation,
|
||||||
|
normalizeMattermostAllowList: mockState.normalizeMattermostAllowList,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./reply-delivery.js", () => ({
|
||||||
|
deliverMattermostReplyPayload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessageMattermost: mockState.sendMessageMattermost,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./slash-commands.js", () => ({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
} {
|
||||||
|
let body = "";
|
||||||
|
const headers = new Map<string, string>();
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
headers.set(name.toLowerCase(), value);
|
||||||
|
},
|
||||||
|
end(chunk?: string | Buffer) {
|
||||||
|
body = chunk ? String(chunk) : "";
|
||||||
|
},
|
||||||
|
} as ServerResponse;
|
||||||
|
return {
|
||||||
|
res,
|
||||||
|
getBody: () => body,
|
||||||
|
getHeaders: () => headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountFixture: ResolvedMattermostAccount = {
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
// pragma: allowlist secret
|
||||||
|
botToken: "bot-token",
|
||||||
|
baseUrl: "https://chat.example.com",
|
||||||
|
botTokenSource: "config",
|
||||||
|
baseUrlSource: "config",
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runSlashRequest(params: {
|
||||||
|
commandTokens: Set<string>;
|
||||||
|
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<string>(),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>;
|
||||||
|
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||||
|
triggerMap?: ReadonlyMap<string, string>;
|
||||||
|
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<string> {
|
||||||
|
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<string>,
|
||||||
|
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<typeof createMattermostClient>;
|
||||||
|
commandText: string;
|
||||||
|
channelId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
}): Promise<SlashInvocationAuth> {
|
||||||
|
const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params;
|
||||||
|
const core = getMattermostRuntime();
|
||||||
|
|
||||||
|
// Resolve channel info so we can enforce DM vs group/channel policies.
|
||||||
|
let channelInfo: MattermostChannel | null = null;
|
||||||
|
try {
|
||||||
|
channelInfo = await fetchMattermostChannel(client, channelId);
|
||||||
|
} catch (err) {
|
||||||
|
log?.(`mattermost: slash channel lookup failed for ${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<void> => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
res.end("Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
try {
|
||||||
|
body = await readBody(req, MAX_BODY_BYTES);
|
||||||
|
} 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<typeof createMattermostClient>;
|
||||||
|
commandText: string;
|
||||||
|
channelId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
teamId: string;
|
||||||
|
kind: "direct" | "group" | "channel";
|
||||||
|
chatType: "direct" | "group" | "channel";
|
||||||
|
channelName: string;
|
||||||
|
channelDisplay: string;
|
||||||
|
roomLabel: string;
|
||||||
|
commandAuthorized: boolean;
|
||||||
|
triggerId?: string;
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
cfg,
|
||||||
|
runtime,
|
||||||
|
client,
|
||||||
|
commandText,
|
||||||
|
channelId,
|
||||||
|
senderId,
|
||||||
|
senderName,
|
||||||
|
teamId,
|
||||||
|
kind,
|
||||||
|
chatType,
|
||||||
|
channelName,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>;
|
||||||
|
/** Registered command IDs for cleanup on shutdown. */
|
||||||
|
registeredCommands: MattermostRegisteredCommand[];
|
||||||
|
/** Current HTTP handler for this account. */
|
||||||
|
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | null;
|
||||||
|
/** The account that activated slash commands. */
|
||||||
|
account: ResolvedMattermostAccount;
|
||||||
|
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||||
|
triggerMap: Map<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map from accountId → per-account slash command state. */
|
||||||
|
const accountStates = new Map<string, SlashCommandAccountState>();
|
||||||
|
|
||||||
|
export function resolveSlashHandlerForToken(token: string): {
|
||||||
|
kind: "none" | "single" | "ambiguous";
|
||||||
|
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||||
|
accountIds?: string[];
|
||||||
|
} {
|
||||||
|
const matches: Array<{
|
||||||
|
accountId: string;
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [accountId, state] of accountStates) {
|
||||||
|
if (state.commandTokens.has(token) && state.handler) {
|
||||||
|
matches.push({ accountId, handler: state.handler });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return { kind: "single", 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<string, SlashCommandAccountState> {
|
||||||
|
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<string, string>;
|
||||||
|
api: {
|
||||||
|
cfg: import("./runtime-api.js").OpenClawConfig;
|
||||||
|
runtime: import("./runtime-api.js").RuntimeEnv;
|
||||||
|
};
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const { account, commandTokens, registeredCommands, triggerMap, api, log } = params;
|
||||||
|
const accountId = account.accountId;
|
||||||
|
|
||||||
|
const tokenSet = new Set(commandTokens);
|
||||||
|
|
||||||
|
const handler = createSlashCommandHttpHandler({
|
||||||
|
account,
|
||||||
|
cfg: api.cfg,
|
||||||
|
runtime: api.runtime,
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
const addCallbackPaths = (
|
||||||
|
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | undefined,
|
||||||
|
) => {
|
||||||
|
const resolved = resolveSlashCommandConfig(raw);
|
||||||
|
callbackPaths.add(resolved.callbackPath);
|
||||||
|
if (resolved.callbackUrl) {
|
||||||
|
try {
|
||||||
|
const urlPath = new URL(resolved.callbackUrl).pathname;
|
||||||
|
if (urlPath && urlPath !== resolved.callbackPath) {
|
||||||
|
callbackPaths.add(urlPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL — ignore, will be caught during registration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandsRaw = mmConfig?.commands as
|
||||||
|
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||||
|
| undefined;
|
||||||
|
addCallbackPaths(commandsRaw);
|
||||||
|
|
||||||
|
const accountsRaw = mmConfig?.accounts ?? {};
|
||||||
|
for (const accountId of Object.keys(accountsRaw)) {
|
||||||
|
const accountCommandsRaw = accountsRaw[accountId]?.commands;
|
||||||
|
addCallbackPaths(accountCommandsRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
if (accountStates.size === 0) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
response_type: "ephemeral",
|
||||||
|
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to peek at the 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, boolean>();
|
||||||
|
|
||||||
|
function cacheKey(baseUrl: string, token: string, id: string): string {
|
||||||
|
return `${baseUrl}::${token}::${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
|
||||||
|
export function isMattermostId(value: string): boolean {
|
||||||
|
return /^[a-z0-9]{26}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExplicitMattermostTarget(raw: string): boolean {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
/^(channel|user|mattermost):/i.test(trimmed) ||
|
||||||
|
trimmed.startsWith("@") ||
|
||||||
|
trimmed.startsWith("#")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMattermostApiStatus(err: unknown): number | undefined {
|
||||||
|
if (!err || typeof err !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const msg = "message" in err ? 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<MattermostOpaqueTargetResolution | null> {
|
||||||
|
const input = params.input.trim();
|
||||||
|
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account =
|
||||||
|
params.cfg && (!params.token || !params.baseUrl)
|
||||||
|
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
|
||||||
|
: null;
|
||||||
|
const token = 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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
export function normalizeAccountId(id: string): string {
|
||||||
|
return id || "default";
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function resolveMergedAccountConfig<T>(params: {
|
||||||
|
channelConfig?: Record<string, unknown>;
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
accountId: string;
|
||||||
|
omitKeys?: string[];
|
||||||
|
nestedObjectKeys?: string[];
|
||||||
|
}): T {
|
||||||
|
const { channelConfig = {}, accounts = {}, accountId } = params;
|
||||||
|
const accountConfig = accounts[accountId] || {};
|
||||||
|
return {
|
||||||
|
...channelConfig,
|
||||||
|
...accountConfig,
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function createResolvedApproverActionAuthAdapter() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApprovalApprovers() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function resolveChannelGroupRequireMention() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface OpenClawConfig {
|
||||||
|
channels?: {
|
||||||
|
mattermost?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
botToken?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export async function readChannelAllowFromStore(
|
||||||
|
channelId: string,
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
accountId?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user