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:
commit
63853de81f
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue