feat: add matrix plugin with V1/V2 SDK support

- Dual V1/V2 plugin export pattern
- Session persistence via ~/.opencode-matrix-sessions.json
- Thread-to-session mapping with deterministic IDs
- Matrix bot integration with E2EE support
- Config from matrix.json or environment variables
This commit is contained in:
Бородин Роман 2026-09-25 15:22:20 +03:00
commit 63853de81f
13 changed files with 9511 additions and 0 deletions

1
plugins/matrix-plugin/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,992 @@
# OpenCode V2 Plugin Documentation
> Сгенерировано: 2026-09-25 | Версия OpenCode: v2.x | @opencode/plugin: v2.x
---
## Table of Contents
1. [Overview](#overview)
2. [Plugin Loading](#plugin-loading)
3. [Plugin Structure](#plugin-structure)
4. [Plugin API (Promise API)](#plugin-api-promise-api)
5. [Context Object](#context-object)
6. [Hooks](#hooks)
7. [Custom Tools](#custom-tools)
8. [Events](#events)
9. [Configuration](#configuration)
10. [External Dependencies](#external-dependencies)
11. [Local Plugin Pitfalls](#local-plugin-pitfalls)
12. [Debugging](#debugging)
13. [Complete Examples](#complete-examples)
---
## Overview
OpenCode V2 plugins extend the agent by adding custom tools, hooks, event handlers, and integrations. A plugin is a TypeScript/JavaScript module that uses `Plugin.define()` from `@opencode/plugin` to register capabilities.
### Key Concepts
- **Plugin**: A module that calls `Plugin.define({ id, setup(ctx) })` and exports it as default.
- **setup(ctx)**: An async function that receives a context object and returns a cleanup function.
- **ctx**: The plugin context providing access to all OpenCode domains (session, tool, event, storage, etc.).
- **Hooks**: Functions registered on domain-specific APIs (e.g., `ctx.session.hook()`, `ctx.tool.hook()`).
- **Events**: Subscribable event streams via `ctx.event.subscribe()`.
- **Custom Tools**: Tools defined with `tool()` helper and registered via `ctx.tool.transform()`.
---
## Plugin Loading
### Discovery Locations
OpenCode discovers plugins from multiple sources, loaded in this order:
| Source | Path | Auto-discovered |
|--------|------|-----------------|
| Global config | `~/.config/opencode/opencode.json` | N/A (explicit) |
| Project config | `opencode.json` | N/A (explicit) |
| Global plugin dir | `~/.config/opencode/plugins/` | Yes |
| Project plugin dir | `.opencode/plugins/` | Yes |
### Discovery Rules
**Global plugin directory** (`~/.config/opencode/plugins/`):
- Direct `.ts` and `.js` files are loaded as plugins
- Immediate subdirectories containing a `package.json` are loaded as plugin packages
- Nested directories are NOT auto-discovered
**Project plugin directory** (`.opencode/plugins/`):
- Same rules as global
### Explicit Configuration
Plugins can also be explicitly listed in `opencode.json`:
```jsonc
{
"plugins": [
// npm package
"opencode-acme-plugin",
// npm package with version
"opencode-acme-plugin@1.2.0",
// scoped npm package
"@acme/opencode-plugin",
// local file path (relative to config file)
"./plugins/local",
// absolute file path
"/home/user/plugin.ts",
// file:// URL
"file:///home/user/plugins/local",
// package with options
{
"package": "@acme/opencode-plugin",
"options": { "agent": "reviewer", "strict": true }
}
]
}
```
### Plugin Control
Prefix an ID or wildcard with `-` to disable:
```jsonc
{
"plugins": [
"*", // enable all
"-opencode.provider.*", // disable all providers
"opencode.provider.openai", // re-enable OpenAI
"-acme.reviewer" // disable specific plugin
]
}
```
Two built-in plugins always ignore removals: `opencode.config.policy` and `opencode.provider.opencode`.
---
## Plugin Structure
### Minimal Plugin
```typescript
// ~/.config/opencode/plugins/my-plugin.ts
export default {
id: "my-plugin",
async setup(ctx) {
console.log("Plugin loaded!")
// cleanup function (optional)
return () => {
console.log("Plugin cleaning up")
}
},
}
```
### Plugin with Package.json (Directory Plugin)
```
~/.config/opencode/plugins/my-plugin/
├── package.json # Required: defines name, dependencies
├── index.ts # Entry point (or main field in package.json)
├── helper.ts # Additional modules
└── node_modules/ # Dependencies (manually installed)
```
**package.json**:
```json
{
"name": "my-opencode-plugin",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@opencode/plugin": "^2.0.16",
"some-npm-package": "^1.0.0"
}
}
```
**index.ts**:
```typescript
import { Plugin, tool } from "@opencode/plugin"
export default Plugin.define({
id: "my-opencode-plugin",
async setup(ctx) {
// Plugin initialization
const result = await someNpmPackage.doSomething()
console.log(`Result: ${result}`)
return {
// hooks
}
},
})
```
---
## Plugin API (Promise API)
The Promise API is the primary way to write V2 plugins:
```typescript
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
// Register hooks, tools, etc.
// ...
// Return cleanup function (optional)
return () => {
// Release resources
}
},
})
```
### Plugin.define() Signature
```typescript
Plugin.define({
id: string, // Unique plugin identifier (required)
async setup(ctx): PluginContext // Setup function (required)
}): PluginDefinition
```
### Effect API (Alternative)
For plugins that need Effect-TS:
```typescript
import { Plugin } from "@opencode/plugin/effect"
```
---
## Context Object
The `ctx` parameter in `setup()` provides access to all OpenCode domains:
### Core Properties
| Property | Type | Description |
|----------|------|-------------|
| `ctx.location` | Location | Where this plugin was loaded (NOT where observed sessions are) |
| `ctx.location.directory` | string | Working directory of the plugin's location |
| `ctx.location.project` | ProjectInfo | Project info at plugin load time |
### Domain APIs
| Domain | Purpose |
|--------|---------|
| `ctx.app` | Application-level operations (logging, options) |
| `ctx.session` | Session management, prompts, hooks |
| `ctx.tool` | Tool hooks, transforms, custom tools |
| `ctx.event` | Event subscription |
| `ctx.storage` | Persistent key-value storage |
| `ctx.shell` | Shell command execution |
| `ctx.command` | Command transforms |
| `ctx.permission` | Permission hooks |
| `ctx.provider` | Provider transforms |
| `ctx.model` | Model transforms |
| `ctx.integration` | Integration/connection APIs |
| `ctx.mcp` | MCP server management |
| `ctx.skill` | Skill management |
| `ctx.agent` | Agent configuration |
| `ctx.vcs` | Version control operations |
| `ctx.websearch` | Web search |
| `ctx.rpc` | RPC calls |
| `ctx.reference` | Reference management |
| `ctx.worktree` | Git worktree operations |
### Important: ctx.location vs Observed Events
> **CRITICAL**: `ctx.location` describes where the plugin was loaded, NOT the location of observed sessions or events. Never infer a session's location from `ctx.location`. Use event/session data instead.
---
## Hooks
### Session Hooks
```typescript
// Prompt hook - called before a prompt is sent
ctx.session.hook("prompt", (event) => {
// Modify event before sending
event.metadata = { ...event.metadata, plugin: "my-plugin" }
})
// Context hook - modify context sent to LLM
ctx.session.hook("context", (input, output) => {
output.system.push(`<custom-context>Rules go here</custom-context>`)
})
// Compaction hook - customize context preservation
ctx.session.hook("compaction", (input, output) => {
output.context.push(`## Preserved state\n- Current task: ...`)
})
```
### Tool Hooks
```typescript
// Before tool execution
ctx.tool.hook("execute.before", (input, output) => {
if (input.tool === "bash" && output.args.command.includes("rm -rf")) {
throw new Error("Dangerous command blocked")
}
})
// After tool execution
ctx.tool.hook("execute.after", (input) => {
console.log(`Tool ${input.tool} completed`)
})
```
### Shell Hooks
```typescript
// Modify shell creation
ctx.shell.hook("create.before", (input, output) => {
output.env.MY_API_KEY = "secret"
output.env.PROJECT_ROOT = input.cwd
})
```
### Permission Hooks
```typescript
ctx.permission.hook("evaluate", (permission, output) => {
if (permission.type === "read_file") {
output.status = "allow"
}
})
```
### Event Subscription
```typescript
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.created") {
console.log(`New session: ${event.sessionID}`)
}
if (event.type === "session.idle") {
console.log("Session idle")
}
if (event.type === "message.updated") {
console.log("Message updated")
}
}
})()
// Cleanup
return () => {
controller.abort()
}
```
### Available Events
| Event Type | Description |
|------------|-------------|
| `session.created` | New session created |
| `session.updated` | Session updated |
| `session.deleted` | Session deleted |
| `session.error` | Session error |
| `session.idle` | Session became idle |
| `session.compacted` | Session compacted |
| `message.updated` | Message updated |
| `message.removed` | Message removed |
| `file.edited` | File edited |
| `permission.asked` | Permission requested |
| `permission.replied` | Permission responded |
| `server.connected` | Server connected |
| `tool.execute.before` | Tool about to execute |
| `tool.execute.after` | Tool finished executing |
---
## Custom Tools
```typescript
import { tool } from "@opencode/plugin"
// In setup():
ctx.tool.transform((editor) => {
editor.add({
name: "my-tool",
description: "Does something useful",
async execute({ sessionID }, delivery) {
// Tool implementation
return "Result"
},
})
})
```
### Using the `tool()` Helper
```typescript
import { tool } from "@opencode/plugin"
const myTool = tool({
description: "Search the web",
args: {
query: tool.schema.string().describe("Search query"),
maxResults: tool.schema.number().optional().describe("Max results"),
},
async execute(args, context) {
const { sessionID, agent } = context
// Implementation using ctx
return `Results for: ${args.query}`
},
})
```
---
## Events
### Subscribing to Events
```typescript
const eventController = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
// Handle event
console.log(`Event: ${event.type}`)
}
})()
```
### Event Update Types
When handling `session.updated` events, the `event.update` field can be:
| Type | Description |
|------|-------------|
| `agent_message_chunk` | Streaming text from the agent |
| `tool_call` | Tool invocation |
| `tool_call_update` | Tool progress/completion |
| `user_message_chunk` | User message fragment |
| `agent_thought_chunk` | Agent reasoning/thought |
---
## Configuration
### Plugin Options
Configure via `opencode.json`:
```jsonc
{
"plugins": [
{
"package": "./plugins/my-plugin",
"options": {
"apiKey": "secret",
"enabled": true
}
}
]
}
```
Access in plugin:
```typescript
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
const apiKey = ctx.options.apiKey
const enabled = ctx.options.enabled
},
})
```
### Structured Logging
```typescript
await ctx.app.log({
body: {
service: "my-plugin",
level: "info", // debug, info, warn, error
message: "Plugin initialized",
extra: { key: "value" },
},
})
```
---
## External Dependencies
### How Dependencies Work
OpenCode V2 does NOT automatically install dependencies for local plugins. You must manually install them.
### For Global Plugin Directory (`~/.config/opencode/plugins/`)
Dependencies must be installed in the config directory:
```json
// ~/.config/opencode/package.json
{
"dependencies": {
"@opencode/plugin": "^2.0.16",
"some-npm-package": "^1.0.0"
}
}
```
OpenCode runs `bun install` at startup for this file.
### For Local Plugin Package Directory
If your plugin is in a subdirectory with its own `package.json`:
```
~/.config/opencode/plugins/my-plugin/
├── package.json
├── index.ts
└── node_modules/ # <-- You must create this!
```
**You must manually run `bun install` (or `npm install`) inside the plugin directory:**
```bash
cd ~/.config/opencode/plugins/my-plugin
bun install
```
> **CRITICAL**: OpenCode does NOT automatically run `bun install` in plugin subdirectories. Dependencies in a plugin's own `package.json` will NOT be available unless you manually install them.
### For npm Package Plugins
Dependencies are installed automatically when the plugin is added via `opencode plugin add`.
---
## Local Plugin Pitfalls
### Pitfall 1: Dependencies Not Auto-Installed
**Problem**: Plugin has `package.json` with dependencies, but they're not available at runtime.
**Solution**: Run `bun install` in the plugin directory:
```bash
cd ~/.config/opencode/plugins/my-plugin
bun install
```
### Pitfall 2: Wrong Import Path
**Problem**: Using `@opencode-ai/plugin` (V1) instead of `@opencode/plugin` (V2).
**Solution**: Use the V2 import:
```typescript
// V2 (correct)
import { Plugin } from "@opencode/plugin"
// V1 (wrong for V2)
import type { Plugin } from "@opencode-ai/plugin"
```
### Pitfall 3: Mixing CommonJS and ESM
**Problem**: Using `require()` and `module.exports` in a TypeScript file loaded as ESM.
**Solution**: Use ES module syntax throughout:
```typescript
// Wrong - CommonJS in ESM context
const { MatrixClient } = require("matrix-bot-sdk")
module.exports = { MatrixBotClient }
// Correct - ES modules
import { MatrixClient } from "matrix-bot-sdk"
export class MatrixBotClient { ... }
```
### Pitfall 4: Plugin Not Loading (Silent Failure)
**Problem**: Plugin has a syntax error or import error, but no log is written.
**Solution**:
1. Check for TypeScript errors: `tsc --noEmit` in the plugin directory
2. Use `client.app.log()` for structured logging
3. Verify the plugin path is correct in `opencode.json`
4. Check that `@opencode/plugin` is installed in the dependency location
### Pitfall 5: Context Destructuring Error
**Problem**: Treating `ctx` as the client directly:
```typescript
// Wrong
export default Plugin.define({
id: "my-plugin",
async setup(client) {
await client.session.prompt(...) // FAILS: client.session.prompt doesn't exist
},
})
// Correct
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
await ctx.session.prompt(...) // Correct
},
})
```
### Pitfall 6: Hook Name Case Sensitivity
**Problem**: Using incorrect hook names.
**Solution**: Verify exact hook names from the API reference. Hook names are case-sensitive.
### Pitfall 7: Missing Cleanup
**Problem**: Event subscriptions or intervals not cleaned up on plugin unload.
**Solution**: Return a cleanup function:
```typescript
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
const interval = setInterval(() => { /* ... */ }, 60000)
const controller = new AbortController()
// Subscribe to events
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
// handle
}
})()
// Cleanup
return () => {
controller.abort()
clearInterval(interval)
}
},
})
```
### Pitfall 8: Using ctx.location for Session Data
**Problem**: Inferring session location from `ctx.location`:
```typescript
// Wrong
const sessionDir = ctx.location.directory // This is where plugin was loaded!
// Correct
const sessionDir = event.directory // Use event/session data
```
---
## Debugging
### Checklist
1. **Plugin not loading?**
- Check for TypeScript errors: `tsc --noEmit`
- Check import paths are correct
- Verify dependencies are installed
- Check plugin path in `opencode.json`
2. **Hooks not firing?**
- Verify hook names match exactly (case-sensitive)
- Check hook registration syntax
3. **State not persisting?**
- Use session-keyed Maps, not global variables
- Use `ctx.storage` for persistent data
4. **`ctx.session.prompt()` failing?**
- Verify destructuring: `async setup(ctx)` not `async setup(client)`
- Check session ID is valid
### Logging
Use structured logging via `ctx.app.log()`:
```typescript
await ctx.app.log({
body: {
service: "my-plugin",
level: "info",
message: "Plugin initialized",
extra: { key: "value" },
},
})
```
Levels: `debug`, `info`, `warn`, `error`.
### Verbose Mode
Run OpenCode with `--verbose` for more detailed plugin loading output.
---
## Complete Examples
### Example 1: Simple Notification Plugin
```typescript
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "notification-plugin",
async setup(ctx) {
void (async () => {
for await (const event of ctx.event.subscribe({
signal: new AbortController().signal,
})) {
if (event.type === "session.idle") {
await ctx.app.log({
body: {
service: "notification",
level: "info",
message: "Session completed",
},
})
}
}
})()
return () => {}
},
})
```
### Example 2: Plugin with Matrix Integration
```typescript
import { Plugin } from "@opencode/plugin"
import { MatrixClient } from "matrix-bot-sdk"
import { marked } from "marked"
export default Plugin.define({
id: "matrix-bot",
async setup(ctx) {
const options = ctx.options
if (!options.homeserver) {
await ctx.app.log({
body: { service: "matrix", level: "warn", message: "Disabled (no homeserver)" },
})
return () => {}
}
const client = new MatrixClient(options.homeserver)
const sessions = new Map<string, any>()
// Login
if (options.accessToken) {
await client.startWithToken("m.login.token", {
user_id: options.userId,
access_token: options.accessToken,
})
} else {
await client.startWithPassword(options.userId, options.password)
}
const userId = await client.getUserId()
await ctx.app.log({
body: { service: "matrix", level: "info", message: `Started as ${userId}` },
})
// Listen for messages
client.on("room.message", async (roomId, event) => {
if (event.type !== "m.room.message" || event.content.msgtype !== "m.text") return
if (event.sender === userId) return
const text = event.content.body?.trim()
if (!text?.startsWith("!oc ")) return
const query = text.slice(4).trim()
const sessionId = roomId
// Send prompt
const result = await ctx.session.prompt({
sessionID: sessionId,
text: query,
})
// Send response
await client.sendText(roomId, result)
})
// Event subscription for response streaming
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.updated" && event.update?.type === "agent_message_chunk") {
// Stream response to Matrix
}
}
})()
return () => {
controller.abort()
client.stop()
}
},
})
```
### Example 3: Plugin with Custom Tool
```typescript
import { Plugin, tool } from "@opencode/plugin"
export default Plugin.define({
id: "custom-tools",
async setup(ctx) {
ctx.tool.transform((editor) => {
editor.add({
name: "git-status",
description: "Show git status of the current project",
async execute(_, delivery) {
const result = await ctx.shell.execute("git status --porcelain")
return result.stdout
},
})
editor.add({
name: "search-files",
description: "Search for files matching a pattern",
async execute(args: { pattern: string }, delivery) {
const result = await ctx.shell.execute(`find . -name "${args.pattern}" -type f`)
return result.stdout
},
})
})
return () => {}
},
})
```
### Example 4: Plugin with Persistent Storage
```typescript
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "state-tracker",
async setup(ctx) {
// Initialize from storage
const version = await ctx.storage.get("state-tracker/version")
if (!version) {
await ctx.storage.set("state-tracker/version", "1.0.0")
await ctx.storage.set("state-tracker/started_at", new Date().toISOString())
}
// Track sessions
const sessions = new Map<string, { count: number }>()
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.created" && event.sessionID) {
sessions.set(event.sessionID, { count: 0 })
}
if (event.type === "session.deleted" && event.sessionID) {
sessions.delete(event.sessionID)
}
}
})()
return () => {
controller.abort()
}
},
})
```
---
## Migration from V1 to V2
### Import Changes
| V1 | V2 |
|----|----|
| `import type { Plugin } from "@opencode-ai/plugin"` | `import { Plugin } from "@opencode/plugin"` |
| `export const MyPlugin = async (ctx) => ({ ...hooks })` | `export default Plugin.define({ id, async setup(ctx) { ... } })` |
### Hook Mapping
| V1 Hook | V2 API |
|---------|--------|
| `event: async ({ event }) => {}` | `ctx.event.subscribe()` |
| `"tool.execute.before"` | `ctx.tool.hook("execute.before", ...)` |
| `"tool.execute.after"` | `ctx.tool.hook("execute.after", ...)` |
| `"chat.message"` | `ctx.session.hook("prompt", ...)` |
| `"chat.params"` | `ctx.session.hook("context", ...)` |
| `permission.ask` | `ctx.permission.hook("evaluate", ...)` |
| `dispose` | `return cleanup` from setup |
| `config` | transforms on affected domains |
### Context Changes
| V1 | V2 |
|----|----|
| `ctx.client` | Domain APIs on `ctx` |
| `ctx.project` | `ctx.location.project` |
| `ctx.directory` | `ctx.location.directory` |
| `ctx.$` | Explicitly imported shell helpers |
---
## Quick Reference
### File: `~/.config/opencode/opencode.json`
```jsonc
{
"plugins": [
// npm package
"opencode-helicone-session",
// local directory
"./plugins/local",
// with options
{
"package": "./plugins/matrix-plugin",
"options": {
"homeserver": "https://matrix.org",
"userId": "@bot:matrix.org",
"password": "secret"
}
}
]
}
```
### File: `~/.config/opencode/plugins/my-plugin/package.json`
```json
{
"name": "my-opencode-plugin",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@opencode/plugin": "^2.0.16"
}
}
```
### File: `~/.config/opencode/plugins/my-plugin/index.ts`
```typescript
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "my-opencode-plugin",
async setup(ctx) {
// Initialization
await ctx.app.log({
body: { service: "my-plugin", level: "info", message: "Loaded" }
})
// Event subscription
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.idle") {
await ctx.app.log({
body: { service: "my-plugin", level: "info", message: "Session idle" }
})
}
}
})()
return () => {
controller.abort()
}
},
})
```
### Install Dependencies
```bash
cd ~/.config/opencode/plugins/my-plugin
bun install
```
---
## References
- [Official OpenCode Plugin Docs](https://opencode.ai/v2/docs/plugins)
- [Plugin Source Code](packages/plugin/src/index.ts)
- [Tool Definition](packages/plugin/src/tool.ts)
- [Plugin Loader](packages/opencode/src/plugin/index.ts)
- [V1 to V2 Migration Guide](https://gist.github.com/yohi/0c3d8edea98d86e7f5c8d6c2d9f93d2d)
- [Plugin Development Reference](https://github.com/growwithsmc/opencode-plugin-dev)

View File

@ -0,0 +1,254 @@
# opencode-matrix-plugin
OpenCode V2 plugin that connects your OpenCode agent to Matrix messaging servers.
## Features
- **Matrix Bot Integration**: Connect OpenCode to any Matrix homeserver
- **Thread Isolation**: Each Matrix thread gets its own isolated session
- **E2EE Support**: End-to-end encryption via Rust crypto SDK
- **Auto-join**: Bot auto-joins rooms it's invited to
- **Multiple Trigger Modes**: Prefix trigger (`!oc`), @mention, or DM
- **Thread Replies**: Respond to plain replies within active threads
- **Bridge Commands**: `/status`, `/clear`, `/help` for session management
- **Rate Limiting**: Configurable per-user rate limiting
- **User Allowlisting**: Restrict who can interact with the bot
- **Image Support**: Upload and display images in Matrix
- **HTML Formatting**: Optional HTML-formatted responses
- **Session Persistence**: Sessions survive plugin reloads
## Installation
### Option 1: Local Plugin (Recommended)
1. Copy this plugin directory into your OpenCode config:
```bash
mkdir -p ~/.config/opencode/plugins
cp -r ./opencode-matrix-plugin ~/.config/opencode/plugins/
```
2. Add the plugin to your `opencode.jsonc`:
```jsonc
{
"plugins": [
"./plugins/opencode-matrix-plugin"
]
}
```
### Option 2: npm Package
```bash
cd ~/.config/opencode
npm init -y
npm install ./path/to/opencode-matrix-plugin
```
Then in `opencode.jsonc`:
```jsonc
{
"plugins": [
{
"package": "opencode-matrix-plugin",
"options": {
"homeserver": "https://matrix.org",
"userId": "@opencode:matrix.org",
"password": "your-bot-password"
}
}
]
}
```
## Configuration
Конфигурация загружается в следующем порядке приоритетов:
1. **Plugin options** из `opencode.jsonc` (`plugins[].options`) — высший приоритет
2. **Config file** — `.opencode/matrix.json` (проект) или `~/.config/opencode/matrix.json` (глобальный)
3. **Environment variables** — `MATRIX_*` — базовый уровень
> **Auto-create**: Если конфиг не найден ни в одной директории, плагин автоматически создаст
> `~/.config/opencode/matrix.json` с дефолтными значениями (права `0o600`).
### Config File
Создайте `matrix.json` в одной из директорий:
**Проектный уровень** — `.opencode/matrix.json` (рядом с `opencode.jsonc`):
```json
{
"homeserver": "https://matrix.org",
"userId": "@opencode-bot:matrix.org",
"password": "your-bot-password",
"autoJoin": true,
"triggerPatterns": ["!oc ", "!ai "],
"allowedUsers": ["@alice:matrix.org"],
"threadIsolation": true,
"respondToThreadReplies": true,
"rateLimitSeconds": 5,
"botName": "opencode",
"enabled": true
}
```
**Глобальный уровень** — `~/.config/opencode/matrix.json`:
```json
{
"homeserver": "https://matrix.org",
"userId": "@opencode-bot:matrix.org",
"password": "your-bot-password",
"triggerPatterns": ["!oc "],
"threadIsolation": true,
"enabled": true
}
```
Файлы поддерживают JSONC (комментарии `//`).
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `MATRIX_HOMESERVER` | Matrix server URL | `https://matrix.org` |
| `MATRIX_USER_ID` | Bot user ID | (required) |
| `MATRIX_ACCESS_TOKEN` | Static access token | (optional) |
| `MATRIX_PASSWORD` | Bot password for login | (optional, preferred) |
| `MATRIX_STORAGE_PATH` | Override storage directory | `~/.local/share/opencode-matrix-bot` |
| `MATRIX_TRIGGER` | Override trigger pattern | — |
| `MATRIX_ALLOWED_USERS` | Comma-separated user IDs | — |
### All Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `homeserver` | string | `https://matrix.org` | Matrix homeserver URL |
| `userId` | string | - | Bot user ID (e.g., `@bot:server.org`) |
| `accessToken` | string | - | Static access token (or use `password`) |
| `password` | string | - | Bot password (preferred, auto-refreshes token) |
| `deviceId` | string | `opencode-matrix-plugin` | Device ID for login |
| `autoJoin` | boolean | `true` | Auto-join rooms the bot is invited to |
| `triggerPatterns` | string[] | `["!oc "]` | Message prefixes to trigger the bot |
| `ignoreRooms` | string[] | `[]` | Room IDs to ignore |
| `ignoreUsers` | string[] | `[]` | User IDs to ignore |
| `allowedUsers` | string[] | `[]` | Allowlist (empty = everyone) |
| `formatHtml` | boolean | `false` | Send HTML-formatted responses |
| `threadIsolation` | boolean | `true` | Per-thread sessions |
| `respondToThreadReplies` | boolean | `true` | Reply to plain thread messages |
| `rateLimitSeconds` | number | `5` | Per-user cooldown between messages |
| `botName` | string | `opencode` | Bot display name for @mentions |
| `storagePath` | string | auto | Directory for bot state and crypto keys |
| `enabled` | boolean | `true` | Enable/disable the plugin |
## Usage
### Triggering the Bot
1. **Prefix trigger**: Send `!oc What is TypeScript?`
2. **@Mention**: Send `@opencode What is TypeScript?`
3. **DM**: Send any message in a direct message with the bot
4. **Thread reply**: Reply to any message in an active thread
### Bridge Commands
| Command | Description |
|---------|-------------|
| `/help` or `/h` | Show help message |
| `/status` | Show current session info |
| `/clear` or `/reset` | Clear current session |
### Room Setup
1. Invite `@opencode:matrix.org` to your room
2. The bot auto-joins (if `autoJoin: true`)
3. Start messaging with the trigger prefix or @mention
### Bot Setup (First Time)
1. Create a Matrix account for your bot (or use existing)
2. Set `MATRIX_PASSWORD` or `MATRIX_ACCESS_TOKEN`
3. The bot will auto-login and save the access token
4. Invite the bot to your rooms
## Architecture
```
Matrix Room
│
▼
MatrixBotClient (matrix-bot-sdk + Rust crypto)
│
├── Event: room.message
├── Authentication: password / token
├── E2EE: automatic
└── Thread handling: m.relates_to
│
▼
SessionManager
│
├── Rate limiting
├── Event deduplication
├── Thread isolation (room:threadId)
└── Session lifecycle
│
▼
OpenCode V2 Plugin API
│
├── ctx.session.prompt() - send queries
├── ctx.session.hook("prompt") - intercept
├── ctx.command.transform() - bridge commands
├── ctx.event.subscribe() - event stream
└── ctx.storage - persistence
│
▼
Response → MatrixBotClient → Matrix Room
```
## Security
- **E2EE**: Full end-to-end encryption support via `@matrix-org/matrix-sdk-crypto-nodejs`
- **Token Storage**: Access tokens saved with `0o600` permissions
- **User Allowlisting**: Restrict bot access to specific users
- **Rate Limiting**: Prevent message spam
- **Event Deduplication**: Prevent processing duplicate events
## Troubleshooting
### Bot doesn't respond
1. Check that the bot is in the room
2. Verify the trigger pattern matches your message
3. Check if the user is in the allowed list (if configured)
4. Check `~/.local/share/opencode-matrix-bot/` for state files
5. Enable debug logging: `BRIDGE_DEBUG=1 opencode`
### Authentication fails
1. Verify `MATRIX_USER_ID` and `MATRIX_PASSWORD` are correct
2. Check that the bot account exists on the homeserver
3. Try setting `MATRIX_ACCESS_TOKEN` directly
4. Check homeserver logs for authentication errors
### E2EE issues
1. Ensure `@matrix-org/matrix-sdk-crypto-nodejs` is installed
2. Check crypto storage directory has write permissions
3. Verify the bot has access to the encrypted room
## Development
```bash
cd plugins/opencode-matrix-plugin
npm install
npm run typecheck
npm run build
```
## License
MIT

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,263 @@
import fs from "fs"
import path from "path"
import os from "os"
import { log } from "./logger.js"
interface PluginOptions {
homeserver?: string
userId?: string
accessToken?: string
password?: string
deviceId?: string
autoJoin?: boolean
triggerPatterns?: string[]
ignoreRooms?: string[]
ignoreUsers?: string[]
allowedUsers?: string[]
formatHtml?: boolean
threadIsolation?: boolean
respondToThreadReplies?: boolean
rateLimitSeconds?: number
botName?: string
storagePath?: string
}
interface MergedOptions {
homeserver: string
userId?: string
accessToken?: string
password?: string
deviceId: string
autoJoin: boolean
triggerPatterns: string[]
ignoreRooms: string[]
ignoreUsers: string[]
allowedUsers: string[]
formatHtml: boolean
threadIsolation: boolean
respondToThreadReplies: boolean
rateLimitSeconds: number
botName: string
storagePath?: string
}
interface LoadConfigResult {
options: MergedOptions
configPath: string | null
}
const CONFIG_FILENAMES = ["matrix.json", "matrix.jsonc"]
function mergeDefaults(raw: Record<string, unknown> | null, envOverrides: Partial<MergedOptions>): MergedOptions {
const base: MergedOptions = {
homeserver: envOverrides.homeserver || "https://matrix.org",
userId: envOverrides.userId,
accessToken: envOverrides.accessToken,
password: envOverrides.password,
deviceId: envOverrides.deviceId || "opencode-matrix-plugin",
autoJoin: envOverrides.autoJoin !== false,
triggerPatterns: envOverrides.triggerPatterns || ["!oc "],
ignoreRooms: envOverrides.ignoreRooms || [],
ignoreUsers: envOverrides.ignoreUsers || [],
allowedUsers: envOverrides.allowedUsers || [],
formatHtml: envOverrides.formatHtml || false,
threadIsolation: envOverrides.threadIsolation !== false,
respondToThreadReplies: envOverrides.respondToThreadReplies !== false,
rateLimitSeconds: envOverrides.rateLimitSeconds || 5,
botName: envOverrides.botName || "opencode",
storagePath: envOverrides.storagePath,
}
if (!raw) return base
return {
homeserver: raw.homeserver as string || base.homeserver,
userId: raw.userId as string || base.userId,
accessToken: raw.accessToken as string || base.accessToken,
password: raw.password as string || base.password,
deviceId: (raw.deviceId as string) || base.deviceId,
autoJoin: (raw.autoJoin as boolean) !== undefined ? (raw.autoJoin as boolean) : base.autoJoin,
triggerPatterns: (raw.triggerPatterns as string[]) || base.triggerPatterns,
ignoreRooms: (raw.ignoreRooms as string[]) || base.ignoreRooms,
ignoreUsers: (raw.ignoreUsers as string[]) || base.ignoreUsers,
allowedUsers: (raw.allowedUsers as string[]) || base.allowedUsers,
formatHtml: (raw.formatHtml as boolean) !== undefined ? (raw.formatHtml as boolean) : base.formatHtml,
threadIsolation: (raw.threadIsolation as boolean) !== undefined ? (raw.threadIsolation as boolean) : base.threadIsolation,
respondToThreadReplies: (raw.respondToThreadReplies as boolean) !== undefined ? (raw.respondToThreadReplies as boolean) : base.respondToThreadReplies,
rateLimitSeconds: (raw.rateLimitSeconds as number) || base.rateLimitSeconds,
botName: (raw.botName as string) || base.botName,
storagePath: (raw.storagePath as string) || base.storagePath,
}
}
function tryReadConfig(dir: string): Record<string, unknown> | null {
for (const filename of CONFIG_FILENAMES) {
const filepath = path.join(dir, filename)
if (!fs.existsSync(filepath)) continue
try {
const content = fs.readFileSync(filepath, "utf-8")
const parsed = JSON.parse(content)
if (typeof parsed === "object" && parsed !== null) {
return parsed
}
} catch (e) {
log(`Failed to read config: ${filepath}`)
return null
}
}
return null
}
function findProjectConfigDir(): string | null {
let dir = process.cwd()
const root = path.parse(dir).root
while (dir && dir !== root) {
const opencodeDir = path.join(dir, ".opencode")
if (fs.existsSync(opencodeDir) && fs.statSync(opencodeDir).isDirectory()) {
return opencodeDir
}
dir = path.dirname(dir)
}
const currentOpencode = path.join(process.cwd(), ".opencode")
if (fs.existsSync(currentOpencode) && fs.statSync(currentOpencode).isDirectory()) {
return currentOpencode
}
return null
}
function findGlobalConfigDir(): string {
return path.join(os.homedir(), ".config", "opencode")
}
const DEFAULT_CONFIG_CONTENT = JSON.stringify({
homeserver: "https://matrix.org",
userId: "",
accessToken: "",
password: "",
deviceId: "opencode-matrix-plugin",
autoJoin: true,
triggerPatterns: ["!oc "],
ignoreRooms: [],
ignoreUsers: [],
allowedUsers: [],
formatHtml: false,
threadIsolation: true,
respondToThreadReplies: true,
rateLimitSeconds: 5,
botName: "opencode",
enabled: true,
}, null, 2)
async function ensureGlobalConfig(globalConfigDir: string): Promise<string | null> {
log(`ensureGlobalConfig called, dir=${globalConfigDir}`)
for (const filename of CONFIG_FILENAMES) {
const filepath = path.join(globalConfigDir, filename)
if (fs.existsSync(filepath)) {
log(`config already exists: ${filepath}`)
return filepath
}
}
log("no config found, creating default")
try {
log("mkdirSync globalConfigDir")
fs.mkdirSync(globalConfigDir, { recursive: true })
const filepath = path.join(globalConfigDir, "matrix.json")
log("writing file: " + filepath)
fs.writeFileSync(filepath, DEFAULT_CONFIG_CONTENT, { mode: 0o600 })
log("Created default config: " + filepath)
return filepath
} catch (err) {
log("Failed to create default config: " + err)
return null
}
}
export async function loadConfig(pluginOptions: PluginOptions = {}): Promise<LoadConfigResult> {
pluginOptions = pluginOptions || {}
const envOverrides: Partial<MergedOptions> = {
homeserver: process.env.MATRIX_HOMESERVER,
userId: process.env.MATRIX_USER_ID,
accessToken: process.env.MATRIX_ACCESS_TOKEN,
password: process.env.MATRIX_PASSWORD,
deviceId: process.env.MATRIX_DEVICE_ID || undefined,
triggerPatterns: process.env.MATRIX_TRIGGER ? [process.env.MATRIX_TRIGGER] : undefined,
allowedUsers: process.env.MATRIX_ALLOWED_USERS ? process.env.MATRIX_ALLOWED_USERS.split(",").map(s => s.trim()).filter(Boolean) : undefined,
storagePath: process.env.MATRIX_STORAGE_PATH,
}
const projectConfigDir = findProjectConfigDir()
log(`projectConfigDir=${projectConfigDir}`)
let rawConfig: Record<string, unknown> | null = null
let configPath: string | null = null
if (projectConfigDir) {
const projectConfig = tryReadConfig(projectConfigDir)
log(`projectConfig=${projectConfig ? "found" : "null"}`)
if (projectConfig) {
rawConfig = projectConfig
const foundFile = CONFIG_FILENAMES.find((f) => fs.existsSync(path.join(projectConfigDir, f)))
if (foundFile) {
configPath = path.join(projectConfigDir, foundFile)
}
}
}
const globalConfigDir = findGlobalConfigDir()
const globalConfig = tryReadConfig(globalConfigDir)
log(`globalConfigDir=${globalConfigDir}`)
log(`globalConfig=${globalConfig ? "found" : "null"}`)
log(`rawConfig=${rawConfig ? "found" : "null"}`)
if (!rawConfig && globalConfig) {
rawConfig = globalConfig
configPath = path.join(globalConfigDir, "matrix.json")
log("using global config")
}
if (!rawConfig) {
log("no config found, calling ensureGlobalConfig")
const createdPath = await ensureGlobalConfig(globalConfigDir)
log(`ensureGlobalConfig returned=${createdPath}`)
if (createdPath) {
configPath = createdPath
}
}
if (rawConfig && (rawConfig.enabled as boolean | undefined) === false) {
return {
options: { ...mergeDefaults(null, envOverrides), homeserver: "" } as MergedOptions,
configPath,
}
}
const merged = mergeDefaults(rawConfig, envOverrides)
const finalOptions: MergedOptions = {
homeserver: pluginOptions.homeserver || merged.homeserver,
userId: pluginOptions.userId || merged.userId,
accessToken: pluginOptions.accessToken || merged.accessToken,
password: pluginOptions.password || merged.password,
deviceId: pluginOptions.deviceId || merged.deviceId,
autoJoin: pluginOptions.autoJoin !== undefined ? pluginOptions.autoJoin : merged.autoJoin,
triggerPatterns: pluginOptions.triggerPatterns || merged.triggerPatterns,
ignoreRooms: pluginOptions.ignoreRooms || merged.ignoreRooms,
ignoreUsers: pluginOptions.ignoreUsers || merged.ignoreUsers,
allowedUsers: pluginOptions.allowedUsers || merged.allowedUsers,
formatHtml: pluginOptions.formatHtml !== undefined ? pluginOptions.formatHtml : merged.formatHtml,
threadIsolation: pluginOptions.threadIsolation !== undefined ? pluginOptions.threadIsolation : merged.threadIsolation,
respondToThreadReplies: pluginOptions.respondToThreadReplies !== undefined ? pluginOptions.respondToThreadReplies : merged.respondToThreadReplies,
rateLimitSeconds: pluginOptions.rateLimitSeconds || merged.rateLimitSeconds,
botName: pluginOptions.botName || merged.botName,
storagePath: pluginOptions.storagePath || merged.storagePath,
}
return { options: finalOptions, configPath }
}

View File

@ -0,0 +1,400 @@
import fs from "fs"
import { MatrixBotClient } from "./matrix-client.js"
import { SessionManager, encodeSessionId } from "./session-manager.js"
import { loadConfig } from "./config-loader.js"
import { log } from "./logger.js"
log("Plugin module loaded")
// =============================================================================
// V2 Support Detection
// =============================================================================
function isV2Context(ctx: any): boolean {
return !!(ctx?.session?.prompt && ctx?.event?.subscribe && ctx?.storage)
}
// =============================================================================
// Shared initialization logic
// =============================================================================
async function runPlugin(ctx: any, options: any, sdkClient?: any) {
const isV2 = isV2Context(ctx)
const eventController = new AbortController()
let expiryInterval: ReturnType<typeof setInterval> | undefined
let cleanup: (() => void) | undefined
try {
const directory = ctx?.location?.directory || process.cwd()
fs.appendFileSync("/tmp/opencode-matrix-plugin/setup-started.log", `[${new Date().toISOString()}] setup() called, dir=${directory}, v2=${isV2}, sdk=${!!sdkClient}\n`)
log(`Plugin setup started, directory=${directory}, v2=${isV2}, sdk=${!!sdkClient}`)
if (!options.homeserver) {
log("Plugin disabled (no homeserver configured)")
return () => {}
}
log(`Configuration loaded`)
const sessionRetentionMs = 30 * 60 * 1000
const matrix = new MatrixBotClient(options)
const sessionManager = new SessionManager(options.rateLimitSeconds || 5)
// V2 Event subscription for response streaming
if (isV2) {
void (async () => {
try {
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
const eventType = typeof event === "object" && event !== null && "type" in event ? (event as { type: string }).type : ""
if (eventType === "session.updated") {
const sessionId = (event as { sessionID?: string }).sessionID
if (!sessionId) continue
const update = (event as { update?: { type: string; content?: string; toolName?: string } }).update
if (!update) continue
}
}
} catch (err) {
if (String(err).includes("AbortError") || (err as { name?: string }).name === "AbortError") {
// expected during cleanup
} else {
log(`Event subscription error: ${String(err)}`)
}
}
})()
}
// V1 Event subscription for response streaming
if (!isV2 && sdkClient) {
void (async () => {
try {
const events = await sdkClient.event.subscribe()
for await (const event of events.stream) {
const eventType = event.type || ""
if (eventType === "session.updated") {
const sessionId = event.properties?.sessionID
if (!sessionId) continue
const update = event.properties?.update
if (!update) continue
}
}
} catch (err) {
if (String(err).includes("AbortError") || (err as { name?: string }).name === "AbortError") {
// expected during cleanup
} else {
log(`V1 event subscription error: ${String(err)}`)
}
}
})()
}
// Message handler
matrix.on(async (data: any) => {
const { context, query, sender, roomId, eventId, timestamp } = data
// Check rate limit
if (sessionManager.isRateLimited(sender)) {
await matrix.sendNotice(context, "Rate limited. Please wait a moment.")
return
}
// Check for active query
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.")
return
}
const abortFn = () => { /* placeholder for cancel */ }
const releaseQuery = sessionManager.markQueryActive(roomId, context.replyThreadRootId || context.eventId, abortFn)
const threadRootId = context.replyThreadRootId || context.eventId
const session = sessionManager.getOrCreateSession(roomId, threadRootId, eventId)
session.isActive = true
session.messageCount++
session.inputChars += query.length
session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId)
session.lastActivity = Date.now()
let opencodeSessionId = session.opencodeSessionId
// If no session ID yet, create one
if (!opencodeSessionId) {
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`)
try {
if (isV2 && ctx?.session?.create) {
// V2: can pass custom ID
const encodedId = encodeSessionId(roomId, threadRootId)
const createResult = await ctx.session.create({ id: encodedId, title: `Matrix: ${roomId.slice(0, 30)}...` })
opencodeSessionId = createResult?.id || createResult?.data?.id || encodedId
log(`V2: Created OpenCode session ${opencodeSessionId} (custom ID: ${encodedId})`)
} else if (sdkClient) {
// V1: cannot pass custom ID, need to save mapping
const createResult = await sdkClient.session.create({ body: { title: `Matrix: ${roomId.slice(0, 30)}...` } })
opencodeSessionId = createResult?.data?.id || createResult?.id || ""
log(`V1: Created OpenCode session ${opencodeSessionId}`)
}
session.opencodeSessionId = opencodeSessionId
sessionManager.saveSession(roomId, threadRootId, opencodeSessionId)
} catch (createErr: any) {
log(`Failed to create session: ${String(createErr)}`)
session.isActive = false
session.lastActivity = Date.now()
releaseQuery()
await matrix.sendNotice(context, "Failed to create session. Check logs.")
return
}
}
try {
if (isV2) {
// V2: use ctx.session.prompt()
const result = await ctx.session.prompt({
sessionID: opencodeSessionId,
text: query,
})
const responseText = result ? String(result) : "No response"
session.outputChars += responseText.length
await matrix.sendReply(context, responseText)
} else if (sdkClient) {
// V1: use SDK client.session.prompt()
log(`V1: Sending prompt to session ${opencodeSessionId}: ${query}`)
try {
const result = await sdkClient.session.prompt({
path: { id: opencodeSessionId },
body: { parts: [{ type: "text", text: query }] },
})
log(`V1: prompt result keys=${Object.keys(result || {})}`)
log(`V1: prompt result.data.keys=${result?.data ? Object.keys(result.data).join(",") : "null"}`)
log(`V1: prompt result.data.info.keys=${result?.data?.info ? Object.keys(result.data.info).join(",") : "null"}`)
log(`V1: prompt result.data.info.parts=${result?.data?.info?.parts ? JSON.stringify(result.data.info.parts).slice(0, 500) : "null"}`)
log(`V1: prompt result.data.message=${result?.data?.message ? JSON.stringify(result.data.message).slice(0, 500) : "null"}`)
// Try to extract content from various locations
let responseText = ""
if (result?.data?.parts) {
responseText = result.data.parts.map((p: any) => p.content || p.text || "").join("")
} else if (result?.data?.info?.parts) {
responseText = result.data.info.parts.map((p: any) => p.content || p.text || "").join("")
} else if (result?.data?.message?.parts) {
responseText = result.data.message.parts.map((p: any) => p.content || p.text || "").join("")
} else if (result?.data?.content) {
responseText = result.data.content
} else if (result?.response?.content) {
responseText = result.response.content
} else if (result?.content) {
responseText = result.content
}
session.outputChars += responseText.length
await matrix.sendReply(context, responseText)
log(`V1: Response sent (${responseText.length} chars)`)
} catch (promptErr: any) {
log(`V1 prompt error: ${String(promptErr)}`)
const errMsg = promptErr?.response?.error?.message || promptErr?.error?.message || String(promptErr)
await matrix.sendNotice(context, `Error: ${errMsg.slice(0, 200)}`)
}
} else {
// No SDK client available
log(`V1 mode without SDK client: Would send prompt to session ${opencodeSessionId}: ${query}`)
const responseText = "Matrix plugin running in V1 mode — prompt forwarding requires OpenCode SDK client"
session.outputChars += responseText.length
await matrix.sendReply(context, responseText)
}
} catch (err: any) {
log(`Error processing message: ${String(err)}`)
const errMsg = err?.response?.error?.message || String(err)
await matrix.sendNotice(context, `Error: ${errMsg.slice(0, 200)}`)
} finally {
session.isActive = false
session.lastActivity = Date.now()
releaseQuery()
}
})
// Bridge commands
async function handleBridgeCommand(
context: { roomId: string; replyThreadRootId?: string; threadRootId?: string; eventId: string },
cmdName: string,
sendFn: (text: string) => Promise<void>,
): Promise<void> {
const threadRootId = context.threadRootId || context.replyThreadRootId || context.eventId
const key = sessionManager.getSessionKey(context.roomId, threadRootId)
const sess = sessionManager.sessions.get(key)
switch (cmdName) {
case "status": {
if (!sess) {
await sendFn("No active session.")
return
}
const age = Math.round((Date.now() - sess.lastActivity) / 60000)
const msg = `Session status:\n- Messages: ${sess.messageCount}\n- Age: ~${age} min ago\n- Input: ~${sess.inputChars} chars\n- Output: ~${sess.outputChars} chars`
await sendFn(msg)
break
}
case "clear":
case "reset": {
sessionManager.removeSession(context.roomId, threadRootId)
await sendFn("Session cleared. Next message will start a fresh session.")
break
}
case "help":
case "h": {
const trigger = options.triggerPatterns?.[0] || "!oc "
const botName = options.botName || "opencode"
const mode = isV2 ? "OpenCode V2 (full)" : sdkClient ? "OpenCode V1 (full via SDK)" : "OpenCode V1 (limited — requires SDK client)"
const msg = [
`${botName} - OpenCode Matrix Plugin`,
"",
"Bridge commands:",
`- /h or /help - Show this help`,
`- /status - Show current chat session info`,
`- /clear or /reset - Delete current session history`,
"",
`Usage: ${trigger} <your question>`,
`Or @${botName} <your question>`,
`Or reply in a thread`,
"",
`Mode: ${mode}`,
].join("\n")
await sendFn(msg)
break
}
default:
await sendFn(`Unknown command: ${cmdName}. Try /help`)
}
}
// Override the matrix.on handler to also process commands
const originalListener = (matrix as any)["listeners"][0]
;(matrix as any)["listeners"] = []
matrix.on(async (data: any) => {
const { context, query, sender, eventId } = data
// Handle bridge commands
if (query.startsWith("/")) {
const cmdName = query.slice(1).split(" ")[0].toLowerCase()
const bridgeCommands = ["status", "clear", "reset", "help", "h"]
if (bridgeCommands.includes(cmdName)) {
const sendFn = (text: string) => matrix.sendNotice(context, text)
await handleBridgeCommand(context, cmdName, sendFn)
return
}
}
// Forward to original handler
if (originalListener) {
await originalListener(data)
}
})
// Session expiry loop
expiryInterval = sessionManager.startExpiryLoop(sessionRetentionMs)
// V2 Storage - defensive access
if (isV2 && ctx?.storage) {
await ctx.storage.set("matrix/plugin_version", "1.0.0")
await ctx.storage.set("matrix/started_at", new Date().toISOString())
}
// Start Matrix client
await matrix.start()
log(`Plugin setup completed successfully (V2=${isV2}, sdk=${!!sdkClient})`)
cleanup = () => {
log("Plugin cleaning up...")
eventController.abort()
if (expiryInterval) clearInterval(expiryInterval)
matrix.stop()
}
return cleanup
} catch (err) {
fs.appendFileSync("/tmp/opencode-matrix-plugin/setup-error.log", `[${new Date().toISOString()}] ${String(err)}\n`)
log(`Setup error: ${String(err)}`)
return () => {
cleanup?.()
}
}
}
// =============================================================================
// V1 Server Entry Point — receives PluginInput with SDK client
// =============================================================================
async function v1Server(input: any, options?: any) {
try {
log("V1 server entry point called")
const { client, directory, worktree, project } = input
const sdkClient = client
if (!sdkClient) {
log("V1: No SDK client in PluginInput")
return {}
}
log(`V1: SDK client available from PluginInput`)
// Load config using loadConfig (reads from matrix.json or env vars)
const pluginOptions = options || {}
const { options: configOptions } = await loadConfig(pluginOptions)
// Create a context that runPlugin can use
const v1Ctx = {
location: { directory: directory || process.cwd(), project: project || { id: "v1" } },
storage: {
set: async (_key: string, _value: any) => { /* V1 storage not available */ },
get: async (_key: string) => null,
},
session: {}, // Not V2 context
event: {}, // Not V2 context
}
await runPlugin(v1Ctx as any, configOptions, sdkClient)
return {}
} catch (err) {
log(`V1 server error: ${String(err)}`)
return {}
}
}
// =============================================================================
// V2 Setup Function
// =============================================================================
async function v2Setup(ctx: any) {
try {
const { options } = await loadConfig(ctx.options)
return await runPlugin(ctx, options)
} catch (err) {
fs.appendFileSync("/tmp/opencode-matrix-plugin/setup-error.log", `[${new Date().toISOString()}] ${String(err)}\n`)
log(`V2 setup error: ${String(err)}`)
return () => {}
}
}
// =============================================================================
// Dual V1/V2 Export
// =============================================================================
// OpenCode 1.18.x calls server(PluginInput, options) where PluginInput contains SDK client.
// V2 reads id + setup() and ignores server().
export default {
id: "opencode-matrix",
async setup(ctx: any) {
const isV2 = isV2Context(ctx)
if (isV2) {
log("Using V2 path (full context)")
return await v2Setup(ctx)
}
log("Using V1 path (server() will be called by OpenCode)")
return await v1Server({}, {})
},
async server(input: any, options?: any) {
log("V1 server() called by OpenCode")
return await v1Server(input, options)
},
}

View File

@ -0,0 +1,21 @@
import fs from "fs"
import path from "path"
const LOG_DIR = "/tmp/opencode-matrix-plugin"
const LOG_FILE = path.join(LOG_DIR, "plugin.log")
export function log(message: string): void {
try {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${message}\n`
fs.mkdirSync(LOG_DIR, { recursive: true })
fs.appendFileSync(LOG_FILE, line)
} catch (e) {
try {
const timestamp = new Date().toISOString()
fs.appendFileSync("/tmp/opencode-matrix-fallback.log", `[${timestamp}] ${message}\n`)
} catch {
// silent fallback
}
}
}

View File

@ -0,0 +1,515 @@
import fs from "fs"
import path from "path"
import os from "os"
// matrix-bot-sdk with native Rust crypto for E2EE
import {
AutojoinRoomsMixin,
LogLevel,
LogService,
MatrixAuth,
MatrixClient,
MessageEvent,
RichConsoleLogger,
RustSdkCryptoStorageProvider,
SimpleFsStorageProvider,
} from "matrix-bot-sdk"
import { StoreType as RustSdkCryptoStoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"
import { log } from "./logger.js"
export interface MatrixOptions {
homeserver?: string
userId?: string
accessToken?: string
password?: string
deviceId?: string
autoJoin?: boolean
triggerPatterns?: string[]
allowedUsers?: string[]
formatHtml?: boolean
threadIsolation?: boolean
respondToThreadReplies?: boolean
rateLimitSeconds?: number
botName?: string
storagePath?: string
}
export interface MatrixEventContext {
sessionId: string
roomId: string
sender: string
query: string
replyThreadRootId?: string
eventId: string
}
export interface MessageListener {
(data: {
context: MatrixEventContext
query: string
sender: string
roomId: string
eventId: string
timestamp: number
}): Promise<void>
}
// Storage paths
function getStoragePaths(options: MatrixOptions) {
const STORAGE_PATH = options.storagePath || path.join(os.homedir(), ".local", "share", "opencode-matrix-bot")
const STATE_STORAGE_PATH = path.join(STORAGE_PATH, "bot-state.json")
const CRYPTO_STORAGE_PATH = path.join(STORAGE_PATH, "crypto")
const TOKEN_FILE_PATH = path.join(STORAGE_PATH, "access_token")
return { STORAGE_PATH, STATE_STORAGE_PATH, CRYPTO_STORAGE_PATH, TOKEN_FILE_PATH }
}
// Token helpers from matrix-auth.ts pattern
interface MatrixTokenValidation {
userId: string
}
interface MatrixAccessTokenOptions {
explicitToken: string
savedToken: string
passwordConfigured: boolean
expectedUserId: string
}
interface MatrixAccessTokenDependencies {
validateToken: (token: string) => Promise<MatrixTokenValidation>
loginWithPassword: () => Promise<string | null>
saveToken: (token: string) => void
log: (message: string) => void
}
function writePrivateFileAtomically(filePath: string, content: string): void {
const pid = process.pid
const ts = Date.now()
const rand = Math.random().toString(36).substring(2, 10)
const temporaryPath = `${filePath}.${pid}.${ts}.${rand}.tmp`
try {
fs.writeFileSync(temporaryPath, content, { mode: 0o600, flag: "wx" })
fs.renameSync(temporaryPath, filePath)
} catch (error) {
try { fs.unlinkSync(temporaryPath) } catch {}
throw error
}
}
function matrixErrorCode(error: unknown): string {
if (!error || typeof error !== "object") return ""
const candidate = error as { errcode?: unknown; body?: { errcode?: unknown } }
if (typeof candidate.errcode === "string") return candidate.errcode
return typeof candidate.body?.errcode === "string" ? candidate.body.errcode : ""
}
function isMatrixAuthenticationError(error: unknown): boolean {
const code = matrixErrorCode(error)
return code === "M_UNKNOWN_TOKEN" || code === "M_MISSING_TOKEN"
}
async function resolveMatrixAccessToken(
options: MatrixAccessTokenOptions,
dependencies: MatrixAccessTokenDependencies,
): Promise<string | null> {
if (options.explicitToken) {
dependencies.log("Using access token from config/env")
return options.explicitToken
}
if (options.savedToken) {
try {
const validation = await dependencies.validateToken(options.savedToken)
if (!options.expectedUserId || validation.userId === options.expectedUserId) {
dependencies.log("Using validated saved access token")
return options.savedToken
}
dependencies.log(
`Saved access token belongs to ${validation.userId}, not ${options.expectedUserId}; logging in again`,
)
} catch (error) {
if (!isMatrixAuthenticationError(error)) throw error
dependencies.log("Saved access token is no longer valid; logging in again")
}
}
if (!options.passwordConfigured) return null
const token = await dependencies.loginWithPassword()
if (!token) return null
dependencies.saveToken(token)
return token
}
// Thread helpers from matrix-thread-helpers.ts
function extractThreadRootId(event: any): string {
const relatesTo = event?.content?.["m.relates_to"]
if (relatesTo?.rel_type === "m.thread" && relatesTo?.event_id) {
return relatesTo.event_id
}
return ""
}
function extractBotNameQuery(text: string, botName: string): string | null {
const name = botName.trim()
if (!name) return null
const prefixes = [`@${name}`, name]
for (const prefix of prefixes) {
if (text.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) continue
const separator = text.charAt(prefix.length)
if (separator !== ":" && !/\s/.test(separator)) continue
return text.slice(prefix.length).replace(/^[:\s]+/, "").trim()
}
return null
}
function resolveThreadRoot(threadRootEventId: string, eventId: string): string {
return threadRootEventId || eventId
}
function buildMatrixSessionId(roomId: string, replyThreadRootId: string, threadIsolation: boolean): string {
if (threadIsolation) {
return `${roomId}:${replyThreadRootId}`
}
return roomId
}
function normalizeMatrixEventContext(
input: { roomId: string; sender?: string; text?: string; eventId: string; threadRootEventId?: string },
threadIsolation: boolean,
): MatrixEventContext {
const roomId = input.roomId
const eventId = input.eventId
const threadRootEventId = input.threadRootEventId || ""
const replyThreadRootId = resolveThreadRoot(threadRootEventId, eventId)
return {
roomId,
sender: input.sender || "unknown",
query: input.text || "",
eventId,
replyThreadRootId,
sessionId: buildMatrixSessionId(roomId, replyThreadRootId, threadIsolation),
}
}
function buildThreadRelation(threadRootEventId: string, lastEventId: string): object {
return {
rel_type: "m.thread",
event_id: threadRootEventId,
is_falling_back: true,
"m.in_reply_to": { event_id: lastEventId },
}
}
function shouldHandleThreadReply(input: {
enabled?: boolean
text: string
threadRootEventId: string
trigger: string
botUserId: string
}): boolean {
if (input.enabled === false) return false
const text = input.text.trim()
if (!text) return false
if (!input.threadRootEventId) return false
if (text.toLowerCase().startsWith(`${input.trigger.toLowerCase()} `)) return false
if (text.toLowerCase().startsWith(`${input.trigger.toLowerCase()}`)) return false
if (text.includes(input.botUserId)) return false
return true
}
export class MatrixBotClient {
private options: MatrixOptions
private matrix: MatrixClient | null = null
public userId: string | null = null
private listeners: MessageListener[] = []
private readonly trigger: string
private readonly botName: string
private readonly threadIsolation: boolean
private readonly respondToThreadReplies: boolean
private readonly allowedUsers: string[]
private readonly formatHtml: boolean
constructor(options: MatrixOptions) {
this.options = options
this.trigger = options.triggerPatterns?.[0] || "!oc "
this.botName = options.botName || "opencode"
this.threadIsolation = options.threadIsolation !== false
this.respondToThreadReplies = options.respondToThreadReplies !== false
this.allowedUsers = options.allowedUsers || []
this.formatHtml = options.formatHtml || false
}
async start(): Promise<void> {
const { STORAGE_PATH, STATE_STORAGE_PATH, CRYPTO_STORAGE_PATH, TOKEN_FILE_PATH } = getStoragePaths(this.options)
if (!this.options.accessToken && !this.options.password) {
log("Error: Either MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD must be set")
throw new Error("No credentials provided")
}
log("Starting Matrix bot...")
log(` Homeserver: ${this.options.homeserver}`)
log(` User: ${this.options.userId}`)
log(` Storage: ${STORAGE_PATH}`)
log(` E2EE: enabled (Rust crypto with SQLite)`)
log(` Thread isolation: ${this.threadIsolation ? "on" : "off"}`)
fs.mkdirSync(STORAGE_PATH, { recursive: true })
fs.mkdirSync(CRYPTO_STORAGE_PATH, { recursive: true })
// Get access token using the same pattern as chat-bridge
let accessToken = await resolveMatrixAccessToken(
{
explicitToken: this.options.accessToken || "",
savedToken: fs.existsSync(TOKEN_FILE_PATH) ? fs.readFileSync(TOKEN_FILE_PATH, "utf-8").trim() : "",
passwordConfigured: Boolean(this.options.password),
expectedUserId: this.options.userId || "",
},
{
validateToken: async (token) => {
const client = new MatrixClient(this.options.homeserver || "https://matrix.org", token)
const whoami = await client.getWhoAmI()
return { userId: whoami.user_id }
},
loginWithPassword: async () => {
log("Logging in with password...")
try {
const auth = new MatrixAuth(this.options.homeserver || "https://matrix.org")
const username = this.options.userId!.split(":")[0].replace("@", "")
const client = await auth.passwordLogin(username, this.options.password!, "OpenCode Matrix Plugin")
log("Password login successful")
return client.accessToken
} catch (err: any) {
log(`Password login failed: ${err.message || err}`)
return null
}
},
saveToken: (token) => writePrivateFileAtomically(TOKEN_FILE_PATH, token),
log: (message) => log(message),
},
)
if (!accessToken) {
log("Error: Could not obtain access token")
throw new Error("Could not obtain access token")
}
// Configure logging
LogService.setLogger(new RichConsoleLogger())
LogService.setLevel(LogLevel.INFO)
LogService.muteModule("Metrics")
// Create client with storage providers (same as chat-bridge)
const stateStorage = new SimpleFsStorageProvider(STATE_STORAGE_PATH)
const cryptoStorage = new RustSdkCryptoStorageProvider(CRYPTO_STORAGE_PATH, RustSdkCryptoStoreType.Sqlite)
this.matrix = new MatrixClient(
this.options.homeserver || "https://matrix.org",
accessToken,
stateStorage,
cryptoStorage,
)
// Setup auto-join mixin (same as chat-bridge)
if (this.options.autoJoin !== false) {
AutojoinRoomsMixin.setupOnClient(this.matrix)
}
// Handle decryption failures
this.matrix.on("room.failed_decryption", async (roomId: string, event: any, error: Error) => {
log(`[CRYPTO] Failed to decrypt in ${roomId}: ${error.message}`)
})
// Get user ID
this.userId = await this.matrix.getUserId()
log(`Matrix bot started as ${this.userId}`)
// Handle messages
this.matrix.on("room.message", this.handleRoomMessage.bind(this))
// Start syncing (same as chat-bridge: await this.matrix.start())
await this.matrix.start()
log("Matrix bot listening for messages")
}
private async handleRoomMessage(roomId: string, event: any): Promise<void> {
if (!this.matrix || !this.userId) return
const message = new MessageEvent(event)
if (message.messageType !== "m.text") return
if (message.sender === this.userId) return
// Check allowed users
if (this.allowedUsers.length > 0 && !this.allowedUsers.includes(message.sender)) {
return
}
const body = message.textBody?.trim()
if (!body) return
// Deduplicate events
if (this.isDuplicateEvent(event.event_id || `${roomId}:${Date.now()}`)) return
const threadRootEventId = extractThreadRootId(event)
const context = normalizeMatrixEventContext({
roomId,
sender: message.sender,
text: body,
eventId: event.event_id,
threadRootEventId,
}, this.threadIsolation)
// Check if this is a DM
const members = await this.matrix.getJoinedRoomMembers(roomId)
const isDM = members.length === 2
// Extract query
let query = ""
const botNameQuery = extractBotNameQuery(body, this.botName)
if (body.startsWith(this.trigger + " ")) {
query = body.slice(this.trigger.length + 1).trim()
} else if (body.startsWith(this.trigger)) {
query = body.slice(this.trigger.length).trim()
} else if (body.includes(this.userId!)) {
query = body.replace(this.userId!, "").trim()
} else if (botNameQuery !== null) {
query = botNameQuery
} else if (isDM) {
query = body
} else if (this.threadIsolation && shouldHandleThreadReply({
enabled: this.respondToThreadReplies,
text: body,
threadRootEventId,
trigger: this.trigger,
botUserId: this.userId!,
})) {
// Implicit thread follow-up
query = body
log(`[THREAD] ${message.sender} in ${context.sessionId}: ${body}`)
} else {
return
}
query = query.replace(/^[:\s]+/, "").trim()
if (!query) return
log(`[MSG] ${message.sender} in ${context.sessionId}: ${body}`)
const timestamp = event.ts
for (const listener of this.listeners) {
try {
await listener({ context, query, sender: message.sender, roomId, eventId: event.event_id, timestamp })
} catch (e) {
log(`Error in message listener: ${e}`)
}
}
}
private processedEvents = new Set<string>()
isDuplicateEvent(eventId: string | undefined): boolean {
if (!eventId) return false
if (this.processedEvents.has(eventId)) return true
if (this.processedEvents.size > 10000) {
this.processedEvents.clear()
}
this.processedEvents.add(eventId)
return false
}
async stop(): Promise<void> {
log("Stopping...")
if (this.matrix) {
this.matrix.stop()
this.matrix = null
}
log("Stopped.")
}
private async sendMessage(roomId: string, text: string): Promise<void> {
if (!this.matrix) throw new Error("Matrix client not started")
if (this.formatHtml) {
const { marked } = await import("marked")
const html = await marked.parse(text)
await this.matrix.sendMessage(roomId, {
msgtype: "m.text",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
})
} else {
await this.matrix.sendText(roomId, text)
}
}
async sendReply(context: MatrixEventContext, text: string): Promise<string | null> {
if (!this.matrix) return null
try {
if (this.threadIsolation) {
const threadRoot = context.replyThreadRootId || context.eventId
const relation = buildThreadRelation(threadRoot, context.eventId)
let content: any
if (this.formatHtml) {
const { marked } = await import("marked")
const html = await marked.parse(text)
content = {
msgtype: "m.text",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
"m.relates_to": relation,
}
} else {
content = {
msgtype: "m.text",
body: text,
"m.relates_to": relation,
}
}
const eventId = await this.matrix.sendMessage(context.roomId, content)
return eventId
} else {
await this.sendMessage(context.roomId, text)
return null
}
} catch (err) {
log(`Failed to send reply to ${context.roomId}: ${err}`)
return null
}
}
async sendNotice(context: MatrixEventContext, text: string): Promise<void> {
if (!this.matrix) return
try {
if (this.threadIsolation) {
const threadRoot = context.replyThreadRootId || context.eventId
const relation = buildThreadRelation(threadRoot, context.eventId)
await this.matrix.sendMessage(context.roomId, {
msgtype: "m.notice",
body: text,
"m.relates_to": relation,
})
} else {
await this.matrix.sendNotice(context.roomId, text)
}
} catch (err) {
log(`Failed to send notice to ${context.roomId}: ${err}`)
}
}
on(listener: MessageListener): void {
this.listeners.push(listener)
}
}

5788
plugins/matrix-plugin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
{
"name": "opencode-matrix-plugin",
"version": "1.0.0",
"type": "module",
"description": "OpenCode V2 plugin for Matrix messaging integration",
"main": "index.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opencode/plugin": "^2.0.16",
"matrix-bot-sdk": "^0.8.0",
"marked": "^15.0.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^20.0.0"
}
}

