Initial commit: OpenClaw Mattermost Extension

This commit is contained in:
2026-04-10 21:57:29 -07:00
commit 2072259fb6
126 changed files with 26403 additions and 0 deletions
+149
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
tests/
test-results/
.sisyphus/
src/
extensions/mattermost/src/**/*.test.ts
+71
View File
@@ -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
View File
@@ -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!
+201
View File
@@ -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.
+507
View File
@@ -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
View File
@@ -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';
```
+562
View File
@@ -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.
+29
View File
@@ -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'],
},
];
+3
View File
@@ -0,0 +1,3 @@
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
// channel plugin (and its runtime state) into tests or other shared surfaces.
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
+6
View File
@@ -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";
+20
View File
@@ -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": {}
}
}
+41
View File
@@ -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"
}
}
}
+88
View File
@@ -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";
+4
View File
@@ -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),
});
+8
View File
@@ -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";
+618
View File
@@ -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]");
});
});
});
+730
View File
@@ -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);
});
});
+135
View File
@@ -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);
+322
View File
@@ -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 };
+448
View File
@@ -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);
});
});
});
+855
View File
@@ -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
});
});
+50
View File
@@ -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