View File

@ -0,0 +1,205 @@
import crypto from "crypto"
import fs from "fs"
import path from "path"
interface Session {
matrixRoomId: string
threadRootId: string
opencodeSessionId: string
eventId: string
isActive: boolean
messageCount: number
inputChars: number
outputChars: number
lastActivity: number
lastEventIds: Map<string, string>
}
export function encodeSessionId(matrixRoomId: string, threadRootId: string): string {
const hash = crypto.createHash("md5").update(`${matrixRoomId}:${threadRootId}`).digest("hex").slice(0, 16)
return `ses_${hash}`
}
const SESSION_MAP_FILE = path.join(process.env.HOME || "/tmp", ".opencode-matrix-sessions.json")
interface SessionMapEntry {
matrixRoomId: string
threadRootId: string
opencodeSessionId: string
messageCount: number
inputChars: number
outputChars: number
lastActivity: number
}
function loadSessionMap(): Map<string, SessionMapEntry> {
const result = new Map<string, SessionMapEntry>()
try {
if (fs.existsSync(SESSION_MAP_FILE)) {
const data = JSON.parse(fs.readFileSync(SESSION_MAP_FILE, "utf-8"))
if (Array.isArray(data)) {
for (const entry of data) {
const key = `${entry.matrixRoomId}::${entry.threadRootId}`
result.set(key, entry)
}
}
}
} catch (e) {
// ignore
}
return result
}
function saveSessionMap(map: Map<string, SessionMapEntry>): void {
try {
const entries: SessionMapEntry[] = []
for (const [, entry] of map) {
entries.push(entry)
}
fs.writeFileSync(SESSION_MAP_FILE, JSON.stringify(entries, null, 2))
} catch (e) {
// ignore
}
}
export class SessionManager {
public sessions = new Map<string, Session>()
private persistentMap: Map<string, SessionMapEntry>
private activeQueries = new Map<string, () => void>()
private rateLimitMs: number
private lastRequestByUser = new Map<string, number>()
private processedEvents = new Set<string>()
constructor(rateLimitSeconds: number) {
this.rateLimitMs = (rateLimitSeconds || 5) * 1000
this.persistentMap = loadSessionMap()
// Restore sessions from persistent map
for (const [key, entry] of this.persistentMap) {
const [matrixRoomId, threadRootId] = key.split("::")
this.sessions.set(key, {
matrixRoomId,
threadRootId,
opencodeSessionId: entry.opencodeSessionId,
eventId: "",
isActive: false,
messageCount: entry.messageCount,
inputChars: entry.inputChars,
outputChars: entry.outputChars,
lastActivity: entry.lastActivity,
lastEventIds: new Map(),
})
}
}
getSessionKey(matrixRoomId: string, threadRootId: string): string {
return `${matrixRoomId}::${threadRootId}`
}
getOrCreateSession(matrixRoomId: string, threadRootId: string, eventId: string): Session {
const key = this.getSessionKey(matrixRoomId, threadRootId)
let session = this.sessions.get(key)
if (!session) {
session = {
matrixRoomId,
threadRootId,
opencodeSessionId: "",
eventId,
isActive: false,
messageCount: 0,
inputChars: 0,
outputChars: 0,
lastActivity: Date.now(),
lastEventIds: new Map(),
}
this.sessions.set(key, session)
}
return session
}
getSession(matrixRoomId: string, threadRootId: string): Session | undefined {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.sessions.get(key)
}
removeSession(matrixRoomId: string, threadRootId: string): void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
this.sessions.delete(key)
this.persistentMap.delete(key)
saveSessionMap(this.persistentMap)
this.activeQueries.delete(key)
}
has(matrixRoomId: string, threadRootId: string): boolean {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.sessions.has(key)
}
hasActiveQuery(matrixRoomId: string, threadRootId: string): boolean {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.activeQueries.has(key)
}
markQueryActive(matrixRoomId: string, threadRootId: string, abortFn: () => void): () => void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
this.activeQueries.set(key, abortFn)
return () => this.activeQueries.delete(key)
}
isRateLimited(sender: string): boolean {
const now = Date.now()
const last = this.lastRequestByUser.get(sender)
if (last && now - last < this.rateLimitMs) {
return true
}
this.lastRequestByUser.set(sender, now)
return false
}
isDuplicateEvent(eventId: string | undefined): boolean {
if (!eventId) return false
if (this.processedEvents.has(eventId)) return true
if (this.processedEvents.size > 10000) {
this.processedEvents.clear()
}
this.processedEvents.add(eventId)
return false
}
expireInactive(maxAgeMs: number): void {
const now = Date.now()
for (const [sessionId, session] of this.sessions) {
if (now - session.lastActivity > maxAgeMs) {
const key = sessionId
this.sessions.delete(key)
this.persistentMap.delete(key)
this.activeQueries.delete(key)
}
}
saveSessionMap(this.persistentMap)
}
startExpiryLoop(maxAgeMs: number, intervalMs: number = 60000): NodeJS.Timeout {
return setInterval(() => {
this.expireInactive(maxAgeMs)
}, intervalMs)
}
saveSession(matrixRoomId: string, threadRootId: string, opencodeSessionId: string): void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
const session = this.sessions.get(key)
if (session) {
const entry: SessionMapEntry = {
matrixRoomId,
threadRootId,
opencodeSessionId,
messageCount: session.messageCount,
inputChars: session.inputChars,
outputChars: session.outputChars,
lastActivity: session.lastActivity,
}
this.persistentMap.set(key, entry)
saveSessionMap(this.persistentMap)
}
}
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"outDir": "./dist",
"rootDir": ".",
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,27 @@
export interface PluginOptions {
homeserver?: string
userId?: string
accessToken?: string
password?: string
deviceId?: string
autoJoin?: boolean
triggerPatterns?: string[]
ignoreRooms?: string[]
ignoreUsers?: string[]
allowedUsers?: string[]
formatHtml?: boolean
threadIsolation?: boolean
respondToThreadReplies?: boolean
rateLimitSeconds?: number
botName?: string
storagePath?: string
}
export interface MatrixEventContext {
sessionId: string
roomId: string
sender: string
query: string
replyThreadRootId?: string
matrixEventId: string
}