Compare commits
8 commits
main
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d700bbb4 | |||
| 9b32c49089 | |||
| 1fc8ba54f1 | |||
| 6597fccd42 | |||
| 71bce3fc6d | |||
| f162fa1401 | |||
| 43ef154c53 | |||
| f1881a5094 |
70 changed files with 15400 additions and 677 deletions
|
|
@ -4,6 +4,13 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
## 2026.2.2
|
## 2026.2.2
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Event Store**: Add NATS JetStream integration for event-sourced memory. All agent events (messages, tool calls, lifecycle) are persisted and queryable. Configure via `gateway.eventStore`. (#RFC-001) Thanks @alberth, @claudia-keller.
|
||||||
|
- **Event Context**: Automatically inject recent event history into session context on startup. Agents now remember recent conversations without manual file management.
|
||||||
|
- **Multi-Agent Event Isolation**: Support per-agent event streams with `eventStore.agents` config. Each agent can have isolated credentials and streams.
|
||||||
|
- **Matrix Multi-Account**: Support multiple Matrix accounts in parallel with proper client isolation. (#matrix-multiaccounts) Thanks @claudia-keller.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||||
|
|
|
||||||
248
docs/features/event-store.md
Normal file
248
docs/features/event-store.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Event Store Integration
|
||||||
|
|
||||||
|
OpenClaw can persist all agent events to NATS JetStream, enabling event-sourced memory, audit trails, and multi-agent knowledge sharing.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When enabled, every interaction becomes an immutable event:
|
||||||
|
- User/assistant messages
|
||||||
|
- Tool calls and results
|
||||||
|
- Session lifecycle (start/end)
|
||||||
|
- Custom events from extensions
|
||||||
|
|
||||||
|
Events are stored in NATS JetStream and can be:
|
||||||
|
- Queried for context building
|
||||||
|
- Replayed for debugging
|
||||||
|
- Shared across agents (with isolation)
|
||||||
|
- Used for continuous learning
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gateway:
|
||||||
|
eventStore:
|
||||||
|
enabled: true
|
||||||
|
url: nats://localhost:4222
|
||||||
|
streamName: openclaw-events
|
||||||
|
subjectPrefix: openclaw.events
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Options
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gateway:
|
||||||
|
eventStore:
|
||||||
|
enabled: true # Enable event publishing
|
||||||
|
url: nats://user:pass@localhost:4222 # NATS connection URL
|
||||||
|
streamName: openclaw-events # JetStream stream name
|
||||||
|
subjectPrefix: openclaw.events # Subject prefix for events
|
||||||
|
|
||||||
|
# Multi-agent configuration (optional)
|
||||||
|
agents:
|
||||||
|
my-agent:
|
||||||
|
url: nats://agent:pass@localhost:4222
|
||||||
|
streamName: events-my-agent
|
||||||
|
subjectPrefix: openclaw.events.my-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `conversation.message.out` | Messages sent to/from the model |
|
||||||
|
| `conversation.tool_call` | Tool invocations |
|
||||||
|
| `conversation.tool_result` | Tool results |
|
||||||
|
| `lifecycle.start` | Session started |
|
||||||
|
| `lifecycle.end` | Session ended |
|
||||||
|
|
||||||
|
## Event Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OpenClawEvent {
|
||||||
|
id: string; // Unique event ID
|
||||||
|
timestamp: number; // Unix milliseconds
|
||||||
|
agent: string; // Agent identifier
|
||||||
|
session: string; // Session key
|
||||||
|
type: string; // Event type
|
||||||
|
visibility: string; // 'internal' | 'public'
|
||||||
|
payload: {
|
||||||
|
runId: string; // Current run ID
|
||||||
|
stream: string; // Event stream type
|
||||||
|
data: any; // Event-specific data
|
||||||
|
sessionKey: string;
|
||||||
|
seq: number; // Sequence in run
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
model?: string;
|
||||||
|
channel?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Injection
|
||||||
|
|
||||||
|
When event store is enabled, OpenClaw automatically:
|
||||||
|
|
||||||
|
1. Queries recent events on session start
|
||||||
|
2. Extracts conversation history and topics
|
||||||
|
3. Injects context into the system prompt
|
||||||
|
|
||||||
|
This gives the agent memory of recent interactions without manual file management.
|
||||||
|
|
||||||
|
### Context Format
|
||||||
|
|
||||||
|
The injected context includes:
|
||||||
|
- Recent conversation snippets (deduplicated)
|
||||||
|
- Active topics mentioned
|
||||||
|
- Event count and timeframe
|
||||||
|
|
||||||
|
## Multi-Agent Isolation
|
||||||
|
|
||||||
|
For multi-agent setups, each agent can have its own stream:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gateway:
|
||||||
|
eventStore:
|
||||||
|
enabled: true
|
||||||
|
url: nats://main:password@localhost:4222
|
||||||
|
streamName: openclaw-events
|
||||||
|
subjectPrefix: openclaw.events.main
|
||||||
|
|
||||||
|
agents:
|
||||||
|
assistant-one:
|
||||||
|
url: nats://assistant1:pass@localhost:4222
|
||||||
|
streamName: events-assistant-one
|
||||||
|
subjectPrefix: openclaw.events.assistant-one
|
||||||
|
assistant-two:
|
||||||
|
url: nats://assistant2:pass@localhost:4222
|
||||||
|
streamName: events-assistant-two
|
||||||
|
subjectPrefix: openclaw.events.assistant-two
|
||||||
|
```
|
||||||
|
|
||||||
|
Combined with NATS account permissions, this ensures agents can only read their own events.
|
||||||
|
|
||||||
|
## NATS Setup
|
||||||
|
|
||||||
|
### Quick Start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name nats \
|
||||||
|
-p 4222:4222 \
|
||||||
|
-v nats-data:/data \
|
||||||
|
nats:latest \
|
||||||
|
-js -sd /data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Stream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nats stream add openclaw-events \
|
||||||
|
--subjects "openclaw.events.>" \
|
||||||
|
--storage file \
|
||||||
|
--retention limits \
|
||||||
|
--max-age 90d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secure Setup (Multi-Agent)
|
||||||
|
|
||||||
|
See [NATS Security Documentation](https://docs.nats.io/running-a-nats-service/configuration/securing_nats) for setting up accounts and permissions.
|
||||||
|
|
||||||
|
Example secure config:
|
||||||
|
```
|
||||||
|
accounts {
|
||||||
|
AGENTS: {
|
||||||
|
jetstream: enabled
|
||||||
|
users: [
|
||||||
|
{ user: main, password: "xxx", permissions: { publish: [">"], subscribe: [">"] } },
|
||||||
|
{ user: agent1, password: "xxx", permissions: {
|
||||||
|
publish: ["openclaw.events.agent1.>", "$JS.API.>", "_INBOX.>"],
|
||||||
|
subscribe: ["openclaw.events.agent1.>", "_INBOX.>", "$JS.API.>"]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
To migrate existing memory files to the event store:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install nats
|
||||||
|
|
||||||
|
# Run migration
|
||||||
|
node scripts/migrate-to-eventstore.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration script imports:
|
||||||
|
- Daily notes (`memory/*.md`)
|
||||||
|
- Long-term memory (`MEMORY.md`)
|
||||||
|
- Knowledge graph entries (`life/areas/`)
|
||||||
|
|
||||||
|
## Querying Events
|
||||||
|
|
||||||
|
### Via NATS CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List streams
|
||||||
|
nats stream ls
|
||||||
|
|
||||||
|
# Get stream info
|
||||||
|
nats stream info openclaw-events
|
||||||
|
|
||||||
|
# Read recent events
|
||||||
|
nats consumer add openclaw-events reader --deliver last --ack none
|
||||||
|
nats consumer next openclaw-events reader --count 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatically
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { connect, StringCodec } from 'nats';
|
||||||
|
|
||||||
|
const nc = await connect({ servers: 'localhost:4222' });
|
||||||
|
const js = nc.jetstream();
|
||||||
|
const jsm = await nc.jetstreamManager();
|
||||||
|
|
||||||
|
// Get last 100 events
|
||||||
|
const info = await jsm.streams.info('openclaw-events');
|
||||||
|
for (let seq = info.state.last_seq - 100; seq <= info.state.last_seq; seq++) {
|
||||||
|
const msg = await jsm.streams.getMessage('openclaw-events', { seq });
|
||||||
|
const event = JSON.parse(StringCodec().decode(msg.data));
|
||||||
|
console.log(event.type, event.timestamp);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Retention Policy**: Set appropriate max-age for your use case (default: 90 days)
|
||||||
|
2. **Stream per Agent**: Use separate streams for agent isolation
|
||||||
|
3. **Backup**: Configure NATS replication or backup JetStream data directory
|
||||||
|
4. **Monitoring**: Use NATS monitoring endpoints to track stream health
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Events not appearing
|
||||||
|
|
||||||
|
1. Check NATS connection: `nats server ping`
|
||||||
|
2. Verify stream exists: `nats stream ls`
|
||||||
|
3. Check OpenClaw logs for connection errors
|
||||||
|
4. Ensure `eventStore.enabled: true` in config
|
||||||
|
|
||||||
|
### Context not loading
|
||||||
|
|
||||||
|
1. Verify events exist in stream
|
||||||
|
2. Check NATS credentials have read permission
|
||||||
|
3. Look for errors in OpenClaw startup logs
|
||||||
|
|
||||||
|
### Performance issues
|
||||||
|
|
||||||
|
1. Limit event retention with `--max-age`
|
||||||
|
2. Use separate streams for high-volume agents
|
||||||
|
3. Consider NATS clustering for scale
|
||||||
|
|
@ -7,16 +7,14 @@ import {
|
||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
type ChannelToolSend,
|
type ChannelToolSend,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig } from "./types.js";
|
|
||||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||||
import { handleMatrixAction } from "./tool-actions.js";
|
import { handleMatrixAction } from "./tool-actions.js";
|
||||||
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||||
if (!account.enabled || !account.configured) {
|
if (!account.enabled || !account.configured) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
||||||
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
||||||
if (gate("reactions")) {
|
if (gate("reactions")) {
|
||||||
|
|
@ -33,28 +31,23 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
actions.add("unpin");
|
actions.add("unpin");
|
||||||
actions.add("list-pins");
|
actions.add("list-pins");
|
||||||
}
|
}
|
||||||
if (gate("memberInfo")) {
|
if (gate("memberInfo")) actions.add("member-info");
|
||||||
actions.add("member-info");
|
if (gate("channelInfo")) actions.add("channel-info");
|
||||||
}
|
|
||||||
if (gate("channelInfo")) {
|
|
||||||
actions.add("channel-info");
|
|
||||||
}
|
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => action !== "poll",
|
supportsAction: ({ action }) => action !== "poll",
|
||||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") {
|
if (action !== "sendMessage") return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const to = typeof args.to === "string" ? args.to : undefined;
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
if (!to) {
|
if (!to) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { to };
|
return { to };
|
||||||
},
|
},
|
||||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||||
const { action, params, cfg } = ctx;
|
const { action, params, cfg } = ctx;
|
||||||
|
// Get accountId from context for multi-account support
|
||||||
|
const accountId = (ctx as { accountId?: string }).accountId ?? undefined;
|
||||||
|
|
||||||
const resolveRoomId = () =>
|
const resolveRoomId = () =>
|
||||||
readStringParam(params, "roomId") ??
|
readStringParam(params, "roomId") ??
|
||||||
readStringParam(params, "channelId") ??
|
readStringParam(params, "channelId") ??
|
||||||
|
|
@ -77,6 +70,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
replyToId: replyTo ?? undefined,
|
replyToId: replyTo ?? undefined,
|
||||||
threadId: threadId ?? undefined,
|
threadId: threadId ?? undefined,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -93,6 +87,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
remove,
|
remove,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -107,6 +102,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
roomId: resolveRoomId(),
|
roomId: resolveRoomId(),
|
||||||
messageId,
|
messageId,
|
||||||
limit,
|
limit,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -121,6 +117,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
limit,
|
limit,
|
||||||
before: readStringParam(params, "before"),
|
before: readStringParam(params, "before"),
|
||||||
after: readStringParam(params, "after"),
|
after: readStringParam(params, "after"),
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -135,6 +132,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
roomId: resolveRoomId(),
|
roomId: resolveRoomId(),
|
||||||
messageId,
|
messageId,
|
||||||
content,
|
content,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -147,6 +145,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
action: "deleteMessage",
|
action: "deleteMessage",
|
||||||
roomId: resolveRoomId(),
|
roomId: resolveRoomId(),
|
||||||
messageId,
|
messageId,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -163,6 +162,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||||
roomId: resolveRoomId(),
|
roomId: resolveRoomId(),
|
||||||
messageId,
|
messageId,
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -175,6 +175,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
action: "memberInfo",
|
action: "memberInfo",
|
||||||
userId,
|
userId,
|
||||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
@ -185,6 +186,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
{
|
{
|
||||||
action: "channelInfo",
|
action: "channelInfo",
|
||||||
roomId: resolveRoomId(),
|
roomId: resolveRoomId(),
|
||||||
|
accountId,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
import { matrixPlugin } from "./channel.js";
|
import { matrixPlugin } from "./channel.js";
|
||||||
import { setMatrixRuntime } from "./runtime.js";
|
import { setMatrixRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
|
@ -32,12 +34,7 @@ describe("matrix directory", () => {
|
||||||
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
matrixPlugin.directory!.listPeers({
|
matrixPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||||
cfg,
|
|
||||||
accountId: undefined,
|
|
||||||
query: undefined,
|
|
||||||
limit: undefined,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ kind: "user", id: "user:@alice:example.org" },
|
{ kind: "user", id: "user:@alice:example.org" },
|
||||||
|
|
@ -48,12 +45,7 @@ describe("matrix directory", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
matrixPlugin.directory!.listGroups({
|
matrixPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||||
cfg,
|
|
||||||
accountId: undefined,
|
|
||||||
query: undefined,
|
|
||||||
limit: undefined,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ kind: "group", id: "room:!room1:example.org" },
|
{ kind: "group", id: "room:!room1:example.org" },
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@ import {
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig } from "./types.js";
|
|
||||||
import { matrixMessageActions } from "./actions.js";
|
import { matrixMessageActions } from "./actions.js";
|
||||||
import { MatrixConfigSchema } from "./config-schema.js";
|
import { MatrixConfigSchema } from "./config-schema.js";
|
||||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||||
import {
|
import type { CoreConfig } from "./types.js";
|
||||||
resolveMatrixGroupRequireMention,
|
|
||||||
resolveMatrixGroupToolPolicy,
|
|
||||||
} from "./group-mentions.js";
|
|
||||||
import {
|
import {
|
||||||
listMatrixAccountIds,
|
listMatrixAccountIds,
|
||||||
resolveDefaultMatrixAccountId,
|
resolveDefaultMatrixAccountId,
|
||||||
|
|
@ -24,12 +21,17 @@ import {
|
||||||
type ResolvedMatrixAccount,
|
type ResolvedMatrixAccount,
|
||||||
} from "./matrix/accounts.js";
|
} from "./matrix/accounts.js";
|
||||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||||
|
import { importMatrixIndex } from "./matrix/import-mutex.js";
|
||||||
import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
|
import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
|
||||||
import { probeMatrix } from "./matrix/probe.js";
|
import { probeMatrix } from "./matrix/probe.js";
|
||||||
import { sendMessageMatrix } from "./matrix/send.js";
|
import { sendMessageMatrix } from "./matrix/send.js";
|
||||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||||
import { matrixOutbound } from "./outbound.js";
|
import { matrixOutbound } from "./outbound.js";
|
||||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||||
|
import {
|
||||||
|
listMatrixDirectoryGroupsLive,
|
||||||
|
listMatrixDirectoryPeersLive,
|
||||||
|
} from "./directory-live.js";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
id: "matrix",
|
id: "matrix",
|
||||||
|
|
@ -44,9 +46,7 @@ const meta = {
|
||||||
|
|
||||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||||
let normalized = raw.trim();
|
let normalized = raw.trim();
|
||||||
if (!normalized) {
|
if (!normalized) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const lowered = normalized.toLowerCase();
|
const lowered = normalized.toLowerCase();
|
||||||
if (lowered.startsWith("matrix:")) {
|
if (lowered.startsWith("matrix:")) {
|
||||||
normalized = normalized.slice("matrix:".length).trim();
|
normalized = normalized.slice("matrix:".length).trim();
|
||||||
|
|
@ -109,7 +109,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
||||||
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||||
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
|
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
setAccountEnabledInConfigSection({
|
setAccountEnabledInConfigSection({
|
||||||
|
|
@ -153,20 +154,15 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
policyPath: "channels.matrix.dm.policy",
|
policyPath: "channels.matrix.dm.policy",
|
||||||
allowFromPath: "channels.matrix.dm.allowFrom",
|
allowFromPath: "channels.matrix.dm.allowFrom",
|
||||||
approveHint: formatPairingApproveHint("matrix"),
|
approveHint: formatPairingApproveHint("matrix"),
|
||||||
normalizeEntry: (raw) =>
|
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
|
||||||
raw
|
|
||||||
.replace(/^matrix:/i, "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase(),
|
|
||||||
}),
|
}),
|
||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy =
|
||||||
if (groupPolicy !== "open") {
|
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
return [];
|
if (groupPolicy !== "open") return [];
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
|
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -175,13 +171,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
resolveReplyToMode: ({ cfg }) =>
|
||||||
|
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
const currentTarget = context.To;
|
const currentTarget = context.To;
|
||||||
return {
|
return {
|
||||||
currentChannelId: currentTarget?.trim() || undefined,
|
currentChannelId: currentTarget?.trim() || undefined,
|
||||||
currentThreadTs:
|
currentThreadTs:
|
||||||
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
context.MessageThreadId != null
|
||||||
|
? String(context.MessageThreadId)
|
||||||
|
: context.ReplyToId,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -191,12 +190,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (raw) => {
|
looksLikeId: (raw) => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return false;
|
||||||
return false;
|
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||||
}
|
|
||||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return trimmed.includes(":");
|
return trimmed.includes(":");
|
||||||
},
|
},
|
||||||
hint: "<room|alias|user>",
|
hint: "<room|alias|user>",
|
||||||
|
|
@ -211,17 +206,13 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
|
|
||||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") {
|
if (!raw || raw === "*") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") {
|
if (!raw || raw === "*") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,9 +220,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
for (const room of Object.values(groups)) {
|
for (const room of Object.values(groups)) {
|
||||||
for (const entry of room.users ?? []) {
|
for (const entry of room.users ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") {
|
if (!raw || raw === "*") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,9 +231,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
.map((raw) => {
|
.map((raw) => {
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||||
if (cleaned.startsWith("@")) {
|
if (cleaned.startsWith("@")) return `user:${cleaned}`;
|
||||||
return `user:${cleaned}`;
|
|
||||||
}
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
})
|
})
|
||||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
|
|
@ -269,12 +256,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||||
.map((raw) => {
|
.map((raw) => {
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
|
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
|
||||||
return raw;
|
if (raw.startsWith("!")) return `room:${raw}`;
|
||||||
}
|
|
||||||
if (raw.startsWith("!")) {
|
|
||||||
return `room:${raw}`;
|
|
||||||
}
|
|
||||||
return raw;
|
return raw;
|
||||||
})
|
})
|
||||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
|
|
@ -302,12 +285,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
name,
|
name,
|
||||||
}),
|
}),
|
||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (input.useEnv) {
|
if (input.useEnv) return null;
|
||||||
return null;
|
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
||||||
}
|
|
||||||
if (!input.homeserver?.trim()) {
|
|
||||||
return "Matrix requires --homeserver";
|
|
||||||
}
|
|
||||||
const accessToken = input.accessToken?.trim();
|
const accessToken = input.accessToken?.trim();
|
||||||
const password = input.password?.trim();
|
const password = input.password?.trim();
|
||||||
const userId = input.userId?.trim();
|
const userId = input.userId?.trim();
|
||||||
|
|
@ -315,12 +294,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
return "Matrix requires --access-token or --password";
|
return "Matrix requires --access-token or --password";
|
||||||
}
|
}
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
if (!userId) {
|
if (!userId) return "Matrix requires --user-id when using --password";
|
||||||
return "Matrix requires --user-id when using --password";
|
if (!password) return "Matrix requires --password when using --user-id";
|
||||||
}
|
|
||||||
if (!password) {
|
|
||||||
return "Matrix requires --password when using --user-id";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -365,9 +340,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: (accounts) =>
|
||||||
accounts.flatMap((account) => {
|
accounts.flatMap((account) => {
|
||||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
if (!lastError) {
|
if (!lastError) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
|
|
@ -387,7 +360,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ timeoutMs, cfg }) => {
|
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
||||||
try {
|
try {
|
||||||
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
|
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
|
||||||
return await probeMatrix({
|
return await probeMatrix({
|
||||||
|
|
@ -427,9 +400,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
baseUrl: account.homeserver,
|
baseUrl: account.homeserver,
|
||||||
});
|
});
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
|
ctx.log?.info(
|
||||||
|
`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`,
|
||||||
|
);
|
||||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||||
const { monitorMatrixProvider } = await import("./matrix/index.js");
|
// Use serialized import to prevent race conditions during parallel account startup.
|
||||||
|
const { monitorMatrixProvider } = await importMatrixIndex();
|
||||||
return monitorMatrixProvider({
|
return monitorMatrixProvider({
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
abortSignal: ctx.abortSignal,
|
abortSignal: ctx.abortSignal,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||||
|
|
||||||
type MatrixUserResult = {
|
type MatrixUserResult = {
|
||||||
|
|
@ -54,9 +55,7 @@ export async function listMatrixDirectoryPeersLive(params: {
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
}): Promise<ChannelDirectoryEntry[]> {
|
}): Promise<ChannelDirectoryEntry[]> {
|
||||||
const query = normalizeQuery(params.query);
|
const query = normalizeQuery(params.query);
|
||||||
if (!query) {
|
if (!query) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
|
|
@ -72,9 +71,7 @@ export async function listMatrixDirectoryPeersLive(params: {
|
||||||
return results
|
return results
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const userId = entry.user_id?.trim();
|
const userId = entry.user_id?.trim();
|
||||||
if (!userId) {
|
if (!userId) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|
@ -126,17 +123,13 @@ export async function listMatrixDirectoryGroupsLive(params: {
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
}): Promise<ChannelDirectoryEntry[]> {
|
}): Promise<ChannelDirectoryEntry[]> {
|
||||||
const query = normalizeQuery(params.query);
|
const query = normalizeQuery(params.query);
|
||||||
if (!query) {
|
if (!query) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||||
|
|
||||||
if (query.startsWith("#")) {
|
if (query.startsWith("#")) {
|
||||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||||
if (!roomId) {
|
if (!roomId) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: "group",
|
kind: "group",
|
||||||
|
|
@ -167,21 +160,15 @@ export async function listMatrixDirectoryGroupsLive(params: {
|
||||||
|
|
||||||
for (const roomId of rooms) {
|
for (const roomId of rooms) {
|
||||||
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
||||||
if (!name) {
|
if (!name) continue;
|
||||||
continue;
|
if (!name.toLowerCase().includes(query)) continue;
|
||||||
}
|
|
||||||
if (!name.toLowerCase().includes(query)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
results.push({
|
results.push({
|
||||||
kind: "group",
|
kind: "group",
|
||||||
id: roomId,
|
id: roomId,
|
||||||
name,
|
name,
|
||||||
handle: `#${name}`,
|
handle: `#${name}`,
|
||||||
});
|
});
|
||||||
if (results.length >= limit) {
|
if (results.length >= limit) break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig } from "./types.js";
|
|
||||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||||
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||||
const rawGroupId = params.groupId?.trim() ?? "";
|
const rawGroupId = params.groupId?.trim() ?? "";
|
||||||
|
|
@ -25,15 +26,9 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||||
name: groupChannel || undefined,
|
name: groupChannel || undefined,
|
||||||
}).config;
|
}).config;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
if (resolved.autoReply === true) {
|
if (resolved.autoReply === true) return false;
|
||||||
return false;
|
if (resolved.autoReply === false) return true;
|
||||||
}
|
if (typeof resolved.requireMention === "boolean") return resolved.requireMention;
|
||||||
if (resolved.autoReply === false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof resolved.requireMention === "boolean") {
|
|
||||||
return resolved.requireMention;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { resolveMatrixAccount } from "./accounts.js";
|
import { resolveMatrixAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||||
import { resolveMatrixConfig } from "./client.js";
|
import { resolveMatrixConfig } from "./client.js";
|
||||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||||
|
|
||||||
|
|
@ -10,56 +10,155 @@ export type ResolvedMatrixAccount = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
homeserver?: string;
|
homeserver?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
config: MatrixConfig;
|
accessToken?: string;
|
||||||
|
config: MatrixAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
|
/**
|
||||||
return [DEFAULT_ACCOUNT_ID];
|
* List account IDs explicitly configured in channels.matrix.accounts
|
||||||
|
*/
|
||||||
|
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||||
|
const accounts = cfg.channels?.matrix?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const key of Object.keys(accounts)) {
|
||||||
|
if (!key) continue;
|
||||||
|
ids.add(normalizeAccountId(key));
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List account IDs referenced in bindings for matrix channel
|
||||||
|
*/
|
||||||
|
function listBoundAccountIds(cfg: CoreConfig): string[] {
|
||||||
|
const bindings = cfg.bindings;
|
||||||
|
if (!Array.isArray(bindings)) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (binding.match?.channel === "matrix" && binding.match?.accountId) {
|
||||||
|
ids.add(normalizeAccountId(binding.match.accountId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Matrix account IDs (configured + bound)
|
||||||
|
*/
|
||||||
|
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||||
|
const ids = Array.from(
|
||||||
|
new Set([
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
...listConfiguredAccountIds(cfg),
|
||||||
|
...listBoundAccountIds(cfg),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||||
const ids = listMatrixAccountIds(cfg);
|
const ids = listMatrixAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||||
return DEFAULT_ACCOUNT_ID;
|
|
||||||
}
|
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account-specific config from channels.matrix.accounts[accountId]
|
||||||
|
*/
|
||||||
|
function resolveAccountConfig(
|
||||||
|
cfg: CoreConfig,
|
||||||
|
accountId: string,
|
||||||
|
): MatrixAccountConfig | undefined {
|
||||||
|
const accounts = cfg.channels?.matrix?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
const matchKey = Object.keys(accounts).find(
|
||||||
|
(key) => normalizeAccountId(key) === normalized
|
||||||
|
);
|
||||||
|
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge base matrix config with account-specific overrides
|
||||||
|
*/
|
||||||
|
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
|
||||||
|
const base = cfg.channels?.matrix ?? {};
|
||||||
|
// Extract base config without 'accounts' key
|
||||||
|
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
|
||||||
|
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
|
|
||||||
|
// Account config overrides base config
|
||||||
|
return { ...baseConfig, ...accountConfig };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMatrixAccount(params: {
|
export function resolveMatrixAccount(params: {
|
||||||
cfg: CoreConfig;
|
cfg: CoreConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedMatrixAccount {
|
}): ResolvedMatrixAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const base = params.cfg.channels?.matrix ?? {};
|
const merged = mergeMatrixAccountConfig(params.cfg, accountId);
|
||||||
const enabled = base.enabled !== false;
|
|
||||||
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
// Check if this is a non-default account - use account-specific auth
|
||||||
const hasHomeserver = Boolean(resolved.homeserver);
|
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID || accountId === "default";
|
||||||
const hasUserId = Boolean(resolved.userId);
|
|
||||||
const hasAccessToken = Boolean(resolved.accessToken);
|
// For non-default accounts, use account-specific credentials
|
||||||
const hasPassword = Boolean(resolved.password);
|
// For default account, use base config or env
|
||||||
|
let homeserver = merged.homeserver;
|
||||||
|
let userId = merged.userId;
|
||||||
|
let accessToken = merged.accessToken;
|
||||||
|
|
||||||
|
if (isDefaultAccount) {
|
||||||
|
// Default account can fall back to env vars
|
||||||
|
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
||||||
|
homeserver = homeserver || resolved.homeserver;
|
||||||
|
userId = userId || resolved.userId;
|
||||||
|
accessToken = accessToken || resolved.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseEnabled = params.cfg.channels?.matrix?.enabled !== false;
|
||||||
|
const accountEnabled = merged.enabled !== false;
|
||||||
|
const enabled = baseEnabled && accountEnabled;
|
||||||
|
|
||||||
|
const hasHomeserver = Boolean(homeserver);
|
||||||
|
const hasAccessToken = Boolean(accessToken);
|
||||||
|
const hasPassword = Boolean(merged.password);
|
||||||
|
const hasUserId = Boolean(userId);
|
||||||
const hasPasswordAuth = hasUserId && hasPassword;
|
const hasPasswordAuth = hasUserId && hasPassword;
|
||||||
const stored = loadMatrixCredentials(process.env);
|
|
||||||
|
// Check for stored credentials (only for default account)
|
||||||
|
const stored = isDefaultAccount ? loadMatrixCredentials(process.env) : null;
|
||||||
const hasStored =
|
const hasStored =
|
||||||
stored && resolved.homeserver
|
stored && homeserver
|
||||||
? credentialsMatchConfig(stored, {
|
? credentialsMatchConfig(stored, {
|
||||||
homeserver: resolved.homeserver,
|
homeserver: homeserver,
|
||||||
userId: resolved.userId || "",
|
userId: userId || "",
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
name: base.name?.trim() || undefined,
|
name: merged.name?.trim() || undefined,
|
||||||
configured,
|
configured,
|
||||||
homeserver: resolved.homeserver || undefined,
|
homeserver: homeserver || undefined,
|
||||||
userId: resolved.userId || undefined,
|
userId: userId || undefined,
|
||||||
config: base,
|
accessToken: accessToken || undefined,
|
||||||
|
config: merged,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
||||||
return listMatrixAccountIds(cfg)
|
return listMatrixAccountIds(cfg)
|
||||||
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.filter((account) => account.enabled && account.configured);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { CoreConfig } from "../types.js";
|
|
||||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
import { getActiveMatrixClient } from "../active-client.js";
|
import { getActiveMatrixClient } from "../active-client.js";
|
||||||
import {
|
import {
|
||||||
createMatrixClient,
|
createMatrixClient,
|
||||||
|
|
@ -8,6 +7,7 @@ import {
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
} from "../client.js";
|
} from "../client.js";
|
||||||
|
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||||
|
|
||||||
export function ensureNodeRuntime() {
|
export function ensureNodeRuntime() {
|
||||||
if (isBunRuntime()) {
|
if (isBunRuntime()) {
|
||||||
|
|
@ -19,23 +19,24 @@ export async function resolveActionClient(
|
||||||
opts: MatrixActionClientOpts = {},
|
opts: MatrixActionClientOpts = {},
|
||||||
): Promise<MatrixActionClient> {
|
): Promise<MatrixActionClient> {
|
||||||
ensureNodeRuntime();
|
ensureNodeRuntime();
|
||||||
if (opts.client) {
|
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||||
return { client: opts.client, stopOnDone: false };
|
|
||||||
}
|
// Try to get the active client for the specified account
|
||||||
const active = getActiveMatrixClient();
|
const active = getActiveMatrixClient(opts.accountId);
|
||||||
if (active) {
|
if (active) return { client: active, stopOnDone: false };
|
||||||
return { client: active, stopOnDone: false };
|
|
||||||
}
|
|
||||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({
|
const auth = await resolveMatrixAuth({
|
||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
accountId: opts.accountId ?? undefined,
|
||||||
});
|
});
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
|
|
@ -43,6 +44,7 @@ export async function resolveActionClient(
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId ?? undefined,
|
||||||
});
|
});
|
||||||
if (auth.encryption && client.crypto) {
|
if (auth.encryption && client.crypto) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
|
||||||
import { resolveActionClient } from "./client.js";
|
|
||||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
MsgType,
|
MsgType,
|
||||||
|
|
@ -10,6 +7,9 @@ import {
|
||||||
type MatrixRawEvent,
|
type MatrixRawEvent,
|
||||||
type RoomMessageEventContent,
|
type RoomMessageEventContent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||||
|
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||||
|
|
||||||
export async function sendMatrixMessage(
|
export async function sendMatrixMessage(
|
||||||
to: string,
|
to: string,
|
||||||
|
|
@ -26,6 +26,7 @@ export async function sendMatrixMessage(
|
||||||
threadId: opts.threadId,
|
threadId: opts.threadId,
|
||||||
client: opts.client,
|
client: opts.client,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,9 +37,7 @@ export async function editMatrixMessage(
|
||||||
opts: MatrixActionClientOpts = {},
|
opts: MatrixActionClientOpts = {},
|
||||||
) {
|
) {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) throw new Error("Matrix edit requires content");
|
||||||
throw new Error("Matrix edit requires content");
|
|
||||||
}
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
|
@ -58,9 +57,7 @@ export async function editMatrixMessage(
|
||||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||||
return { eventId: eventId ?? null };
|
return { eventId: eventId ?? null };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,9 +71,7 @@ export async function deleteMatrixMessage(
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +97,7 @@ export async function readMatrixMessages(
|
||||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||||
const dir = opts.after ? "f" : "b";
|
const dir = opts.after ? "f" : "b";
|
||||||
// @vector-im/matrix-bot-sdk uses doRequest for room messages
|
// @vector-im/matrix-bot-sdk uses doRequest for room messages
|
||||||
const res = (await client.doRequest(
|
const res = await client.doRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||||
{
|
{
|
||||||
|
|
@ -110,7 +105,7 @@ export async function readMatrixMessages(
|
||||||
limit,
|
limit,
|
||||||
from: token,
|
from: token,
|
||||||
},
|
},
|
||||||
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
||||||
const messages = res.chunk
|
const messages = res.chunk
|
||||||
.filter((event) => event.type === EventType.RoomMessage)
|
.filter((event) => event.type === EventType.RoomMessage)
|
||||||
.filter((event) => !event.unsigned?.redacted_because)
|
.filter((event) => !event.unsigned?.redacted_because)
|
||||||
|
|
@ -121,8 +116,6 @@ export async function readMatrixMessages(
|
||||||
prevBatch: res.start ?? null,
|
prevBatch: res.start ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { resolveMatrixRoomId } from "../send.js";
|
|
||||||
import { resolveActionClient } from "./client.js";
|
|
||||||
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
type MatrixActionClientOpts,
|
type MatrixActionClientOpts,
|
||||||
type MatrixMessageSummary,
|
type MatrixMessageSummary,
|
||||||
type RoomPinnedEventsEventContent,
|
type RoomPinnedEventsEventContent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
export async function pinMatrixMessage(
|
export async function pinMatrixMessage(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|
@ -22,9 +22,7 @@ export async function pinMatrixMessage(
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +40,7 @@ export async function unpinMatrixMessage(
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,8 +65,6 @@ export async function listMatrixPins(
|
||||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||||
return { pinned, events };
|
return { pinned, events };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { resolveMatrixRoomId } from "../send.js";
|
|
||||||
import { resolveActionClient } from "./client.js";
|
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
RelationType,
|
RelationType,
|
||||||
|
|
@ -8,6 +6,8 @@ import {
|
||||||
type MatrixReactionSummary,
|
type MatrixReactionSummary,
|
||||||
type ReactionEventContent,
|
type ReactionEventContent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
export async function listMatrixReactions(
|
export async function listMatrixReactions(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|
@ -22,18 +22,16 @@ export async function listMatrixReactions(
|
||||||
? Math.max(1, Math.floor(opts.limit))
|
? Math.max(1, Math.floor(opts.limit))
|
||||||
: 100;
|
: 100;
|
||||||
// @vector-im/matrix-bot-sdk uses doRequest for relations
|
// @vector-im/matrix-bot-sdk uses doRequest for relations
|
||||||
const res = (await client.doRequest(
|
const res = await client.doRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
{ dir: "b", limit },
|
{ dir: "b", limit },
|
||||||
)) as { chunk: MatrixRawEvent[] };
|
) as { chunk: MatrixRawEvent[] };
|
||||||
const summaries = new Map<string, MatrixReactionSummary>();
|
const summaries = new Map<string, MatrixReactionSummary>();
|
||||||
for (const event of res.chunk) {
|
for (const event of res.chunk) {
|
||||||
const content = event.content as ReactionEventContent;
|
const content = event.content as ReactionEventContent;
|
||||||
const key = content["m.relates_to"]?.key;
|
const key = content["m.relates_to"]?.key;
|
||||||
if (!key) {
|
if (!key) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const sender = event.sender ?? "";
|
const sender = event.sender ?? "";
|
||||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||||
key,
|
key,
|
||||||
|
|
@ -48,9 +46,7 @@ export async function listMatrixReactions(
|
||||||
}
|
}
|
||||||
return Array.from(summaries.values());
|
return Array.from(summaries.values());
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,35 +58,27 @@ export async function removeMatrixReactions(
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
const res = (await client.doRequest(
|
const res = await client.doRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
{ dir: "b", limit: 200 },
|
{ dir: "b", limit: 200 },
|
||||||
)) as { chunk: MatrixRawEvent[] };
|
) as { chunk: MatrixRawEvent[] };
|
||||||
const userId = await client.getUserId();
|
const userId = await client.getUserId();
|
||||||
if (!userId) {
|
if (!userId) return { removed: 0 };
|
||||||
return { removed: 0 };
|
|
||||||
}
|
|
||||||
const targetEmoji = opts.emoji?.trim();
|
const targetEmoji = opts.emoji?.trim();
|
||||||
const toRemove = res.chunk
|
const toRemove = res.chunk
|
||||||
.filter((event) => event.sender === userId)
|
.filter((event) => event.sender === userId)
|
||||||
.filter((event) => {
|
.filter((event) => {
|
||||||
if (!targetEmoji) {
|
if (!targetEmoji) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const content = event.content as ReactionEventContent;
|
const content = event.content as ReactionEventContent;
|
||||||
return content["m.relates_to"]?.key === targetEmoji;
|
return content["m.relates_to"]?.key === targetEmoji;
|
||||||
})
|
})
|
||||||
.map((event) => event.event_id)
|
.map((event) => event.event_id)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
if (toRemove.length === 0) {
|
if (toRemove.length === 0) return { removed: 0 };
|
||||||
return { removed: 0 };
|
|
||||||
}
|
|
||||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||||
return { removed: toRemove.length };
|
return { removed: toRemove.length };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { resolveMatrixRoomId } from "../send.js";
|
|
||||||
import { resolveActionClient } from "./client.js";
|
|
||||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
export async function getMatrixMemberInfo(
|
export async function getMatrixMemberInfo(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|
@ -25,13 +25,14 @@ export async function getMatrixMemberInfo(
|
||||||
roomId: roomId ?? null,
|
roomId: roomId ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
export async function getMatrixRoomInfo(
|
||||||
|
roomId: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
) {
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
|
@ -56,7 +57,11 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
const aliasState = await client.getRoomStateEvent(
|
||||||
|
resolvedRoom,
|
||||||
|
"m.room.canonical_alias",
|
||||||
|
"",
|
||||||
|
);
|
||||||
canonicalAlias = aliasState?.alias ?? null;
|
canonicalAlias = aliasState?.alias ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -78,8 +83,6 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) client.stop();
|
||||||
client.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
type MatrixMessageSummary,
|
type MatrixMessageSummary,
|
||||||
|
|
@ -37,7 +38,10 @@ export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSum
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
export async function readPinnedEvents(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const content = (await client.getRoomStateEvent(
|
const content = (await client.getRoomStateEvent(
|
||||||
roomId,
|
roomId,
|
||||||
|
|
@ -64,9 +68,7 @@ export async function fetchEventSummary(
|
||||||
): Promise<MatrixMessageSummary | null> {
|
): Promise<MatrixMessageSummary | null> {
|
||||||
try {
|
try {
|
||||||
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
||||||
if (raw.unsigned?.redacted_because) {
|
if (raw.unsigned?.redacted_because) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return summarizeMatrixRawEvent(raw);
|
return summarizeMatrixRawEvent(raw);
|
||||||
} catch {
|
} catch {
|
||||||
// Event not found, redacted, or inaccessible - return null
|
// Event not found, redacted, or inaccessible - return null
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export type MatrixRawEvent = {
|
||||||
export type MatrixActionClientOpts = {
|
export type MatrixActionClientOpts = {
|
||||||
client?: MatrixClient;
|
client?: MatrixClient;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatrixMessageSummary = {
|
export type MatrixMessageSummary = {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,34 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
let activeClient: MatrixClient | null = null;
|
const DEFAULT_ACCOUNT_KEY = "default";
|
||||||
|
|
||||||
export function setActiveMatrixClient(client: MatrixClient | null): void {
|
// Multi-account: Map of accountId -> client
|
||||||
activeClient = client;
|
const activeClients = new Map<string, MatrixClient>();
|
||||||
|
|
||||||
|
function normalizeAccountKey(accountId?: string | null): string {
|
||||||
|
return accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveMatrixClient(): MatrixClient | null {
|
export function setActiveMatrixClient(client: MatrixClient | null, accountId?: string | null): void {
|
||||||
return activeClient;
|
const key = normalizeAccountKey(accountId);
|
||||||
|
if (client) {
|
||||||
|
activeClients.set(key, client);
|
||||||
|
} else {
|
||||||
|
activeClients.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||||
|
const key = normalizeAccountKey(accountId);
|
||||||
|
const client = activeClients.get(key);
|
||||||
|
if (client) return client;
|
||||||
|
// Fallback: if specific account not found, try default
|
||||||
|
if (key !== DEFAULT_ACCOUNT_KEY) {
|
||||||
|
return activeClients.get(DEFAULT_ACCOUNT_KEY) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveMatrixClients(): Array<{ accountId: string; client: MatrixClient }> {
|
||||||
|
return Array.from(activeClients.entries()).map(([accountId, client]) => ({ accountId, client }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { resolveMatrixConfig } from "./client.js";
|
import { resolveMatrixConfig } from "./client.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,8 @@ export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||||
export { isBunRuntime } from "./client/runtime.js";
|
export { isBunRuntime } from "./client/runtime.js";
|
||||||
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
|
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
|
||||||
export { createMatrixClient } from "./client/create-client.js";
|
export { createMatrixClient } from "./client/create-client.js";
|
||||||
export { resolveSharedMatrixClient, waitForMatrixSync, stopSharedClient } from "./client/shared.js";
|
export {
|
||||||
|
resolveSharedMatrixClient,
|
||||||
|
waitForMatrixSync,
|
||||||
|
stopSharedClient,
|
||||||
|
} from "./client/shared.js";
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,70 @@
|
||||||
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
import type { CoreConfig } from "../types.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
|
||||||
|
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||||
|
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||||
|
import { importCredentials } from "../import-mutex.js";
|
||||||
|
|
||||||
function clean(value?: string): string {
|
function clean(value?: string): string {
|
||||||
return value?.trim() ?? "";
|
return value?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account-specific config from channels.matrix.accounts[accountId]
|
||||||
|
*/
|
||||||
|
function resolveAccountConfig(
|
||||||
|
cfg: CoreConfig,
|
||||||
|
accountId: string,
|
||||||
|
): MatrixAccountConfig | undefined {
|
||||||
|
const accounts = cfg.channels?.matrix?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
const matchKey = Object.keys(accounts).find(
|
||||||
|
(key) => normalizeAccountId(key) === normalized
|
||||||
|
);
|
||||||
|
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge base matrix config with account-specific overrides
|
||||||
|
*/
|
||||||
|
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
|
||||||
|
const base = cfg.channels?.matrix ?? {};
|
||||||
|
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
|
||||||
|
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
|
return { ...baseConfig, ...accountConfig };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMatrixConfig(
|
export function resolveMatrixConfig(
|
||||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
accountId?: string,
|
||||||
): MatrixResolvedConfig {
|
): MatrixResolvedConfig {
|
||||||
const matrix = cfg.channels?.matrix ?? {};
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
|
||||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
|
||||||
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
// Get merged config for this account
|
||||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
const merged = mergeMatrixAccountConfig(cfg, normalizedAccountId);
|
||||||
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
|
||||||
|
// For default account, allow env var fallbacks
|
||||||
|
const homeserver = clean(merged.homeserver) || (isDefaultAccount ? clean(env.MATRIX_HOMESERVER) : "");
|
||||||
|
const userId = clean(merged.userId) || (isDefaultAccount ? clean(env.MATRIX_USER_ID) : "");
|
||||||
|
const accessToken = clean(merged.accessToken) || (isDefaultAccount ? clean(env.MATRIX_ACCESS_TOKEN) : "") || undefined;
|
||||||
|
const password = clean(merged.password) || (isDefaultAccount ? clean(env.MATRIX_PASSWORD) : "") || undefined;
|
||||||
|
const deviceName = clean(merged.deviceName) || (isDefaultAccount ? clean(env.MATRIX_DEVICE_NAME) : "") || undefined;
|
||||||
const initialSyncLimit =
|
const initialSyncLimit =
|
||||||
typeof matrix.initialSyncLimit === "number"
|
typeof merged.initialSyncLimit === "number"
|
||||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
? Math.max(0, Math.floor(merged.initialSyncLimit))
|
||||||
: undefined;
|
: undefined;
|
||||||
const encryption = matrix.encryption ?? false;
|
const encryption = merged.encryption ?? false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
homeserver,
|
homeserver,
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -37,22 +79,30 @@ export function resolveMatrixConfig(
|
||||||
export async function resolveMatrixAuth(params?: {
|
export async function resolveMatrixAuth(params?: {
|
||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
accountId?: string;
|
||||||
}): Promise<MatrixAuth> {
|
}): Promise<MatrixAuth> {
|
||||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||||
const env = params?.env ?? process.env;
|
const env = params?.env ?? process.env;
|
||||||
const resolved = resolveMatrixConfig(cfg, env);
|
const accountId = params?.accountId;
|
||||||
|
const resolved = resolveMatrixConfig(cfg, env, accountId);
|
||||||
|
|
||||||
if (!resolved.homeserver) {
|
if (!resolved.homeserver) {
|
||||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
throw new Error(`Matrix homeserver is required for account ${accountId ?? "default"} (matrix.homeserver)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
|
||||||
|
|
||||||
|
// Only use cached credentials for default account
|
||||||
|
// Use serialized import to prevent race conditions during parallel account startup
|
||||||
const {
|
const {
|
||||||
loadMatrixCredentials,
|
loadMatrixCredentials,
|
||||||
saveMatrixCredentials,
|
saveMatrixCredentials,
|
||||||
credentialsMatchConfig,
|
credentialsMatchConfig,
|
||||||
touchMatrixCredentials,
|
touchMatrixCredentials,
|
||||||
} = await import("../credentials.js");
|
} = await importCredentials();
|
||||||
|
|
||||||
const cached = loadMatrixCredentials(env);
|
const cached = isDefaultAccount ? loadMatrixCredentials(env) : null;
|
||||||
const cachedCredentials =
|
const cachedCredentials =
|
||||||
cached &&
|
cached &&
|
||||||
credentialsMatchConfig(cached, {
|
credentialsMatchConfig(cached, {
|
||||||
|
|
@ -71,13 +121,15 @@ export async function resolveMatrixAuth(params?: {
|
||||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||||
const whoami = await tempClient.getUserId();
|
const whoami = await tempClient.getUserId();
|
||||||
userId = whoami;
|
userId = whoami;
|
||||||
// Save the credentials with the fetched userId
|
// Only save credentials for default account
|
||||||
saveMatrixCredentials({
|
if (isDefaultAccount) {
|
||||||
homeserver: resolved.homeserver,
|
saveMatrixCredentials({
|
||||||
userId,
|
homeserver: resolved.homeserver,
|
||||||
accessToken: resolved.accessToken,
|
userId,
|
||||||
});
|
accessToken: resolved.accessToken,
|
||||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
});
|
||||||
|
}
|
||||||
|
} else if (isDefaultAccount && cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||||
touchMatrixCredentials(env);
|
touchMatrixCredentials(env);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -90,7 +142,8 @@ export async function resolveMatrixAuth(params?: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedCredentials) {
|
// Try cached credentials (only for default account)
|
||||||
|
if (isDefaultAccount && cachedCredentials) {
|
||||||
touchMatrixCredentials(env);
|
touchMatrixCredentials(env);
|
||||||
return {
|
return {
|
||||||
homeserver: cachedCredentials.homeserver,
|
homeserver: cachedCredentials.homeserver,
|
||||||
|
|
@ -103,12 +156,14 @@ export async function resolveMatrixAuth(params?: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.userId) {
|
if (!resolved.userId) {
|
||||||
throw new Error("Matrix userId is required when no access token is configured (matrix.userId)");
|
throw new Error(
|
||||||
|
`Matrix userId is required for account ${accountId ?? "default"} when no access token is configured`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.password) {
|
if (!resolved.password) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Matrix password is required when no access token is configured (matrix.password)",
|
`Matrix password is required for account ${accountId ?? "default"} when no access token is configured`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +181,7 @@ export async function resolveMatrixAuth(params?: {
|
||||||
|
|
||||||
if (!loginResponse.ok) {
|
if (!loginResponse.ok) {
|
||||||
const errorText = await loginResponse.text();
|
const errorText = await loginResponse.text();
|
||||||
throw new Error(`Matrix login failed: ${errorText}`);
|
throw new Error(`Matrix login failed for account ${accountId ?? "default"}: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = (await loginResponse.json()) as {
|
const login = (await loginResponse.json()) as {
|
||||||
|
|
@ -137,7 +192,7 @@ export async function resolveMatrixAuth(params?: {
|
||||||
|
|
||||||
const accessToken = login.access_token?.trim();
|
const accessToken = login.access_token?.trim();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error("Matrix login did not return an access token");
|
throw new Error(`Matrix login did not return an access token for account ${accountId ?? "default"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth: MatrixAuth = {
|
const auth: MatrixAuth = {
|
||||||
|
|
@ -149,12 +204,15 @@ export async function resolveMatrixAuth(params?: {
|
||||||
encryption: resolved.encryption,
|
encryption: resolved.encryption,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveMatrixCredentials({
|
// Only save credentials for default account
|
||||||
homeserver: auth.homeserver,
|
if (isDefaultAccount) {
|
||||||
userId: auth.userId,
|
saveMatrixCredentials({
|
||||||
accessToken: auth.accessToken,
|
homeserver: auth.homeserver,
|
||||||
deviceId: login.device_id,
|
userId: auth.userId,
|
||||||
});
|
accessToken: auth.accessToken,
|
||||||
|
deviceId: login.device_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LogService,
|
LogService,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
SimpleFsStorageProvider,
|
SimpleFsStorageProvider,
|
||||||
RustSdkCryptoStorageProvider,
|
RustSdkCryptoStorageProvider,
|
||||||
} from "@vector-im/matrix-bot-sdk";
|
} from "@vector-im/matrix-bot-sdk";
|
||||||
import fs from "node:fs";
|
|
||||||
|
import { importCryptoNodejs } from "../import-mutex.js";
|
||||||
|
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||||
import {
|
import {
|
||||||
maybeMigrateLegacyStorage,
|
maybeMigrateLegacyStorage,
|
||||||
|
|
@ -14,9 +18,7 @@ import {
|
||||||
} from "./storage.js";
|
} from "./storage.js";
|
||||||
|
|
||||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||||
if (input == null) {
|
if (input == null) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!Array.isArray(input)) {
|
if (!Array.isArray(input)) {
|
||||||
LogService.warn(
|
LogService.warn(
|
||||||
"MatrixClientLite",
|
"MatrixClientLite",
|
||||||
|
|
@ -65,14 +67,14 @@ export async function createMatrixClient(params: {
|
||||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
// Use serialized import to prevent race conditions with native Rust module
|
||||||
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
|
const { StoreType } = await importCryptoNodejs();
|
||||||
} catch (err) {
|
cryptoStorage = new RustSdkCryptoStorageProvider(
|
||||||
LogService.warn(
|
storagePaths.cryptoPath,
|
||||||
"MatrixClientLite",
|
StoreType.Sqlite,
|
||||||
"Failed to initialize crypto storage, E2EE disabled:",
|
|
||||||
err,
|
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +85,12 @@ export async function createMatrixClient(params: {
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
|
const client = new MatrixClient(
|
||||||
|
params.homeserver,
|
||||||
|
params.accessToken,
|
||||||
|
storage,
|
||||||
|
cryptoStorage,
|
||||||
|
);
|
||||||
|
|
||||||
if (client.crypto) {
|
if (client.crypto) {
|
||||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,32 @@ import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
|
||||||
let matrixSdkLoggingConfigured = false;
|
let matrixSdkLoggingConfigured = false;
|
||||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||||
|
|
||||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
function shouldSuppressMatrixHttpNotFound(
|
||||||
if (module !== "MatrixHttpClient") {
|
module: string,
|
||||||
return false;
|
messageOrObject: unknown[],
|
||||||
}
|
): boolean {
|
||||||
|
if (module !== "MatrixHttpClient") return false;
|
||||||
return messageOrObject.some((entry) => {
|
return messageOrObject.some((entry) => {
|
||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||||
if (matrixSdkLoggingConfigured) {
|
if (matrixSdkLoggingConfigured) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
matrixSdkLoggingConfigured = true;
|
matrixSdkLoggingConfigured = true;
|
||||||
|
|
||||||
LogService.setLogger({
|
LogService.setLogger({
|
||||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
trace: (module, ...messageOrObject) =>
|
||||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
debug: (module, ...messageOrObject) =>
|
||||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||||
|
info: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||||
|
warn: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||||
error: (module, ...messageOrObject) => {
|
error: (module, ...messageOrObject) => {
|
||||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
||||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||||
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import type { MatrixAuth } from "./types.js";
|
|
||||||
import { resolveMatrixAuth } from "./config.js";
|
|
||||||
import { createMatrixClient } from "./create-client.js";
|
import { createMatrixClient } from "./create-client.js";
|
||||||
|
import { resolveMatrixAuth } from "./config.js";
|
||||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||||
|
import type { MatrixAuth } from "./types.js";
|
||||||
|
|
||||||
type SharedMatrixClientState = {
|
type SharedMatrixClientState = {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
|
|
@ -13,6 +14,12 @@ type SharedMatrixClientState = {
|
||||||
cryptoReady: boolean;
|
cryptoReady: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Multi-account support: Map of accountKey -> client state
|
||||||
|
const sharedClients = new Map<string, SharedMatrixClientState>();
|
||||||
|
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
|
||||||
|
const sharedClientStartPromises = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
// Legacy single-client references (for backwards compatibility)
|
||||||
let sharedClientState: SharedMatrixClientState | null = null;
|
let sharedClientState: SharedMatrixClientState | null = null;
|
||||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||||
let sharedClientStartPromise: Promise<void> | null = null;
|
let sharedClientStartPromise: Promise<void> | null = null;
|
||||||
|
|
@ -27,10 +34,14 @@ function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): stri
|
||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAccountKey(accountId?: string | null): string {
|
||||||
|
return accountId ?? DEFAULT_ACCOUNT_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
async function createSharedMatrixClient(params: {
|
async function createSharedMatrixClient(params: {
|
||||||
auth: MatrixAuth;
|
auth: MatrixAuth;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
accountId?: string | null;
|
accountId?: string;
|
||||||
}): Promise<SharedMatrixClientState> {
|
}): Promise<SharedMatrixClientState> {
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: params.auth.homeserver,
|
homeserver: params.auth.homeserver,
|
||||||
|
|
@ -53,15 +64,24 @@ async function ensureSharedClientStarted(params: {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
encryption?: boolean;
|
encryption?: boolean;
|
||||||
|
accountId?: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (params.state.started) {
|
if (params.state.started) return;
|
||||||
|
|
||||||
|
const accountKey = getAccountKey(params.accountId);
|
||||||
|
const existingPromise = sharedClientStartPromises.get(accountKey);
|
||||||
|
if (existingPromise) {
|
||||||
|
await existingPromise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sharedClientStartPromise) {
|
|
||||||
|
// Legacy compatibility
|
||||||
|
if (sharedClientStartPromise && !params.accountId) {
|
||||||
await sharedClientStartPromise;
|
await sharedClientStartPromise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sharedClientStartPromise = (async () => {
|
|
||||||
|
const startPromise = (async () => {
|
||||||
const client = params.state.client;
|
const client = params.state.client;
|
||||||
|
|
||||||
// Initialize crypto if enabled
|
// Initialize crypto if enabled
|
||||||
|
|
@ -80,10 +100,19 @@ async function ensureSharedClientStarted(params: {
|
||||||
await client.start();
|
await client.start();
|
||||||
params.state.started = true;
|
params.state.started = true;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
sharedClientStartPromises.set(accountKey, startPromise);
|
||||||
|
if (!params.accountId) {
|
||||||
|
sharedClientStartPromise = startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sharedClientStartPromise;
|
await startPromise;
|
||||||
} finally {
|
} finally {
|
||||||
sharedClientStartPromise = null;
|
sharedClientStartPromises.delete(accountKey);
|
||||||
|
if (!params.accountId) {
|
||||||
|
sharedClientStartPromise = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,23 +126,67 @@ export async function resolveSharedMatrixClient(
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<MatrixClient> {
|
): Promise<MatrixClient> {
|
||||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId ?? undefined }));
|
||||||
const key = buildSharedClientKey(auth, params.accountId);
|
const key = buildSharedClientKey(auth, params.accountId);
|
||||||
|
const accountKey = getAccountKey(params.accountId);
|
||||||
const shouldStart = params.startClient !== false;
|
const shouldStart = params.startClient !== false;
|
||||||
|
|
||||||
if (sharedClientState?.key === key) {
|
// Check if we already have this client in the multi-account map
|
||||||
|
const existingClient = sharedClients.get(accountKey);
|
||||||
|
if (existingClient?.key === key) {
|
||||||
|
if (shouldStart) {
|
||||||
|
await ensureSharedClientStarted({
|
||||||
|
state: existingClient,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Update legacy reference for default account
|
||||||
|
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
|
||||||
|
sharedClientState = existingClient;
|
||||||
|
}
|
||||||
|
return existingClient.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy compatibility: check old single-client state
|
||||||
|
if (!params.accountId && sharedClientState?.key === key) {
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
await ensureSharedClientStarted({
|
await ensureSharedClientStarted({
|
||||||
state: sharedClientState,
|
state: sharedClientState,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return sharedClientState.client;
|
return sharedClientState.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sharedClientPromise) {
|
// Check for pending creation promise for this account
|
||||||
|
const pendingPromise = sharedClientPromises.get(accountKey);
|
||||||
|
if (pendingPromise) {
|
||||||
|
const pending = await pendingPromise;
|
||||||
|
if (pending.key === key) {
|
||||||
|
if (shouldStart) {
|
||||||
|
await ensureSharedClientStarted({
|
||||||
|
state: pending,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pending.client;
|
||||||
|
}
|
||||||
|
// Key mismatch - stop old client
|
||||||
|
pending.client.stop();
|
||||||
|
sharedClients.delete(accountKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: check old single-client promise
|
||||||
|
if (!params.accountId && sharedClientPromise) {
|
||||||
const pending = await sharedClientPromise;
|
const pending = await sharedClientPromise;
|
||||||
if (pending.key === key) {
|
if (pending.key === key) {
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
|
|
@ -122,6 +195,7 @@ export async function resolveSharedMatrixClient(
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return pending.client;
|
return pending.client;
|
||||||
|
|
@ -131,25 +205,39 @@ export async function resolveSharedMatrixClient(
|
||||||
sharedClientPromise = null;
|
sharedClientPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedClientPromise = createSharedMatrixClient({
|
// Create new client
|
||||||
|
const createPromise = createSharedMatrixClient({
|
||||||
auth,
|
auth,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sharedClientPromises.set(accountKey, createPromise);
|
||||||
|
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
|
||||||
|
sharedClientPromise = createPromise;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await sharedClientPromise;
|
const created = await createPromise;
|
||||||
sharedClientState = created;
|
sharedClients.set(accountKey, created);
|
||||||
|
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
|
||||||
|
sharedClientState = created;
|
||||||
|
}
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
await ensureSharedClientStarted({
|
await ensureSharedClientStarted({
|
||||||
state: created,
|
state: created,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return created.client;
|
return created.client;
|
||||||
} finally {
|
} finally {
|
||||||
sharedClientPromise = null;
|
sharedClientPromises.delete(accountKey);
|
||||||
|
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
|
||||||
|
sharedClientPromise = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,9 +250,28 @@ export async function waitForMatrixSync(_params: {
|
||||||
// This is kept for API compatibility but is essentially a no-op now
|
// This is kept for API compatibility but is essentially a no-op now
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSharedClient(): void {
|
export function stopSharedClient(accountId?: string | null): void {
|
||||||
if (sharedClientState) {
|
if (accountId) {
|
||||||
sharedClientState.client.stop();
|
// Stop specific account
|
||||||
sharedClientState = null;
|
const accountKey = getAccountKey(accountId);
|
||||||
|
const client = sharedClients.get(accountKey);
|
||||||
|
if (client) {
|
||||||
|
client.client.stop();
|
||||||
|
sharedClients.delete(accountKey);
|
||||||
|
}
|
||||||
|
// Also clear legacy reference if it matches
|
||||||
|
if (sharedClientState?.key === client?.key) {
|
||||||
|
sharedClientState = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stop all clients (legacy behavior + all multi-account clients)
|
||||||
|
for (const [key, client] of sharedClients) {
|
||||||
|
client.client.stop();
|
||||||
|
sharedClients.delete(key);
|
||||||
|
}
|
||||||
|
if (sharedClientState) {
|
||||||
|
sharedClientState.client.stop();
|
||||||
|
sharedClientState = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { MatrixStoragePaths } from "./types.js";
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
import type { MatrixStoragePaths } from "./types.js";
|
||||||
|
|
||||||
export const DEFAULT_ACCOUNT_KEY = "default";
|
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||||
|
|
@ -20,9 +21,7 @@ function sanitizePathSegment(value: string): string {
|
||||||
function resolveHomeserverKey(homeserver: string): string {
|
function resolveHomeserverKey(homeserver: string): string {
|
||||||
try {
|
try {
|
||||||
const url = new URL(homeserver);
|
const url = new URL(homeserver);
|
||||||
if (url.host) {
|
if (url.host) return sanitizePathSegment(url.host);
|
||||||
return sanitizePathSegment(url.host);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
|
@ -83,14 +82,11 @@ export function maybeMigrateLegacyStorage(params: {
|
||||||
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||||
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||||
const hasNewStorage =
|
const hasNewStorage =
|
||||||
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
|
fs.existsSync(params.storagePaths.storagePath) ||
|
||||||
|
fs.existsSync(params.storagePaths.cryptoPath);
|
||||||
|
|
||||||
if (!hasLegacyStorage && !hasLegacyCrypto) {
|
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
||||||
return;
|
if (hasNewStorage) return;
|
||||||
}
|
|
||||||
if (hasNewStorage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
if (hasLegacyStorage) {
|
if (hasLegacyStorage) {
|
||||||
|
|
@ -124,7 +120,11 @@ export function writeStorageMeta(params: {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8");
|
fs.writeFileSync(
|
||||||
|
params.storagePaths.metaPath,
|
||||||
|
JSON.stringify(payload, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore meta write failures
|
// ignore meta write failures
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export type MatrixStoredCredentials = {
|
export type MatrixStoredCredentials = {
|
||||||
|
|
@ -18,7 +19,8 @@ export function resolveMatrixCredentialsDir(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
stateDir?: string,
|
stateDir?: string,
|
||||||
): string {
|
): string {
|
||||||
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
const resolvedStateDir =
|
||||||
|
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
return path.join(resolvedStateDir, "credentials", "matrix");
|
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,9 +34,7 @@ export function loadMatrixCredentials(
|
||||||
): MatrixStoredCredentials | null {
|
): MatrixStoredCredentials | null {
|
||||||
const credPath = resolveMatrixCredentialsPath(env);
|
const credPath = resolveMatrixCredentialsPath(env);
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(credPath)) {
|
if (!fs.existsSync(credPath)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const raw = fs.readFileSync(credPath, "utf-8");
|
const raw = fs.readFileSync(credPath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||||
if (
|
if (
|
||||||
|
|
@ -73,9 +73,7 @@ export function saveMatrixCredentials(
|
||||||
|
|
||||||
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
||||||
const existing = loadMatrixCredentials(env);
|
const existing = loadMatrixCredentials(env);
|
||||||
if (!existing) {
|
if (!existing) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.lastUsedAt = new Date().toISOString();
|
existing.lastUsedAt = new Date().toISOString();
|
||||||
const credPath = resolveMatrixCredentialsPath(env);
|
const credPath = resolveMatrixCredentialsPath(env);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
||||||
|
|
@ -26,9 +27,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
confirm?: (message: string) => Promise<boolean>;
|
confirm?: (message: string) => Promise<boolean>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (isMatrixSdkAvailable()) {
|
if (isMatrixSdkAvailable()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const confirm = params.confirm;
|
const confirm = params.confirm;
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
||||||
|
|
@ -53,8 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isMatrixSdkAvailable()) {
|
if (!isMatrixSdkAvailable()) {
|
||||||
throw new Error(
|
throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
|
||||||
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { markdownToMatrixHtml } from "./format.js";
|
import { markdownToMatrixHtml } from "./format.js";
|
||||||
|
|
||||||
describe("markdownToMatrixHtml", () => {
|
describe("markdownToMatrixHtml", () => {
|
||||||
|
|
|
||||||
88
extensions/matrix/src/matrix/import-mutex.ts
Normal file
88
extensions/matrix/src/matrix/import-mutex.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* Import Mutex - Serializes dynamic imports to prevent race conditions
|
||||||
|
*
|
||||||
|
* Problem: When multiple Matrix accounts start in parallel, they all call
|
||||||
|
* dynamic imports simultaneously. Native modules (like @matrix-org/matrix-sdk-crypto-nodejs)
|
||||||
|
* can crash when loaded in parallel from multiple promises.
|
||||||
|
*
|
||||||
|
* Solution: Cache the import promise so that concurrent callers await the same promise
|
||||||
|
* instead of triggering parallel imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Cache for import promises - key is module specifier
|
||||||
|
const importCache = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely import a module with deduplication.
|
||||||
|
* If an import is already in progress, returns the existing promise.
|
||||||
|
* Once resolved, the result is cached for future calls.
|
||||||
|
*/
|
||||||
|
export async function serializedImport<T>(
|
||||||
|
moduleSpecifier: string,
|
||||||
|
importFn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const existing = importCache.get(moduleSpecifier);
|
||||||
|
if (existing) {
|
||||||
|
return existing as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importPromise = importFn().catch((err) => {
|
||||||
|
// On failure, remove from cache to allow retry
|
||||||
|
importCache.delete(moduleSpecifier);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
importCache.set(moduleSpecifier, importPromise);
|
||||||
|
return importPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-cached imports for critical modules
|
||||||
|
let cryptoNodejsModule: typeof import("@matrix-org/matrix-sdk-crypto-nodejs") | null = null;
|
||||||
|
let credentialsModule: typeof import("./credentials.js") | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely import the crypto-nodejs module (Rust native).
|
||||||
|
* This is the most critical one - parallel imports of native modules crash.
|
||||||
|
*/
|
||||||
|
export async function importCryptoNodejs(): Promise<typeof import("@matrix-org/matrix-sdk-crypto-nodejs")> {
|
||||||
|
if (cryptoNodejsModule) return cryptoNodejsModule;
|
||||||
|
|
||||||
|
const mod = await serializedImport(
|
||||||
|
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||||
|
() => import("@matrix-org/matrix-sdk-crypto-nodejs")
|
||||||
|
);
|
||||||
|
cryptoNodejsModule = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely import the credentials module.
|
||||||
|
*/
|
||||||
|
export async function importCredentials(): Promise<typeof import("./credentials.js")> {
|
||||||
|
if (credentialsModule) return credentialsModule;
|
||||||
|
|
||||||
|
const mod = await serializedImport(
|
||||||
|
"../credentials.js",
|
||||||
|
() => import("./credentials.js")
|
||||||
|
);
|
||||||
|
credentialsModule = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-cached import for matrix index module
|
||||||
|
let matrixIndexModule: typeof import("./index.js") | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely import the matrix/index.js module.
|
||||||
|
* This is called from channel.ts during parallel account startup.
|
||||||
|
*/
|
||||||
|
export async function importMatrixIndex(): Promise<typeof import("./index.js")> {
|
||||||
|
if (matrixIndexModule) return matrixIndexModule;
|
||||||
|
|
||||||
|
const mod = await serializedImport(
|
||||||
|
"./matrix/index.js",
|
||||||
|
() => import("./index.js")
|
||||||
|
);
|
||||||
|
matrixIndexModule = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,7 @@ export function resolveMatrixAllowListMatch(params: {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}): MatrixAllowListMatch {
|
}): MatrixAllowListMatch {
|
||||||
const allowList = params.allowList;
|
const allowList = params.allowList;
|
||||||
if (allowList.length === 0) {
|
if (allowList.length === 0) return { allowed: false };
|
||||||
return { allowed: false };
|
|
||||||
}
|
|
||||||
if (allowList.includes("*")) {
|
if (allowList.includes("*")) {
|
||||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||||
}
|
}
|
||||||
|
|
@ -39,9 +37,7 @@ export function resolveMatrixAllowListMatch(params: {
|
||||||
{ value: localPart, source: "localpart" },
|
{ value: localPart, source: "localpart" },
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate.value) {
|
if (!candidate.value) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (allowList.includes(candidate.value)) {
|
if (allowList.includes(candidate.value)) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
||||||
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
|
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
|
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
|
|
@ -12,9 +13,7 @@ export function registerMatrixAutoJoin(params: {
|
||||||
const { client, cfg, runtime } = params;
|
const { client, cfg, runtime } = params;
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
const logVerbose = (message: string) => {
|
const logVerbose = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) {
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
runtime.log?.(message);
|
runtime.log?.(message);
|
||||||
};
|
};
|
||||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||||
|
|
@ -33,9 +32,7 @@ export function registerMatrixAutoJoin(params: {
|
||||||
|
|
||||||
// For "allowlist" mode, handle invites manually
|
// For "allowlist" mode, handle invites manually
|
||||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||||
if (autoJoin !== "allowlist") {
|
if (autoJoin !== "allowlist") return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get room alias if available
|
// Get room alias if available
|
||||||
let alias: string | undefined;
|
let alias: string | undefined;
|
||||||
|
|
|
||||||
14
extensions/matrix/src/matrix/monitor/debug-log.ts
Normal file
14
extensions/matrix/src/matrix/monitor/debug-log.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const DEBUG_LOG_PATH = "/home/keller/clawd/agents/mondo-assistant/debug-matrix.log";
|
||||||
|
|
||||||
|
export function debugLog(message: string): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const line = `[${timestamp}] ${message}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(DEBUG_LOG_PATH, line);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,16 +12,17 @@ type DirectRoomTrackerOptions = {
|
||||||
|
|
||||||
const DM_CACHE_TTL_MS = 30_000;
|
const DM_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
|
export function createDirectRoomTracker(
|
||||||
|
client: MatrixClient,
|
||||||
|
opts: DirectRoomTrackerOptions = {},
|
||||||
|
) {
|
||||||
const log = opts.log ?? (() => {});
|
const log = opts.log ?? (() => {});
|
||||||
let lastDmUpdateMs = 0;
|
let lastDmUpdateMs = 0;
|
||||||
let cachedSelfUserId: string | null = null;
|
let cachedSelfUserId: string | null = null;
|
||||||
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||||
|
|
||||||
const ensureSelfUserId = async (): Promise<string | null> => {
|
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||||
if (cachedSelfUserId) {
|
if (cachedSelfUserId) return cachedSelfUserId;
|
||||||
return cachedSelfUserId;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
cachedSelfUserId = await client.getUserId();
|
cachedSelfUserId = await client.getUserId();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -32,9 +33,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||||
|
|
||||||
const refreshDmCache = async (): Promise<void> => {
|
const refreshDmCache = async (): Promise<void> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
|
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastDmUpdateMs = now;
|
lastDmUpdateMs = now;
|
||||||
try {
|
try {
|
||||||
await client.dms.update();
|
await client.dms.update();
|
||||||
|
|
@ -62,9 +61,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||||
|
|
||||||
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
||||||
const target = userId?.trim();
|
const target = userId?.trim();
|
||||||
if (!target) {
|
if (!target) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
||||||
return state?.is_direct === true;
|
return state?.is_direct === true;
|
||||||
|
|
@ -97,7 +94,11 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
|
log(
|
||||||
|
`matrix: dm check room=${roomId} result=group members=${
|
||||||
|
memberCount ?? "unknown"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import type { MatrixAuth } from "../client.js";
|
import type { MatrixAuth } from "../client.js";
|
||||||
import type { MatrixRawEvent } from "./types.js";
|
import type { MatrixRawEvent } from "./types.js";
|
||||||
import { EventType } from "./types.js";
|
import { EventType } from "./types.js";
|
||||||
|
|
@ -25,7 +26,41 @@ export function registerMatrixMonitorEvents(params: {
|
||||||
onRoomMessage,
|
onRoomMessage,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
client.on("room.message", onRoomMessage);
|
// Track processed event IDs to avoid double-processing from room.message + room.decrypted_event
|
||||||
|
const processedEvents = new Set<string>();
|
||||||
|
const PROCESSED_EVENTS_MAX = 1000;
|
||||||
|
|
||||||
|
const deduplicatedHandler = async (roomId: string, event: MatrixRawEvent, source: string) => {
|
||||||
|
const eventId = event?.event_id;
|
||||||
|
if (!eventId) {
|
||||||
|
logVerboseMessage(`matrix: ${source} event has no id, processing anyway`);
|
||||||
|
await onRoomMessage(roomId, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedEvents.has(eventId)) {
|
||||||
|
logVerboseMessage(`matrix: ${source} skipping duplicate event id=${eventId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedEvents.add(eventId);
|
||||||
|
// Prevent memory leak by clearing old entries
|
||||||
|
if (processedEvents.size > PROCESSED_EVENTS_MAX) {
|
||||||
|
const iterator = processedEvents.values();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const next = iterator.next();
|
||||||
|
if (next.done) break;
|
||||||
|
processedEvents.delete(next.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logVerboseMessage(`matrix: ${source} processing event id=${eventId} room=${roomId}`);
|
||||||
|
await onRoomMessage(roomId, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
deduplicatedHandler(roomId, event, "room.message");
|
||||||
|
});
|
||||||
|
|
||||||
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
const eventId = event?.event_id ?? "unknown";
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
|
@ -36,12 +71,21 @@ export function registerMatrixMonitorEvents(params: {
|
||||||
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
const eventId = event?.event_id ?? "unknown";
|
const eventId = event?.event_id ?? "unknown";
|
||||||
const eventType = event?.type ?? "unknown";
|
const eventType = event?.type ?? "unknown";
|
||||||
|
const content = event?.content as Record<string, unknown> | undefined;
|
||||||
|
const hasFile = content && "file" in content;
|
||||||
|
const hasUrl = content && "url" in content;
|
||||||
|
// DEBUG: Always log decrypted events with file info
|
||||||
|
console.log(`[MATRIX-E2EE-DEBUG] decrypted_event room=${roomId} type=${eventType} id=${eventId} hasFile=${hasFile} hasUrl=${hasUrl}`);
|
||||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||||
|
// Process decrypted messages through the deduplicated handler
|
||||||
|
deduplicatedHandler(roomId, event, "room.decrypted_event");
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(
|
client.on(
|
||||||
"room.failed_decryption",
|
"room.failed_decryption",
|
||||||
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||||
|
// DEBUG: Always log failed decryption
|
||||||
|
console.log(`[MATRIX-E2EE-DEBUG] FAILED_DECRYPTION room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ roomId, eventId: event.event_id, error: error.message },
|
{ roomId, eventId: event.event_id, error: error.message },
|
||||||
"Failed to decrypt message",
|
"Failed to decrypt message",
|
||||||
|
|
@ -83,7 +127,8 @@ export function registerMatrixMonitorEvents(params: {
|
||||||
const hint = formatNativeDependencyHint({
|
const hint = formatNativeDependencyHint({
|
||||||
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
||||||
manager: "pnpm",
|
manager: "pnpm",
|
||||||
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
downloadCommand:
|
||||||
|
"node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
||||||
});
|
});
|
||||||
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
||||||
logger.warn({ roomId }, warning);
|
logger.warn({ roomId }, warning);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
// File-based debug logging
|
||||||
|
const DEBUG_LOG = "/home/keller/clawd/agents/mondo-assistant/matrix-debug.log";
|
||||||
|
function debugWrite(msg: string) {
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
createReplyPrefixContext,
|
||||||
createTypingCallbacks,
|
createTypingCallbacks,
|
||||||
|
|
@ -9,30 +19,25 @@ import {
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
|
||||||
import {
|
import {
|
||||||
formatPollAsText,
|
formatPollAsText,
|
||||||
isPollStartType,
|
isPollStartType,
|
||||||
parsePollStartContent,
|
parsePollStartContent,
|
||||||
type PollStartContent,
|
type PollStartContent,
|
||||||
} from "../poll-types.js";
|
} from "../poll-types.js";
|
||||||
import {
|
import { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js";
|
||||||
reactMatrixMessage,
|
|
||||||
sendMessageMatrix,
|
|
||||||
sendReadReceiptMatrix,
|
|
||||||
sendTypingMatrix,
|
|
||||||
} from "../send.js";
|
|
||||||
import {
|
import {
|
||||||
resolveMatrixAllowListMatch,
|
resolveMatrixAllowListMatch,
|
||||||
resolveMatrixAllowListMatches,
|
resolveMatrixAllowListMatches,
|
||||||
normalizeAllowListLower,
|
normalizeAllowListLower,
|
||||||
} from "./allowlist.js";
|
} from "./allowlist.js";
|
||||||
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
|
||||||
import { downloadMatrixMedia } from "./media.js";
|
import { downloadMatrixMedia } from "./media.js";
|
||||||
import { resolveMentions } from "./mentions.js";
|
import { resolveMentions } from "./mentions.js";
|
||||||
import { deliverMatrixReplies } from "./replies.js";
|
import { deliverMatrixReplies } from "./replies.js";
|
||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
|
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||||
|
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||||
import { EventType, RelationType } from "./types.js";
|
import { EventType, RelationType } from "./types.js";
|
||||||
|
|
||||||
export type MatrixMonitorHandlerParams = {
|
export type MatrixMonitorHandlerParams = {
|
||||||
|
|
@ -41,7 +46,7 @@ export type MatrixMonitorHandlerParams = {
|
||||||
logging: {
|
logging: {
|
||||||
shouldLogVerbose: () => boolean;
|
shouldLogVerbose: () => boolean;
|
||||||
};
|
};
|
||||||
channel: (typeof import("openclaw/plugin-sdk"))["channel"];
|
channel: typeof import("openclaw/plugin-sdk")["channel"];
|
||||||
system: {
|
system: {
|
||||||
enqueueSystemEvent: (
|
enqueueSystemEvent: (
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -63,7 +68,7 @@ export type MatrixMonitorHandlerParams = {
|
||||||
: Record<string, unknown> | undefined
|
: Record<string, unknown> | undefined
|
||||||
: Record<string, unknown> | undefined;
|
: Record<string, unknown> | undefined;
|
||||||
mentionRegexes: ReturnType<
|
mentionRegexes: ReturnType<
|
||||||
(typeof import("openclaw/plugin-sdk"))["channel"]["mentions"]["buildMentionRegexes"]
|
typeof import("openclaw/plugin-sdk")["channel"]["mentions"]["buildMentionRegexes"]
|
||||||
>;
|
>;
|
||||||
groupPolicy: "open" | "allowlist" | "disabled";
|
groupPolicy: "open" | "allowlist" | "disabled";
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
|
|
@ -81,10 +86,10 @@ export type MatrixMonitorHandlerParams = {
|
||||||
selfUserId: string;
|
selfUserId: string;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
getRoomInfo: (
|
getRoomInfo: (roomId: string) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
||||||
roomId: string,
|
|
||||||
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
|
||||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||||
|
/** Account ID for multi-account routing */
|
||||||
|
accountId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
||||||
|
|
@ -110,12 +115,15 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
directTracker,
|
directTracker,
|
||||||
getRoomInfo,
|
getRoomInfo,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
accountId,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
return async (roomId: string, event: MatrixRawEvent) => {
|
return async (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
debugWrite(`HANDLER-START: room=${roomId} eventId=${event.event_id ?? "unknown"} type=${event.type} sender=${event.sender} accountId=${accountId ?? "default"}`);
|
||||||
try {
|
try {
|
||||||
const eventType = event.type;
|
const eventType = event.type;
|
||||||
if (eventType === EventType.RoomMessageEncrypted) {
|
if (eventType === EventType.RoomMessageEncrypted) {
|
||||||
|
debugWrite(`HANDLER: SKIP encrypted event (should be auto-decrypted)`);
|
||||||
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
|
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -124,24 +132,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
const locationContent = event.content as LocationMessageEventContent;
|
const locationContent = event.content as LocationMessageEventContent;
|
||||||
const isLocationEvent =
|
const isLocationEvent =
|
||||||
eventType === EventType.Location ||
|
eventType === EventType.Location ||
|
||||||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
(eventType === EventType.RoomMessage &&
|
||||||
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
|
locationContent.msgtype === EventType.Location);
|
||||||
return;
|
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
|
||||||
}
|
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
if (event.unsigned?.redacted_because) {
|
if (event.unsigned?.redacted_because) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const senderId = event.sender;
|
const senderId = event.sender;
|
||||||
if (!senderId) {
|
if (!senderId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const selfUserId = await client.getUserId();
|
const selfUserId = await client.getUserId();
|
||||||
if (senderId === selfUserId) {
|
if (senderId === selfUserId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const eventTs = event.origin_server_ts;
|
const eventTs = event.origin_server_ts;
|
||||||
const eventAge = event.unsigned?.age;
|
const eventAge = event.unsigned?.age;
|
||||||
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
||||||
|
|
@ -157,7 +158,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
|
|
||||||
const roomInfo = await getRoomInfo(roomId);
|
const roomInfo = await getRoomInfo(roomId);
|
||||||
const roomName = roomInfo.name;
|
const roomName = roomInfo.name;
|
||||||
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
|
const roomAliases = [
|
||||||
|
roomInfo.canonicalAlias ?? "",
|
||||||
|
...roomInfo.altAliases,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
let content = event.content as RoomMessageEventContent;
|
let content = event.content as RoomMessageEventContent;
|
||||||
if (isPollEvent) {
|
if (isPollEvent) {
|
||||||
|
|
@ -186,9 +190,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
|
|
||||||
const relates = content["m.relates_to"];
|
const relates = content["m.relates_to"];
|
||||||
if (relates && "rel_type" in relates) {
|
if (relates && "rel_type" in relates) {
|
||||||
if (relates.rel_type === RelationType.Replace) {
|
if (relates.rel_type === RelationType.Replace) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDirectMessage = await directTracker.isDirectMessage({
|
const isDirectMessage = await directTracker.isDirectMessage({
|
||||||
|
|
@ -198,9 +200,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
});
|
});
|
||||||
const isRoom = !isDirectMessage;
|
const isRoom = !isDirectMessage;
|
||||||
|
|
||||||
if (isRoom && groupPolicy === "disabled") {
|
if (isRoom && groupPolicy === "disabled") return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomConfigInfo = isRoom
|
const roomConfigInfo = isRoom
|
||||||
? resolveMatrixRoomConfig({
|
? resolveMatrixRoomConfig({
|
||||||
|
|
@ -233,9 +233,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName = await getMemberDisplayName(roomId, senderId);
|
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||||
const storeAllowFrom = await core.channel.pairing
|
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||||
.readAllowFromStore("matrix")
|
|
||||||
.catch(() => []);
|
|
||||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||||
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
||||||
|
|
@ -245,9 +243,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
if (!dmEnabled || dmPolicy === "disabled") {
|
if (!dmEnabled || dmPolicy === "disabled") return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const allowMatch = resolveMatrixAllowListMatch({
|
const allowMatch = resolveMatrixAllowListMatch({
|
||||||
allowList: effectiveAllowFrom,
|
allowList: effectiveAllowFrom,
|
||||||
|
|
@ -329,8 +325,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBody =
|
const rawBody = locationPayload?.text
|
||||||
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
|
?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||||
let media: {
|
let media: {
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
|
@ -343,7 +339,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
? content.file
|
? content.file
|
||||||
: undefined;
|
: undefined;
|
||||||
const mediaUrl = contentUrl ?? contentFile?.url;
|
const mediaUrl = contentUrl ?? contentFile?.url;
|
||||||
|
|
||||||
|
// DEBUG: Log media detection
|
||||||
|
const msgtype = "msgtype" in content ? content.msgtype : undefined;
|
||||||
|
debugWrite(`HANDLER: room=${roomId} sender=${senderId} msgtype=${msgtype} contentUrl=${contentUrl ?? "none"} mediaUrl=${mediaUrl ?? "none"} accountId=${accountId ?? "default"}`);
|
||||||
|
logVerboseMessage(`matrix: content check msgtype=${msgtype} contentUrl=${contentUrl ?? "none"} mediaUrl=${mediaUrl ?? "none"} rawBody="${rawBody.slice(0,50)}"`);
|
||||||
|
|
||||||
if (!rawBody && !mediaUrl) {
|
if (!rawBody && !mediaUrl) {
|
||||||
|
debugWrite(`HANDLER: SKIP - no rawBody and no mediaUrl`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,8 +355,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
? (content.info as { mimetype?: string; size?: number })
|
? (content.info as { mimetype?: string; size?: number })
|
||||||
: undefined;
|
: undefined;
|
||||||
const contentType = contentInfo?.mimetype;
|
const contentType = contentInfo?.mimetype;
|
||||||
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
const contentSize =
|
||||||
|
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
|
debugWrite(`HANDLER: attempting media download url=${mediaUrl} size=${contentSize ?? "unknown"} maxBytes=${mediaMaxBytes}`);
|
||||||
|
logVerboseMessage(`matrix: attempting media download url=${mediaUrl} size=${contentSize ?? "unknown"} maxBytes=${mediaMaxBytes}`);
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
|
|
@ -363,15 +369,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
file: contentFile,
|
file: contentFile,
|
||||||
});
|
});
|
||||||
|
debugWrite(`HANDLER: media download SUCCESS path=${media?.path ?? "none"}`);
|
||||||
|
logVerboseMessage(`matrix: media download success path=${media?.path ?? "none"}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
debugWrite(`HANDLER: media download FAILED: ${String(err)}`);
|
||||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
} else if (mediaUrl) {
|
||||||
|
debugWrite(`HANDLER: skipping non-mxc url=${mediaUrl}`);
|
||||||
|
logVerboseMessage(`matrix: skipping non-mxc media url=${mediaUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = rawBody || media?.placeholder || "";
|
const bodyText = rawBody || media?.placeholder || "";
|
||||||
if (!bodyText) {
|
if (!bodyText) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||||
content,
|
content,
|
||||||
|
|
@ -461,6 +471,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "dm" : "channel",
|
kind: isDirectMessage ? "dm" : "channel",
|
||||||
id: isDirectMessage ? senderId : roomId,
|
id: isDirectMessage ? senderId : roomId,
|
||||||
|
|
@ -512,7 +523,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
...locationPayload?.context,
|
...(locationPayload?.context ?? {}),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "text" as const,
|
CommandSource: "text" as const,
|
||||||
OriginatingChannel: "matrix" as const,
|
OriginatingChannel: "matrix" as const,
|
||||||
|
|
@ -533,11 +544,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
: undefined,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||||
error: String(err),
|
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
||||||
},
|
|
||||||
"failed updating session meta",
|
"failed updating session meta",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -551,19 +558,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
const shouldAckReaction = () =>
|
const shouldAckReaction = () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
ackReaction &&
|
ackReaction &&
|
||||||
core.channel.reactions.shouldAckReaction({
|
core.channel.reactions.shouldAckReaction({
|
||||||
scope: ackScope,
|
scope: ackScope,
|
||||||
isDirect: isDirectMessage,
|
isDirect: isDirectMessage,
|
||||||
isGroup: isRoom,
|
isGroup: isRoom,
|
||||||
isMentionableGroup: isRoom,
|
isMentionableGroup: isRoom,
|
||||||
requireMention: Boolean(shouldRequireMention),
|
requireMention: Boolean(shouldRequireMention),
|
||||||
canDetectMention,
|
canDetectMention,
|
||||||
effectiveWasMentioned: wasMentioned || shouldBypassMention,
|
effectiveWasMentioned: wasMentioned || shouldBypassMention,
|
||||||
shouldBypassMention,
|
shouldBypassMention,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (shouldAckReaction() && messageId) {
|
if (shouldAckReaction() && messageId) {
|
||||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
reactMatrixMessage(roomId, messageId, ackReaction, { client }).catch((err) => {
|
||||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -648,9 +655,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
const finalCount = counts.final;
|
const finalCount = counts.final;
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { format } from "node:util";
|
import { format } from "node:util";
|
||||||
import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk";
|
|
||||||
|
import {
|
||||||
|
mergeAllowlist,
|
||||||
|
summarizeMapping,
|
||||||
|
type RuntimeEnv,
|
||||||
|
} from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
|
||||||
import { setActiveMatrixClient } from "../active-client.js";
|
import { setActiveMatrixClient } from "../active-client.js";
|
||||||
import {
|
import {
|
||||||
isBunRuntime,
|
isBunRuntime,
|
||||||
|
|
@ -15,6 +18,8 @@ import { createDirectRoomTracker } from "./direct.js";
|
||||||
import { registerMatrixMonitorEvents } from "./events.js";
|
import { registerMatrixMonitorEvents } from "./events.js";
|
||||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||||
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||||
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export type MonitorMatrixOpts = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
|
|
@ -33,9 +38,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
}
|
}
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
let cfg = core.config.loadConfig() as CoreConfig;
|
let cfg = core.config.loadConfig() as CoreConfig;
|
||||||
if (cfg.channels?.matrix?.enabled === false) {
|
if (cfg.channels?.matrix?.enabled === false) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||||
|
|
@ -51,22 +54,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const logVerboseMessage = (message: string) => {
|
const logVerboseMessage = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) {
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.debug(message);
|
logger.debug(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUserEntry = (raw: string) =>
|
const normalizeUserEntry = (raw: string) =>
|
||||||
raw
|
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
|
||||||
.replace(/^matrix:/i, "")
|
|
||||||
.replace(/^user:/i, "")
|
|
||||||
.trim();
|
|
||||||
const normalizeRoomEntry = (raw: string) =>
|
const normalizeRoomEntry = (raw: string) =>
|
||||||
raw
|
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
|
||||||
.replace(/^matrix:/i, "")
|
|
||||||
.replace(/^(room|channel):/i, "")
|
|
||||||
.trim();
|
|
||||||
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||||
|
|
||||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||||
|
|
@ -118,9 +113,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
const pending: Array<{ input: string; query: string }> = [];
|
const pending: Array<{ input: string; query: string }> = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const cleaned = normalizeRoomEntry(trimmed);
|
const cleaned = normalizeRoomEntry(trimmed);
|
||||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||||
if (!nextRooms[cleaned]) {
|
if (!nextRooms[cleaned]) {
|
||||||
|
|
@ -140,9 +133,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
});
|
});
|
||||||
resolved.forEach((entry, index) => {
|
resolved.forEach((entry, index) => {
|
||||||
const source = pending[index];
|
const source = pending[index];
|
||||||
if (!source) {
|
if (!source) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (entry.resolved && entry.id) {
|
if (entry.resolved && entry.id) {
|
||||||
if (!nextRooms[entry.id]) {
|
if (!nextRooms[entry.id]) {
|
||||||
nextRooms[entry.id] = roomsConfig[source.input];
|
nextRooms[entry.id] = roomsConfig[source.input];
|
||||||
|
|
@ -172,7 +163,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const auth = await resolveMatrixAuth({ cfg });
|
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
|
||||||
const resolvedInitialSyncLimit =
|
const resolvedInitialSyncLimit =
|
||||||
typeof opts.initialSyncLimit === "number"
|
typeof opts.initialSyncLimit === "number"
|
||||||
? Math.max(0, Math.floor(opts.initialSyncLimit))
|
? Math.max(0, Math.floor(opts.initialSyncLimit))
|
||||||
|
|
@ -187,7 +178,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
startClient: false,
|
startClient: false,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
setActiveMatrixClient(client);
|
setActiveMatrixClient(client, opts.accountId);
|
||||||
|
|
||||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
|
|
@ -232,6 +223,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
directTracker,
|
directTracker,
|
||||||
getRoomInfo,
|
getRoomInfo,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerMatrixMonitorEvents({
|
registerMatrixMonitorEvents({
|
||||||
|
|
@ -265,20 +257,17 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||||
logger.info("matrix: device verification requested - please verify in another client");
|
logger.info("matrix: device verification requested - please verify in another client");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)");
|
||||||
{ error: String(err) },
|
|
||||||
"Device verification request failed (may already be verified)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
try {
|
try {
|
||||||
logVerboseMessage("matrix: stopping client");
|
logVerboseMessage(`matrix: stopping client for account ${opts.accountId ?? "default"}`);
|
||||||
stopSharedClient();
|
stopSharedClient(opts.accountId);
|
||||||
} finally {
|
} finally {
|
||||||
setActiveMatrixClient(null);
|
setActiveMatrixClient(null, opts.accountId);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
|
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatLocationText,
|
formatLocationText,
|
||||||
toLocationContext,
|
toLocationContext,
|
||||||
|
|
@ -19,37 +20,25 @@ type GeoUriParams = {
|
||||||
|
|
||||||
function parseGeoUri(value: string): GeoUriParams | null {
|
function parseGeoUri(value: string): GeoUriParams | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return null;
|
||||||
return null;
|
if (!trimmed.toLowerCase().startsWith("geo:")) return null;
|
||||||
}
|
|
||||||
if (!trimmed.toLowerCase().startsWith("geo:")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const payload = trimmed.slice(4);
|
const payload = trimmed.slice(4);
|
||||||
const [coordsPart, ...paramParts] = payload.split(";");
|
const [coordsPart, ...paramParts] = payload.split(";");
|
||||||
const coords = coordsPart.split(",");
|
const coords = coordsPart.split(",");
|
||||||
if (coords.length < 2) {
|
if (coords.length < 2) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const latitude = Number.parseFloat(coords[0] ?? "");
|
const latitude = Number.parseFloat(coords[0] ?? "");
|
||||||
const longitude = Number.parseFloat(coords[1] ?? "");
|
const longitude = Number.parseFloat(coords[1] ?? "");
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new Map<string, string>();
|
const params = new Map<string, string>();
|
||||||
for (const part of paramParts) {
|
for (const part of paramParts) {
|
||||||
const segment = part.trim();
|
const segment = part.trim();
|
||||||
if (!segment) {
|
if (!segment) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const eqIndex = segment.indexOf("=");
|
const eqIndex = segment.indexOf("=");
|
||||||
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
||||||
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
||||||
const key = rawKey.trim().toLowerCase();
|
const key = rawKey.trim().toLowerCase();
|
||||||
if (!key) {
|
if (!key) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const valuePart = rawValue.trim();
|
const valuePart = rawValue.trim();
|
||||||
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
||||||
}
|
}
|
||||||
|
|
@ -72,17 +61,11 @@ export function resolveMatrixLocation(params: {
|
||||||
const isLocation =
|
const isLocation =
|
||||||
eventType === EventType.Location ||
|
eventType === EventType.Location ||
|
||||||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
||||||
if (!isLocation) {
|
if (!isLocation) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
||||||
if (!geoUri) {
|
if (!geoUri) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = parseGeoUri(geoUri);
|
const parsed = parseGeoUri(geoUri);
|
||||||
if (!parsed) {
|
if (!parsed) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
||||||
const location: NormalizedLocation = {
|
const location: NormalizedLocation = {
|
||||||
latitude: parsed.latitude,
|
latitude: parsed.latitude,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import { setMatrixRuntime } from "../../runtime.js";
|
import { setMatrixRuntime } from "../../runtime.js";
|
||||||
import { downloadMatrixMedia } from "./media.js";
|
import { downloadMatrixMedia } from "./media.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
// Type for encrypted file info
|
// Type for encrypted file info
|
||||||
|
|
@ -23,19 +24,21 @@ async function fetchMatrixMediaBuffer(params: {
|
||||||
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
||||||
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
||||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||||
if (!url) {
|
if (!url) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the client's download method which handles auth
|
// Use the client's download method which handles auth
|
||||||
try {
|
try {
|
||||||
const buffer = await params.client.downloadContent(params.mxcUrl);
|
// downloadContent returns {data: Buffer, contentType: string}
|
||||||
|
const response = await params.client.downloadContent(params.mxcUrl);
|
||||||
|
const buffer = response.data;
|
||||||
|
const contentType = response.contentType;
|
||||||
|
|
||||||
if (buffer.byteLength > params.maxBytes) {
|
if (buffer.byteLength > params.maxBytes) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
throw new Error("Matrix media exceeds configured size limit");
|
||||||
}
|
}
|
||||||
return { buffer: Buffer.from(buffer) };
|
return { buffer: Buffer.from(buffer), headerType: contentType };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
|
throw new Error(`Matrix media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +78,10 @@ export async function downloadMatrixMedia(params: {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||||
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
|
if (
|
||||||
|
typeof params.sizeBytes === "number" &&
|
||||||
|
params.sizeBytes > params.maxBytes
|
||||||
|
) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
throw new Error("Matrix media exceeds configured size limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +101,7 @@ export async function downloadMatrixMedia(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fetched) {
|
if (!fetched) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export async function deliverMatrixReplies(params: {
|
export async function deliverMatrixReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
|
|
@ -61,9 +62,7 @@ export async function deliverMatrixReplies(params: {
|
||||||
chunkMode,
|
chunkMode,
|
||||||
)) {
|
)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
|
||||||
|
|
||||||
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
|
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
|
||||||
const cached = roomInfoCache.get(roomId);
|
const cached = roomInfoCache.get(roomId);
|
||||||
if (cached) {
|
if (cached) return cached;
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
let name: string | undefined;
|
let name: string | undefined;
|
||||||
let canonicalAlias: string | undefined;
|
let canonicalAlias: string | undefined;
|
||||||
let altAliases: string[] = [];
|
let altAliases: string[] = [];
|
||||||
try {
|
try {
|
||||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
|
const nameState = await client
|
||||||
|
.getRoomStateEvent(roomId, "m.room.name", "")
|
||||||
|
.catch(() => null);
|
||||||
name = nameState?.name;
|
name = nameState?.name;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -37,7 +37,10 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
|
||||||
return info;
|
return info;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
|
const getMemberDisplayName = async (
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const memberState = await client
|
const memberState = await client
|
||||||
.getRoomStateEvent(roomId, "m.room.member", userId)
|
.getRoomStateEvent(roomId, "m.room.member", userId)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk";
|
|
||||||
import type { MatrixRoomConfig } from "../../types.js";
|
import type { MatrixRoomConfig } from "../../types.js";
|
||||||
|
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
export type MatrixRoomConfigResolved = {
|
export type MatrixRoomConfigResolved = {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
|
|
@ -24,12 +24,7 @@ export function resolveMatrixRoomConfig(params: {
|
||||||
...params.aliases,
|
...params.aliases,
|
||||||
params.name ?? "",
|
params.name ?? "",
|
||||||
);
|
);
|
||||||
const {
|
const { entry: matched, key: matchedKey, wildcardEntry, wildcardKey } = resolveChannelEntryMatch({
|
||||||
entry: matched,
|
|
||||||
key: matchedKey,
|
|
||||||
wildcardEntry,
|
|
||||||
wildcardKey,
|
|
||||||
} = resolveChannelEntryMatch({
|
|
||||||
entries: rooms,
|
entries: rooms,
|
||||||
keys: candidates,
|
keys: candidates,
|
||||||
wildcardKey: "*",
|
wildcardKey: "*",
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ export function resolveMatrixThreadTarget(params: {
|
||||||
isThreadRoot?: boolean;
|
isThreadRoot?: boolean;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { threadReplies, messageId, threadRootId } = params;
|
const { threadReplies, messageId, threadRootId } = params;
|
||||||
if (threadReplies === "off") {
|
if (threadReplies === "off") return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const isThreadRoot = params.isThreadRoot === true;
|
const isThreadRoot = params.isThreadRoot === true;
|
||||||
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
|
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
|
||||||
if (threadReplies === "inbound") {
|
if (threadReplies === "inbound") {
|
||||||
|
|
@ -47,9 +45,7 @@ export function resolveMatrixThreadRootId(params: {
|
||||||
content: RoomMessageEventContent;
|
content: RoomMessageEventContent;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const relates = params.content["m.relates_to"];
|
const relates = params.content["m.relates_to"];
|
||||||
if (!relates || typeof relates !== "object") {
|
if (!relates || typeof relates !== "object") return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
||||||
if ("event_id" in relates && typeof relates.event_id === "string") {
|
if ("event_id" in relates && typeof relates.event_id === "string") {
|
||||||
return relates.event_id;
|
return relates.event_id;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { parsePollStartContent } from "./poll-types.js";
|
import { parsePollStartContent } from "./poll-types.js";
|
||||||
|
|
||||||
describe("parsePollStartContent", () => {
|
describe("parsePollStartContent", () => {
|
||||||
|
|
|
||||||
|
|
@ -77,25 +77,18 @@ export function isPollStartType(eventType: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTextContent(text?: TextContent): string {
|
export function getTextContent(text?: TextContent): string {
|
||||||
if (!text) {
|
if (!text) return "";
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
||||||
const poll =
|
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START]
|
||||||
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
|
||||||
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
|
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
||||||
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
if (!poll) return null;
|
||||||
if (!poll) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = getTextContent(poll.question);
|
const question = getTextContent(poll.question);
|
||||||
if (!question) {
|
if (!question) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const answers = poll.answers
|
const answers = poll.answers
|
||||||
.map((answer) => getTextContent(answer))
|
.map((answer) => getTextContent(answer))
|
||||||
|
|
@ -131,9 +124,7 @@ function buildTextContent(body: string): TextContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPollFallbackText(question: string, answers: string[]): string {
|
function buildPollFallbackText(question: string, answers: string[]): string {
|
||||||
if (answers.length === 0) {
|
if (answers.length === 0) return question;
|
||||||
return question;
|
|
||||||
}
|
|
||||||
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import { setMatrixRuntime } from "../runtime.js";
|
import { setMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import type { PollInput } from "openclaw/plugin-sdk";
|
import type { PollInput } from "openclaw/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||||
|
|
@ -45,6 +46,7 @@ export async function sendMessageMatrix(
|
||||||
const { client, stopOnDone } = await resolveMatrixClient({
|
const { client, stopOnDone } = await resolveMatrixClient({
|
||||||
client: opts.client,
|
client: opts.client,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
|
|
@ -122,9 +124,7 @@ export async function sendMessageMatrix(
|
||||||
const followupRelation = threadId ? relation : undefined;
|
const followupRelation = threadId ? relation : undefined;
|
||||||
for (const chunk of textChunks) {
|
for (const chunk of textChunks) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) {
|
if (!text) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const followup = buildTextContent(text, followupRelation);
|
const followup = buildTextContent(text, followupRelation);
|
||||||
const followupEventId = await sendContent(followup);
|
const followupEventId = await sendContent(followup);
|
||||||
lastMessageId = followupEventId ?? lastMessageId;
|
lastMessageId = followupEventId ?? lastMessageId;
|
||||||
|
|
@ -132,9 +132,7 @@ export async function sendMessageMatrix(
|
||||||
} else {
|
} else {
|
||||||
for (const chunk of chunks.length ? chunks : [""]) {
|
for (const chunk of chunks.length ? chunks : [""]) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) {
|
if (!text) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const content = buildTextContent(text, relation);
|
const content = buildTextContent(text, relation);
|
||||||
const eventId = await sendContent(content);
|
const eventId = await sendContent(content);
|
||||||
lastMessageId = eventId ?? lastMessageId;
|
lastMessageId = eventId ?? lastMessageId;
|
||||||
|
|
@ -214,9 +212,7 @@ export async function sendReadReceiptMatrix(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
client?: MatrixClient,
|
client?: MatrixClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!eventId?.trim()) {
|
if (!eventId?.trim()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
|
@ -234,13 +230,14 @@ export async function reactMatrixMessage(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
client?: MatrixClient,
|
opts: { client?: MatrixClient; accountId?: string | null } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!emoji.trim()) {
|
if (!emoji.trim()) {
|
||||||
throw new Error("Matrix reaction requires an emoji");
|
throw new Error("Matrix reaction requires an emoji");
|
||||||
}
|
}
|
||||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||||
client,
|
client: opts.client,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
import type { CoreConfig } from "../types.js";
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { getActiveMatrixClient } from "../active-client.js";
|
import { getActiveMatrixClient } from "../active-client.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
} from "../client.js";
|
} from "../client.js";
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
|
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
|
|
@ -28,29 +29,31 @@ export function resolveMediaMaxBytes(): number | undefined {
|
||||||
export async function resolveMatrixClient(opts: {
|
export async function resolveMatrixClient(opts: {
|
||||||
client?: MatrixClient;
|
client?: MatrixClient;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||||
ensureNodeRuntime();
|
ensureNodeRuntime();
|
||||||
if (opts.client) {
|
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||||
return { client: opts.client, stopOnDone: false };
|
|
||||||
}
|
// Try to get the active client for the specified account
|
||||||
const active = getActiveMatrixClient();
|
const active = getActiveMatrixClient(opts.accountId);
|
||||||
if (active) {
|
if (active) return { client: active, stopOnDone: false };
|
||||||
return { client: active, stopOnDone: false };
|
|
||||||
}
|
|
||||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth();
|
const auth = await resolveMatrixAuth({ accountId: opts.accountId ?? undefined });
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
|
accountId: opts.accountId ?? undefined,
|
||||||
});
|
});
|
||||||
if (auth.encryption && client.crypto) {
|
if (auth.encryption && client.crypto) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
|
||||||
import { markdownToMatrixHtml } from "../format.js";
|
import { markdownToMatrixHtml } from "../format.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
MsgType,
|
MsgType,
|
||||||
RelationType,
|
RelationType,
|
||||||
|
|
@ -13,7 +13,10 @@ import {
|
||||||
|
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
|
export function buildTextContent(
|
||||||
|
body: string,
|
||||||
|
relation?: MatrixRelation,
|
||||||
|
): MatrixTextContent {
|
||||||
const content: MatrixTextContent = relation
|
const content: MatrixTextContent = relation
|
||||||
? {
|
? {
|
||||||
msgtype: MsgType.Text,
|
msgtype: MsgType.Text,
|
||||||
|
|
@ -30,32 +33,34 @@ export function buildTextContent(body: string, relation?: MatrixRelation): Matri
|
||||||
|
|
||||||
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
||||||
const formatted = markdownToMatrixHtml(body ?? "");
|
const formatted = markdownToMatrixHtml(body ?? "");
|
||||||
if (!formatted) {
|
if (!formatted) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
content.format = "org.matrix.custom.html";
|
content.format = "org.matrix.custom.html";
|
||||||
content.formatted_body = formatted;
|
content.formatted_body = formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
||||||
const trimmed = replyToId?.trim();
|
const trimmed = replyToId?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return { "m.in_reply_to": { event_id: trimmed } };
|
return { "m.in_reply_to": { event_id: trimmed } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
|
export function buildThreadRelation(
|
||||||
|
threadId: string,
|
||||||
|
replyToId?: string,
|
||||||
|
): MatrixThreadRelation {
|
||||||
const trimmed = threadId.trim();
|
const trimmed = threadId.trim();
|
||||||
return {
|
return {
|
||||||
rel_type: RelationType.Thread,
|
rel_type: RelationType.Thread,
|
||||||
event_id: trimmed,
|
event_id: trimmed,
|
||||||
is_falling_back: true,
|
is_falling_back: true,
|
||||||
"m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
|
"m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
|
export function resolveMatrixMsgType(
|
||||||
|
contentType?: string,
|
||||||
|
_fileName?: string,
|
||||||
|
): MatrixMediaMsgType {
|
||||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
|
|
@ -74,9 +79,7 @@ export function resolveMatrixVoiceDecision(opts: {
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
}): { useVoice: boolean } {
|
}): { useVoice: boolean } {
|
||||||
if (!opts.wantsVoice) {
|
if (!opts.wantsVoice) return { useVoice: false };
|
||||||
return { useVoice: false };
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
getCore().media.isVoiceCompatibleAudio({
|
getCore().media.isVoiceCompatibleAudio({
|
||||||
contentType: opts.contentType,
|
contentType: opts.contentType,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import type {
|
||||||
VideoFileInfo,
|
VideoFileInfo,
|
||||||
} from "@vector-im/matrix-bot-sdk";
|
} from "@vector-im/matrix-bot-sdk";
|
||||||
import { parseBuffer, type IFileInfo } from "music-metadata";
|
import { parseBuffer, type IFileInfo } from "music-metadata";
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { applyMatrixFormatting } from "./formatting.js";
|
|
||||||
import {
|
import {
|
||||||
type MatrixMediaContent,
|
type MatrixMediaContent,
|
||||||
type MatrixMediaInfo,
|
type MatrixMediaInfo,
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type MatrixRelation,
|
type MatrixRelation,
|
||||||
type MediaKind,
|
type MediaKind,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { applyMatrixFormatting } from "./formatting.js";
|
||||||
|
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
|
|
@ -53,9 +54,7 @@ export function buildMatrixMediaInfo(params: {
|
||||||
};
|
};
|
||||||
return timedInfo;
|
return timedInfo;
|
||||||
}
|
}
|
||||||
if (Object.keys(base).length === 0) {
|
if (Object.keys(base).length === 0) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,12 +113,8 @@ export async function prepareImageInfo(params: {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
}): Promise<DimensionalFileInfo | undefined> {
|
}): Promise<DimensionalFileInfo | undefined> {
|
||||||
const meta = await getCore()
|
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||||
.media.getImageMetadata(params.buffer)
|
if (!meta) return undefined;
|
||||||
.catch(() => null);
|
|
||||||
if (!meta) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
||||||
const maxDim = Math.max(meta.width, meta.height);
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
|
|
@ -130,9 +125,7 @@ export async function prepareImageInfo(params: {
|
||||||
quality: THUMBNAIL_QUALITY,
|
quality: THUMBNAIL_QUALITY,
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
const thumbMeta = await getCore()
|
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||||
.media.getImageMetadata(thumbBuffer)
|
|
||||||
.catch(() => null);
|
|
||||||
const thumbUri = await params.client.uploadContent(
|
const thumbUri = await params.client.uploadContent(
|
||||||
thumbBuffer,
|
thumbBuffer,
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
|
|
@ -160,9 +153,7 @@ export async function resolveMediaDurationMs(params: {
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
kind: MediaKind;
|
kind: MediaKind;
|
||||||
}): Promise<number | undefined> {
|
}): Promise<number | undefined> {
|
||||||
if (params.kind !== "audio" && params.kind !== "video") {
|
if (params.kind !== "audio" && params.kind !== "video") return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const fileInfo: IFileInfo | string | undefined =
|
const fileInfo: IFileInfo | string | undefined =
|
||||||
params.contentType || params.fileName
|
params.contentType || params.fileName
|
||||||
|
|
@ -210,7 +201,7 @@ export async function uploadMediaMaybeEncrypted(
|
||||||
},
|
},
|
||||||
): Promise<{ url: string; file?: EncryptedFile }> {
|
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||||
// Check if room is encrypted and crypto is available
|
// Check if room is encrypted and crypto is available
|
||||||
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
|
const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId);
|
||||||
|
|
||||||
if (isEncrypted && client.crypto) {
|
if (isEncrypted && client.crypto) {
|
||||||
// Encrypt the media before uploading
|
// Encrypt the media before uploading
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
import { EventType } from "./types.js";
|
import { EventType } from "./types.js";
|
||||||
|
|
||||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||||
|
|
@ -25,9 +26,7 @@ describe("resolveMatrixRoomId", () => {
|
||||||
const roomId = await resolveMatrixRoomId(client, userId);
|
const roomId = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
expect(roomId).toBe("!room:example.org");
|
expect(roomId).toBe("!room:example.org");
|
||||||
// oxlint-disable-next-line typescript/unbound-method
|
|
||||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||||
// oxlint-disable-next-line typescript/unbound-method
|
|
||||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -38,7 +37,10 @@ describe("resolveMatrixRoomId", () => {
|
||||||
const client = {
|
const client = {
|
||||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||||
getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]),
|
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||||
|
"@bot:example.org",
|
||||||
|
userId,
|
||||||
|
]),
|
||||||
setAccountData,
|
setAccountData,
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
|
@ -78,9 +80,11 @@ describe("resolveMatrixRoomId", () => {
|
||||||
const client = {
|
const client = {
|
||||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||||
getJoinedRoomMembers: vi
|
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||||
.fn()
|
"@bot:example.org",
|
||||||
.mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]),
|
userId,
|
||||||
|
"@extra:example.org",
|
||||||
|
]),
|
||||||
setAccountData: vi.fn().mockResolvedValue(undefined),
|
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||||
|
|
||||||
import { EventType, type MatrixDirectAccountData } from "./types.js";
|
import { EventType, type MatrixDirectAccountData } from "./types.js";
|
||||||
|
|
||||||
function normalizeTarget(raw: string): string {
|
function normalizeTarget(raw: string): string {
|
||||||
|
|
@ -10,9 +11,7 @@ function normalizeTarget(raw: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeThreadId(raw?: string | number | null): string | null {
|
export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||||
if (raw === undefined || raw === null) {
|
if (raw === undefined || raw === null) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = String(raw).trim();
|
const trimmed = String(raw).trim();
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
@ -26,15 +25,16 @@ async function persistDirectRoom(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let directContent: MatrixDirectAccountData | null = null;
|
let directContent: MatrixDirectAccountData | null = null;
|
||||||
try {
|
try {
|
||||||
directContent = await client.getAccountData(EventType.Direct);
|
directContent = (await client.getAccountData(
|
||||||
|
EventType.Direct,
|
||||||
|
)) as MatrixDirectAccountData | null;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore fetch errors and fall back to an empty map.
|
// Ignore fetch errors and fall back to an empty map.
|
||||||
}
|
}
|
||||||
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
|
const existing =
|
||||||
|
directContent && !Array.isArray(directContent) ? directContent : {};
|
||||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||||
if (current[0] === roomId) {
|
if (current[0] === roomId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||||
try {
|
try {
|
||||||
await client.setAccountData(EventType.Direct, {
|
await client.setAccountData(EventType.Direct, {
|
||||||
|
|
@ -46,21 +46,28 @@ async function persistDirectRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
async function resolveDirectRoomId(
|
||||||
|
client: MatrixClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string> {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!trimmed.startsWith("@")) {
|
if (!trimmed.startsWith("@")) {
|
||||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
throw new Error(
|
||||||
|
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = directRoomCache.get(trimmed);
|
const cached = directRoomCache.get(trimmed);
|
||||||
if (cached) {
|
if (cached) return cached;
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||||
try {
|
try {
|
||||||
const directContent = await client.getAccountData(EventType.Direct);
|
const directContent = (await client.getAccountData(
|
||||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
EventType.Direct,
|
||||||
|
)) as MatrixDirectAccountData | null;
|
||||||
|
const list = Array.isArray(directContent?.[trimmed])
|
||||||
|
? directContent[trimmed]
|
||||||
|
: [];
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
directRoomCache.set(trimmed, list[0]);
|
directRoomCache.set(trimmed, list[0]);
|
||||||
return list[0];
|
return list[0];
|
||||||
|
|
@ -81,9 +88,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!members.includes(trimmed)) {
|
if (!members.includes(trimmed)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||||
if (members.length === 2) {
|
if (members.length === 2) {
|
||||||
directRoomCache.set(trimmed, roomId);
|
directRoomCache.set(trimmed, roomId);
|
||||||
|
|
@ -107,7 +112,10 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
||||||
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
|
export async function resolveMatrixRoomId(
|
||||||
|
client: MatrixClient,
|
||||||
|
raw: string,
|
||||||
|
): Promise<string> {
|
||||||
const target = normalizeTarget(raw);
|
const target = normalizeTarget(raw);
|
||||||
const lowered = target.toLowerCase();
|
const lowered = target.toLowerCase();
|
||||||
if (lowered.startsWith("matrix:")) {
|
if (lowered.startsWith("matrix:")) {
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,16 @@ import {
|
||||||
type ChannelOnboardingDmPolicy,
|
type ChannelOnboardingDmPolicy,
|
||||||
type WizardPrompter,
|
type WizardPrompter,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { CoreConfig, DmPolicy } from "./types.js";
|
|
||||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||||
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||||
|
import type { CoreConfig, DmPolicy } from "./types.js";
|
||||||
|
|
||||||
const channel = "matrix" as const;
|
const channel = "matrix" as const;
|
||||||
|
|
||||||
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
|
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
|
||||||
const allowFrom =
|
const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
|
||||||
policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
|
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
channels: {
|
channels: {
|
||||||
|
|
@ -249,12 +248,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
initialValue: existing.homeserver ?? envHomeserver,
|
initialValue: existing.homeserver ?? envHomeserver,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) {
|
if (!raw) return "Required";
|
||||||
return "Required";
|
if (!/^https?:\/\//i.test(raw)) return "Use a full URL (https://...)";
|
||||||
}
|
|
||||||
if (!/^https?:\/\//i.test(raw)) {
|
|
||||||
return "Use a full URL (https://...)";
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -278,13 +273,13 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
|
|
||||||
if (!accessToken && !password) {
|
if (!accessToken && !password) {
|
||||||
// Ask auth method FIRST before asking for user ID
|
// Ask auth method FIRST before asking for user ID
|
||||||
const authMode = await prompter.select({
|
const authMode = (await prompter.select({
|
||||||
message: "Matrix auth method",
|
message: "Matrix auth method",
|
||||||
options: [
|
options: [
|
||||||
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
||||||
{ value: "password", label: "Password (requires user ID)" },
|
{ value: "password", label: "Password (requires user ID)" },
|
||||||
],
|
],
|
||||||
});
|
})) as "token" | "password";
|
||||||
|
|
||||||
if (authMode === "token") {
|
if (authMode === "token") {
|
||||||
accessToken = String(
|
accessToken = String(
|
||||||
|
|
@ -304,15 +299,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
initialValue: existing.userId ?? envUserId,
|
initialValue: existing.userId ?? envUserId,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) {
|
if (!raw) return "Required";
|
||||||
return "Required";
|
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
||||||
}
|
if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)";
|
||||||
if (!raw.startsWith("@")) {
|
|
||||||
return "Matrix user IDs should start with @";
|
|
||||||
}
|
|
||||||
if (!raw.includes(":")) {
|
|
||||||
return "Matrix user IDs should include a server (:server)";
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -380,9 +369,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
const unresolved: string[] = [];
|
const unresolved: string[] = [];
|
||||||
for (const entry of accessConfig.entries) {
|
for (const entry of accessConfig.entries) {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
|
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
|
||||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||||
resolvedIds.push(cleaned);
|
resolvedIds.push(cleaned);
|
||||||
|
|
@ -403,7 +390,10 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
unresolved.push(entry);
|
unresolved.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
|
roomKeys = [
|
||||||
|
...resolvedIds,
|
||||||
|
...unresolved.map((entry) => entry.trim()).filter(Boolean),
|
||||||
|
];
|
||||||
if (resolvedIds.length > 0 || unresolved.length > 0) {
|
if (resolvedIds.length > 0 || unresolved.length > 0) {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||||
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
|
||||||
import { getMatrixRuntime } from "./runtime.js";
|
import { getMatrixRuntime } from "./runtime.js";
|
||||||
|
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
||||||
|
|
||||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@ import type {
|
||||||
ChannelResolveResult,
|
ChannelResolveResult,
|
||||||
RuntimeEnv,
|
RuntimeEnv,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
|
||||||
|
import {
|
||||||
|
listMatrixDirectoryGroupsLive,
|
||||||
|
listMatrixDirectoryPeersLive,
|
||||||
|
} from "./directory-live.js";
|
||||||
|
|
||||||
function pickBestGroupMatch(
|
function pickBestGroupMatch(
|
||||||
matches: ChannelDirectoryEntry[],
|
matches: ChannelDirectoryEntry[],
|
||||||
query: string,
|
query: string,
|
||||||
): ChannelDirectoryEntry | undefined {
|
): ChannelDirectoryEntry | undefined {
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
const exact = matches.find((match) => {
|
const exact = matches.find((match) => {
|
||||||
|
|
@ -21,9 +23,7 @@ function pickBestGroupMatch(
|
||||||
const id = match.id.trim().toLowerCase();
|
const id = match.id.trim().toLowerCase();
|
||||||
return name === normalized || handle === normalized || id === normalized;
|
return name === normalized || handle === normalized || id === normalized;
|
||||||
});
|
});
|
||||||
if (exact) {
|
if (exact) return exact;
|
||||||
return exact;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return matches[0];
|
return matches[0];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import {
|
|
||||||
createActionGate,
|
|
||||||
jsonResult,
|
|
||||||
readNumberParam,
|
|
||||||
readReactionParams,
|
|
||||||
readStringParam,
|
|
||||||
} from "openclaw/plugin-sdk";
|
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
import {
|
import {
|
||||||
deleteMatrixMessage,
|
deleteMatrixMessage,
|
||||||
|
|
@ -21,6 +15,13 @@ import {
|
||||||
unpinMatrixMessage,
|
unpinMatrixMessage,
|
||||||
} from "./matrix/actions.js";
|
} from "./matrix/actions.js";
|
||||||
import { reactMatrixMessage } from "./matrix/send.js";
|
import { reactMatrixMessage } from "./matrix/send.js";
|
||||||
|
import {
|
||||||
|
createActionGate,
|
||||||
|
jsonResult,
|
||||||
|
readNumberParam,
|
||||||
|
readReactionParams,
|
||||||
|
readStringParam,
|
||||||
|
} from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
||||||
const reactionActions = new Set(["react", "reactions"]);
|
const reactionActions = new Set(["react", "reactions"]);
|
||||||
|
|
@ -28,12 +29,8 @@ const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
||||||
|
|
||||||
function readRoomId(params: Record<string, unknown>, required = true): string {
|
function readRoomId(params: Record<string, unknown>, required = true): string {
|
||||||
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
||||||
if (direct) {
|
if (direct) return direct;
|
||||||
return direct;
|
if (!required) return readStringParam(params, "to") ?? "";
|
||||||
}
|
|
||||||
if (!required) {
|
|
||||||
return readStringParam(params, "to") ?? "";
|
|
||||||
}
|
|
||||||
return readStringParam(params, "to", { required: true });
|
return readStringParam(params, "to", { required: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +39,7 @@ export async function handleMatrixAction(
|
||||||
cfg: CoreConfig,
|
cfg: CoreConfig,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
|
const accountId = readStringParam(params, "accountId") ?? undefined;
|
||||||
const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions);
|
const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions);
|
||||||
|
|
||||||
if (reactionActions.has(action)) {
|
if (reactionActions.has(action)) {
|
||||||
|
|
@ -57,13 +55,14 @@ export async function handleMatrixAction(
|
||||||
if (remove || isEmpty) {
|
if (remove || isEmpty) {
|
||||||
const result = await removeMatrixReactions(roomId, messageId, {
|
const result = await removeMatrixReactions(roomId, messageId, {
|
||||||
emoji: remove ? emoji : undefined,
|
emoji: remove ? emoji : undefined,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, removed: result.removed });
|
return jsonResult({ ok: true, removed: result.removed });
|
||||||
}
|
}
|
||||||
await reactMatrixMessage(roomId, messageId, emoji);
|
await reactMatrixMessage(roomId, messageId, emoji, { accountId });
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
}
|
}
|
||||||
const reactions = await listMatrixReactions(roomId, messageId);
|
const reactions = await listMatrixReactions(roomId, messageId, { accountId });
|
||||||
return jsonResult({ ok: true, reactions });
|
return jsonResult({ ok: true, reactions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,13 +78,13 @@ export async function handleMatrixAction(
|
||||||
allowEmpty: true,
|
allowEmpty: true,
|
||||||
});
|
});
|
||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
const replyToId =
|
const replyToId = readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
|
||||||
readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
|
|
||||||
const threadId = readStringParam(params, "threadId");
|
const threadId = readStringParam(params, "threadId");
|
||||||
const result = await sendMatrixMessage(to, content, {
|
const result = await sendMatrixMessage(to, content, {
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
threadId: threadId ?? undefined,
|
threadId: threadId ?? undefined,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, result });
|
return jsonResult({ ok: true, result });
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +92,14 @@ export async function handleMatrixAction(
|
||||||
const roomId = readRoomId(params);
|
const roomId = readRoomId(params);
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
const content = readStringParam(params, "content", { required: true });
|
const content = readStringParam(params, "content", { required: true });
|
||||||
const result = await editMatrixMessage(roomId, messageId, content);
|
const result = await editMatrixMessage(roomId, messageId, content, { accountId });
|
||||||
return jsonResult({ ok: true, result });
|
return jsonResult({ ok: true, result });
|
||||||
}
|
}
|
||||||
case "deleteMessage": {
|
case "deleteMessage": {
|
||||||
const roomId = readRoomId(params);
|
const roomId = readRoomId(params);
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
const reason = readStringParam(params, "reason");
|
const reason = readStringParam(params, "reason");
|
||||||
await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined });
|
await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined, accountId });
|
||||||
return jsonResult({ ok: true, deleted: true });
|
return jsonResult({ ok: true, deleted: true });
|
||||||
}
|
}
|
||||||
case "readMessages": {
|
case "readMessages": {
|
||||||
|
|
@ -112,6 +111,7 @@ export async function handleMatrixAction(
|
||||||
limit: limit ?? undefined,
|
limit: limit ?? undefined,
|
||||||
before: before ?? undefined,
|
before: before ?? undefined,
|
||||||
after: after ?? undefined,
|
after: after ?? undefined,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, ...result });
|
return jsonResult({ ok: true, ...result });
|
||||||
}
|
}
|
||||||
|
|
@ -127,15 +127,15 @@ export async function handleMatrixAction(
|
||||||
const roomId = readRoomId(params);
|
const roomId = readRoomId(params);
|
||||||
if (action === "pinMessage") {
|
if (action === "pinMessage") {
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
const result = await pinMatrixMessage(roomId, messageId);
|
const result = await pinMatrixMessage(roomId, messageId, { accountId });
|
||||||
return jsonResult({ ok: true, pinned: result.pinned });
|
return jsonResult({ ok: true, pinned: result.pinned });
|
||||||
}
|
}
|
||||||
if (action === "unpinMessage") {
|
if (action === "unpinMessage") {
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
const result = await unpinMatrixMessage(roomId, messageId);
|
const result = await unpinMatrixMessage(roomId, messageId, { accountId });
|
||||||
return jsonResult({ ok: true, pinned: result.pinned });
|
return jsonResult({ ok: true, pinned: result.pinned });
|
||||||
}
|
}
|
||||||
const result = await listMatrixPins(roomId);
|
const result = await listMatrixPins(roomId, { accountId });
|
||||||
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
|
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +147,7 @@ export async function handleMatrixAction(
|
||||||
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
||||||
const result = await getMatrixMemberInfo(userId, {
|
const result = await getMatrixMemberInfo(userId, {
|
||||||
roomId: roomId ?? undefined,
|
roomId: roomId ?? undefined,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, member: result });
|
return jsonResult({ ok: true, member: result });
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +157,7 @@ export async function handleMatrixAction(
|
||||||
throw new Error("Matrix room info is disabled.");
|
throw new Error("Matrix room info is disabled.");
|
||||||
}
|
}
|
||||||
const roomId = readRoomId(params);
|
const roomId = readRoomId(params);
|
||||||
const result = await getMatrixRoomInfo(roomId);
|
const result = await getMatrixRoomInfo(roomId, { accountId });
|
||||||
return jsonResult({ ok: true, room: result });
|
return jsonResult({ ok: true, room: result });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,10 @@ export type MatrixActionConfig = {
|
||||||
channelInfo?: boolean;
|
channelInfo?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatrixConfig = {
|
export type MatrixAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** If false, do not start Matrix. Default: true. */
|
/** If false, do not start this account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Matrix homeserver URL (https://matrix.example.org). */
|
/** Matrix homeserver URL (https://matrix.example.org). */
|
||||||
homeserver?: string;
|
homeserver?: string;
|
||||||
|
|
@ -87,9 +87,22 @@ export type MatrixConfig = {
|
||||||
actions?: MatrixActionConfig;
|
actions?: MatrixActionConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MatrixConfig = {
|
||||||
|
/** Optional per-account Matrix configuration (multi-account). */
|
||||||
|
accounts?: Record<string, MatrixAccountConfig>;
|
||||||
|
} & MatrixAccountConfig;
|
||||||
|
|
||||||
export type CoreConfig = {
|
export type CoreConfig = {
|
||||||
channels?: {
|
channels?: {
|
||||||
matrix?: MatrixConfig;
|
matrix?: MatrixConfig;
|
||||||
};
|
};
|
||||||
|
bindings?: Array<{
|
||||||
|
agentId?: string;
|
||||||
|
match?: {
|
||||||
|
channel?: string;
|
||||||
|
accountId?: string;
|
||||||
|
peer?: { kind?: string; id?: string };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
12668
package-lock.json
generated
Normal file
12668
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -152,6 +152,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.13.1",
|
"@agentclientprotocol/sdk": "0.13.1",
|
||||||
|
"nats": "^2.19.0",
|
||||||
"@aws-sdk/client-bedrock": "^3.980.0",
|
"@aws-sdk/client-bedrock": "^3.980.0",
|
||||||
"@buape/carbon": "0.14.0",
|
"@buape/carbon": "0.14.0",
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
|
|
|
||||||
112
pnpm-lock.yaml
112
pnpm-lock.yaml
|
|
@ -129,6 +129,9 @@ importers:
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
|
nats:
|
||||||
|
specifier: ^2.19.0
|
||||||
|
version: 2.29.3
|
||||||
node-edge-tts:
|
node-edge-tts:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9
|
version: 1.2.9
|
||||||
|
|
@ -317,7 +320,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
openclaw:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: 2026.2.1(@napi-rs/canvas@0.1.89)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.15.1(typescript@5.9.3))(signal-polyfill@0.2.2)
|
||||||
|
|
||||||
extensions/imessage:
|
extensions/imessage:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
|
@ -343,7 +346,7 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/matrix:
|
extensions/matrix.upstream-disabled:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
|
|
@ -375,7 +378,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
openclaw:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: 2026.2.1(@napi-rs/canvas@0.1.89)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.15.1(typescript@5.9.3))(signal-polyfill@0.2.2)
|
||||||
|
|
||||||
extensions/memory-lancedb:
|
extensions/memory-lancedb:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -4212,6 +4215,10 @@ packages:
|
||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nats@2.29.3:
|
||||||
|
resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==}
|
||||||
|
engines: {node: '>= 14.0.0'}
|
||||||
|
|
||||||
negotiator@0.6.3:
|
negotiator@0.6.3:
|
||||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -4224,6 +4231,10 @@ packages:
|
||||||
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
nkeys.js@1.1.0:
|
||||||
|
resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
node-addon-api@8.5.0:
|
node-addon-api@8.5.0:
|
||||||
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
|
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
|
||||||
engines: {node: ^18 || ^20 || >= 21}
|
engines: {node: ^18 || ^20 || >= 21}
|
||||||
|
|
@ -4369,6 +4380,14 @@ packages:
|
||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
openclaw@2026.2.1:
|
||||||
|
resolution: {integrity: sha512-SCGnsg/E9XPpYd1KCH+hvfQFTg+RLptBAAPbc+9e7PHn7aNzte7mcm+2W/kxn71Aie8jqwbZgWx9JdEPneiaLQ==}
|
||||||
|
engines: {node: '>=22.12.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@napi-rs/canvas': ^0.1.89
|
||||||
|
node-llama-cpp: 3.15.1
|
||||||
|
|
||||||
opus-decoder@0.7.11:
|
opus-decoder@0.7.11:
|
||||||
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==}
|
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==}
|
||||||
|
|
||||||
|
|
@ -5071,6 +5090,9 @@ packages:
|
||||||
tweetnacl@0.14.5:
|
tweetnacl@0.14.5:
|
||||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||||
|
|
||||||
|
tweetnacl@1.0.3:
|
||||||
|
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -9555,12 +9577,20 @@ snapshots:
|
||||||
|
|
||||||
nanoid@5.1.6: {}
|
nanoid@5.1.6: {}
|
||||||
|
|
||||||
|
nats@2.29.3:
|
||||||
|
dependencies:
|
||||||
|
nkeys.js: 1.1.0
|
||||||
|
|
||||||
negotiator@0.6.3: {}
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
netmask@2.0.2: {}
|
netmask@2.0.2: {}
|
||||||
|
|
||||||
|
nkeys.js@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
tweetnacl: 1.0.3
|
||||||
|
|
||||||
node-addon-api@8.5.0: {}
|
node-addon-api@8.5.0: {}
|
||||||
|
|
||||||
node-api-headers@1.8.0: {}
|
node-api-headers@1.8.0: {}
|
||||||
|
|
@ -9735,6 +9765,80 @@ snapshots:
|
||||||
ws: 8.19.0
|
ws: 8.19.0
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
|
openclaw@2026.2.1(@napi-rs/canvas@0.1.89)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.15.1(typescript@5.9.3))(signal-polyfill@0.2.2):
|
||||||
|
dependencies:
|
||||||
|
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
||||||
|
'@aws-sdk/client-bedrock': 3.980.0
|
||||||
|
'@buape/carbon': 0.14.0(hono@4.11.7)
|
||||||
|
'@clack/prompts': 1.0.0
|
||||||
|
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
||||||
|
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
||||||
|
'@homebridge/ciao': 1.3.4
|
||||||
|
'@line/bot-sdk': 10.6.0
|
||||||
|
'@lydell/node-pty': 1.2.0-beta.3
|
||||||
|
'@mariozechner/pi-agent-core': 0.51.0(ws@8.19.0)(zod@4.3.6)
|
||||||
|
'@mariozechner/pi-ai': 0.51.0(ws@8.19.0)(zod@4.3.6)
|
||||||
|
'@mariozechner/pi-coding-agent': 0.51.0(ws@8.19.0)(zod@4.3.6)
|
||||||
|
'@mariozechner/pi-tui': 0.51.0
|
||||||
|
'@mozilla/readability': 0.6.0
|
||||||
|
'@napi-rs/canvas': 0.1.89
|
||||||
|
'@sinclair/typebox': 0.34.47
|
||||||
|
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||||
|
'@slack/web-api': 7.13.0
|
||||||
|
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||||
|
ajv: 8.17.1
|
||||||
|
chalk: 5.6.2
|
||||||
|
chokidar: 5.0.0
|
||||||
|
cli-highlight: 2.1.11
|
||||||
|
commander: 14.0.3
|
||||||
|
croner: 10.0.1
|
||||||
|
discord-api-types: 0.38.38
|
||||||
|
dotenv: 17.2.3
|
||||||
|
express: 5.2.1
|
||||||
|
file-type: 21.3.0
|
||||||
|
grammy: 1.39.3
|
||||||
|
hono: 4.11.7
|
||||||
|
jiti: 2.6.1
|
||||||
|
json5: 2.2.3
|
||||||
|
jszip: 3.10.1
|
||||||
|
linkedom: 0.18.12
|
||||||
|
long: 5.3.2
|
||||||
|
markdown-it: 14.1.0
|
||||||
|
node-edge-tts: 1.2.9
|
||||||
|
node-llama-cpp: 3.15.1(typescript@5.9.3)
|
||||||
|
osc-progress: 0.3.0
|
||||||
|
pdfjs-dist: 5.4.624
|
||||||
|
playwright-core: 1.58.1
|
||||||
|
proper-lockfile: 4.1.2
|
||||||
|
qrcode-terminal: 0.12.0
|
||||||
|
sharp: 0.34.5
|
||||||
|
signal-utils: 0.21.1(signal-polyfill@0.2.2)
|
||||||
|
sqlite-vec: 0.1.7-alpha.2
|
||||||
|
tar: 7.5.7
|
||||||
|
tslog: 4.10.2
|
||||||
|
undici: 7.20.0
|
||||||
|
ws: 8.19.0
|
||||||
|
yaml: 2.8.2
|
||||||
|
zod: 4.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@discordjs/opus'
|
||||||
|
- '@modelcontextprotocol/sdk'
|
||||||
|
- '@types/express'
|
||||||
|
- audio-decode
|
||||||
|
- aws-crt
|
||||||
|
- bufferutil
|
||||||
|
- canvas
|
||||||
|
- debug
|
||||||
|
- encoding
|
||||||
|
- ffmpeg-static
|
||||||
|
- jimp
|
||||||
|
- link-preview-js
|
||||||
|
- node-opus
|
||||||
|
- opusscript
|
||||||
|
- signal-polyfill
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
opus-decoder@0.7.11:
|
opus-decoder@0.7.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@wasm-audio-decoders/common': 9.0.7
|
'@wasm-audio-decoders/common': 9.0.7
|
||||||
|
|
@ -10614,6 +10718,8 @@ snapshots:
|
||||||
|
|
||||||
tweetnacl@0.14.5: {}
|
tweetnacl@0.14.5: {}
|
||||||
|
|
||||||
|
tweetnacl@1.0.3: {}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
|
|
|
||||||
307
scripts/migrate-to-eventstore.mjs
Normal file
307
scripts/migrate-to-eventstore.mjs
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Migrate existing OpenClaw memory to NATS Event Store.
|
||||||
|
*
|
||||||
|
* This script imports:
|
||||||
|
* - Daily notes (memory/*.md)
|
||||||
|
* - Long-term memory (MEMORY.md)
|
||||||
|
* - Knowledge graph entries (if present)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/migrate-to-eventstore.mjs [options]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --nats-url NATS connection URL (default: nats://localhost:4222)
|
||||||
|
* --stream Stream name (default: openclaw-events)
|
||||||
|
* --prefix Subject prefix (default: openclaw.events)
|
||||||
|
* --workspace Workspace directory (default: current directory)
|
||||||
|
* --dry-run Show what would be migrated without actually doing it
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* node scripts/migrate-to-eventstore.mjs
|
||||||
|
* node scripts/migrate-to-eventstore.mjs --workspace ~/clawd --dry-run
|
||||||
|
* node scripts/migrate-to-eventstore.mjs --nats-url nats://user:pass@localhost:4222
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { connect, StringCodec } from 'nats';
|
||||||
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||||
|
import { join, basename, dirname } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const sc = StringCodec();
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options = {
|
||||||
|
natsUrl: 'nats://localhost:4222',
|
||||||
|
streamName: 'openclaw-events',
|
||||||
|
subjectPrefix: 'openclaw.events',
|
||||||
|
workspace: process.cwd(),
|
||||||
|
dryRun: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
switch (args[i]) {
|
||||||
|
case '--nats-url':
|
||||||
|
options.natsUrl = args[++i];
|
||||||
|
break;
|
||||||
|
case '--stream':
|
||||||
|
options.streamName = args[++i];
|
||||||
|
break;
|
||||||
|
case '--prefix':
|
||||||
|
options.subjectPrefix = args[++i];
|
||||||
|
break;
|
||||||
|
case '--workspace':
|
||||||
|
options.workspace = args[++i];
|
||||||
|
break;
|
||||||
|
case '--dry-run':
|
||||||
|
options.dryRun = true;
|
||||||
|
break;
|
||||||
|
case '--help':
|
||||||
|
console.log(`
|
||||||
|
Usage: node scripts/migrate-to-eventstore.mjs [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--nats-url NATS connection URL (default: nats://localhost:4222)
|
||||||
|
--stream Stream name (default: openclaw-events)
|
||||||
|
--prefix Subject prefix (default: openclaw.events)
|
||||||
|
--workspace Workspace directory (default: current directory)
|
||||||
|
--dry-run Show what would be migrated without actually doing it
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateEventId() {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).substring(2, 10);
|
||||||
|
return `${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(filename) {
|
||||||
|
const match = filename.match(/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
return new Date(match[1]).getTime();
|
||||||
|
}
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* findMarkdownFiles(dir, pattern = /\.md$/) {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||||
|
yield* findMarkdownFiles(path, pattern);
|
||||||
|
} else if (entry.isFile() && pattern.test(entry.name)) {
|
||||||
|
yield path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateFile(filePath, workspace, options) {
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const relativePath = filePath.replace(workspace, '').replace(/^\//, '');
|
||||||
|
const filename = basename(filePath);
|
||||||
|
const timestamp = parseDate(filename);
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
// Determine event type based on file location
|
||||||
|
let eventType = 'memory';
|
||||||
|
if (relativePath.includes('memory/')) {
|
||||||
|
eventType = 'daily-note';
|
||||||
|
} else if (filename === 'MEMORY.md') {
|
||||||
|
eventType = 'long-term-memory';
|
||||||
|
} else if (relativePath.includes('areas/people/')) {
|
||||||
|
eventType = 'person';
|
||||||
|
} else if (relativePath.includes('areas/companies/')) {
|
||||||
|
eventType = 'company';
|
||||||
|
} else if (relativePath.includes('areas/projects/')) {
|
||||||
|
eventType = 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migration event
|
||||||
|
events.push({
|
||||||
|
id: generateEventId(),
|
||||||
|
timestamp,
|
||||||
|
agent: 'migration',
|
||||||
|
session: 'migration:initial',
|
||||||
|
type: `migration.${eventType}`,
|
||||||
|
visibility: 'internal',
|
||||||
|
payload: {
|
||||||
|
source: relativePath,
|
||||||
|
content: content.slice(0, 10000), // Limit content size
|
||||||
|
contentLength: content.length,
|
||||||
|
migratedAt: Date.now(),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
filename,
|
||||||
|
eventType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract sections from the file
|
||||||
|
const sections = content.split(/^## /m).filter(Boolean);
|
||||||
|
for (const section of sections.slice(0, 20)) { // Limit sections
|
||||||
|
const lines = section.split('\n');
|
||||||
|
const title = lines[0]?.trim();
|
||||||
|
const body = lines.slice(1).join('\n').trim();
|
||||||
|
|
||||||
|
if (title && body.length > 50) {
|
||||||
|
events.push({
|
||||||
|
id: generateEventId(),
|
||||||
|
timestamp: timestamp + Math.random() * 1000,
|
||||||
|
agent: 'migration',
|
||||||
|
session: 'migration:initial',
|
||||||
|
type: 'migration.section',
|
||||||
|
visibility: 'internal',
|
||||||
|
payload: {
|
||||||
|
source: relativePath,
|
||||||
|
title,
|
||||||
|
content: body.slice(0, 2000),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
filename,
|
||||||
|
eventType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
|
||||||
|
console.log('OpenClaw Event Store Migration');
|
||||||
|
console.log('==============================');
|
||||||
|
console.log(`Workspace: ${options.workspace}`);
|
||||||
|
console.log(`NATS URL: ${options.natsUrl}`);
|
||||||
|
console.log(`Stream: ${options.streamName}`);
|
||||||
|
console.log(`Dry run: ${options.dryRun}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Collect files to migrate
|
||||||
|
const files = [];
|
||||||
|
const memoryDir = join(options.workspace, 'memory');
|
||||||
|
const memoryFile = join(options.workspace, 'MEMORY.md');
|
||||||
|
const knowledgeDir = join(options.workspace, 'life', 'areas');
|
||||||
|
|
||||||
|
// Memory directory
|
||||||
|
for await (const file of findMarkdownFiles(memoryDir)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MEMORY.md
|
||||||
|
if (existsSync(memoryFile)) {
|
||||||
|
files.push(memoryFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowledge graph
|
||||||
|
for await (const file of findMarkdownFiles(knowledgeDir)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${files.length} files to migrate`);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('No files found. Nothing to migrate.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all events
|
||||||
|
const allEvents = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const events = await migrateFile(file, options.workspace, options);
|
||||||
|
allEvents.push(...events);
|
||||||
|
console.log(` ${file.replace(options.workspace, '')}: ${events.length} events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTotal events: ${allEvents.length}`);
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log('\nDry run - no events published.');
|
||||||
|
console.log('Sample event:');
|
||||||
|
console.log(JSON.stringify(allEvents[0], null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to NATS and publish
|
||||||
|
console.log('\nConnecting to NATS...');
|
||||||
|
|
||||||
|
let nc;
|
||||||
|
try {
|
||||||
|
// Parse URL for credentials
|
||||||
|
const httpUrl = options.natsUrl.replace(/^nats:\/\//, 'http://');
|
||||||
|
const url = new URL(httpUrl);
|
||||||
|
const connOpts = {
|
||||||
|
servers: `${url.hostname}:${url.port || 4222}`,
|
||||||
|
};
|
||||||
|
if (url.username && url.password) {
|
||||||
|
connOpts.user = decodeURIComponent(url.username);
|
||||||
|
connOpts.pass = decodeURIComponent(url.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
nc = await connect(connOpts);
|
||||||
|
console.log('Connected!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to connect: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const js = nc.jetstream();
|
||||||
|
const jsm = await nc.jetstreamManager();
|
||||||
|
|
||||||
|
// Ensure stream exists
|
||||||
|
try {
|
||||||
|
await jsm.streams.info(options.streamName);
|
||||||
|
console.log(`Stream ${options.streamName} exists`);
|
||||||
|
} catch {
|
||||||
|
console.log(`Creating stream ${options.streamName}...`);
|
||||||
|
await jsm.streams.add({
|
||||||
|
name: options.streamName,
|
||||||
|
subjects: [`${options.subjectPrefix}.>`],
|
||||||
|
retention: 'limits',
|
||||||
|
storage: 'file',
|
||||||
|
max_age: 90 * 24 * 60 * 60 * 1000000000, // 90 days in nanoseconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish events
|
||||||
|
console.log('\nPublishing events...');
|
||||||
|
let published = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const event of allEvents) {
|
||||||
|
const subject = `${options.subjectPrefix}.${event.type}`;
|
||||||
|
try {
|
||||||
|
await js.publish(subject, sc.encode(JSON.stringify(event)));
|
||||||
|
published++;
|
||||||
|
if (published % 100 === 0) {
|
||||||
|
process.stdout.write(`\r Published: ${published}/${allEvents.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors++;
|
||||||
|
console.error(`\n Error publishing: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\nMigration complete!`);
|
||||||
|
console.log(` Published: ${published}`);
|
||||||
|
console.log(` Errors: ${errors}`);
|
||||||
|
|
||||||
|
await nc.drain();
|
||||||
|
process.exit(errors > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@ import os from "node:os";
|
||||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
||||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||||
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
||||||
|
import { buildEventContext, formatContextForPrompt } from "../../../infra/event-context.js";
|
||||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||||
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||||
|
|
@ -341,6 +342,48 @@ export async function runEmbeddedAttempt(
|
||||||
});
|
});
|
||||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||||
|
|
||||||
|
// Load event-sourced context if enabled
|
||||||
|
let eventContextHint: string | undefined;
|
||||||
|
const eventStoreConfig = params.config?.gateway?.eventStore;
|
||||||
|
if (eventStoreConfig?.enabled) {
|
||||||
|
try {
|
||||||
|
// Extract agentId from sessionKey (format: "agent:{agentId}:...")
|
||||||
|
const sessionKeyParts = params.sessionKey?.split(":") ?? [];
|
||||||
|
const agentId = sessionKeyParts.length >= 2 ? sessionKeyParts[1] : "main";
|
||||||
|
|
||||||
|
// Check for agent-specific eventStore config
|
||||||
|
const agentEventConfig = eventStoreConfig.agents?.[agentId];
|
||||||
|
const effectiveNatsUrl =
|
||||||
|
agentEventConfig?.natsUrl || eventStoreConfig.natsUrl || "nats://localhost:4222";
|
||||||
|
const effectiveStreamName =
|
||||||
|
agentEventConfig?.streamName || eventStoreConfig.streamName || "openclaw-events";
|
||||||
|
const effectiveSubjectPrefix =
|
||||||
|
agentEventConfig?.subjectPrefix || eventStoreConfig.subjectPrefix || "openclaw.events";
|
||||||
|
|
||||||
|
const eventContext = await buildEventContext(
|
||||||
|
{
|
||||||
|
natsUrl: effectiveNatsUrl,
|
||||||
|
streamName: effectiveStreamName,
|
||||||
|
subjectPrefix: effectiveSubjectPrefix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: agentId,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
hoursBack: 2,
|
||||||
|
maxEvents: 100,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (eventContext.eventsProcessed > 0) {
|
||||||
|
eventContextHint = formatContextForPrompt(eventContext);
|
||||||
|
log.info(
|
||||||
|
`[event-context] Loaded ${eventContext.eventsProcessed} events for agent=${agentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`[event-context] Failed to load: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
defaultThinkLevel: params.thinkLevel,
|
defaultThinkLevel: params.thinkLevel,
|
||||||
|
|
@ -366,6 +409,7 @@ export async function runEmbeddedAttempt(
|
||||||
userTime,
|
userTime,
|
||||||
userTimeFormat,
|
userTimeFormat,
|
||||||
contextFiles,
|
contextFiles,
|
||||||
|
eventContextHint,
|
||||||
});
|
});
|
||||||
const systemPromptReport = buildSystemPromptReport({
|
const systemPromptReport = buildSystemPromptReport({
|
||||||
source: "run",
|
source: "run",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||||
userTime?: string;
|
userTime?: string;
|
||||||
userTimeFormat?: ResolvedTimeFormat;
|
userTimeFormat?: ResolvedTimeFormat;
|
||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
|
/** Event-sourced context from NATS (formatted text block). */
|
||||||
|
eventContextHint?: string;
|
||||||
}): string {
|
}): string {
|
||||||
return buildAgentSystemPrompt({
|
return buildAgentSystemPrompt({
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
|
|
@ -71,6 +73,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||||
userTime: params.userTime,
|
userTime: params.userTime,
|
||||||
userTimeFormat: params.userTimeFormat,
|
userTimeFormat: params.userTimeFormat,
|
||||||
contextFiles: params.contextFiles,
|
contextFiles: params.contextFiles,
|
||||||
|
eventContextHint: params.eventContextHint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ export function buildAgentSystemPrompt(params: {
|
||||||
defaultThinkLevel?: ThinkLevel;
|
defaultThinkLevel?: ThinkLevel;
|
||||||
reasoningLevel?: ReasoningLevel;
|
reasoningLevel?: ReasoningLevel;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
/** Event-sourced context from NATS (formatted text block). */
|
||||||
|
eventContextHint?: string;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
reasoningTagHint?: boolean;
|
reasoningTagHint?: boolean;
|
||||||
toolNames?: string[];
|
toolNames?: string[];
|
||||||
|
|
@ -551,6 +553,11 @@ export function buildAgentSystemPrompt(params: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event-sourced context (from NATS JetStream)
|
||||||
|
if (params.eventContextHint) {
|
||||||
|
lines.push("## Event-Sourced Memory", "", params.eventContextHint, "");
|
||||||
|
}
|
||||||
|
|
||||||
// Skip silent replies for subagent/none modes
|
// Skip silent replies for subagent/none modes
|
||||||
if (!isMinimal) {
|
if (!isMinimal) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,28 @@ export type GatewayNodesConfig = {
|
||||||
denyCommands?: string[];
|
denyCommands?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentEventStoreConfig = {
|
||||||
|
/** NATS server URL with agent-specific credentials. */
|
||||||
|
natsUrl: string;
|
||||||
|
/** JetStream stream name for this agent. */
|
||||||
|
streamName: string;
|
||||||
|
/** Subject prefix override (default: openclaw.events.{agentId}). */
|
||||||
|
subjectPrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventStoreConfig = {
|
||||||
|
/** Enable Event Store (NATS JetStream) integration. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** NATS server URL (default: nats://localhost:4222). */
|
||||||
|
natsUrl?: string;
|
||||||
|
/** JetStream stream name (default: openclaw-events). */
|
||||||
|
streamName?: string;
|
||||||
|
/** Subject prefix for events (default: openclaw.events). */
|
||||||
|
subjectPrefix?: string;
|
||||||
|
/** Per-agent NATS credentials for isolated event stores. */
|
||||||
|
agents?: Record<string, AgentEventStoreConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
export type GatewayConfig = {
|
export type GatewayConfig = {
|
||||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||||
port?: number;
|
port?: number;
|
||||||
|
|
@ -235,6 +257,8 @@ export type GatewayConfig = {
|
||||||
tls?: GatewayTlsConfig;
|
tls?: GatewayTlsConfig;
|
||||||
http?: GatewayHttpConfig;
|
http?: GatewayHttpConfig;
|
||||||
nodes?: GatewayNodesConfig;
|
nodes?: GatewayNodesConfig;
|
||||||
|
/** Event Store (NATS JetStream) configuration for persistent event logging. */
|
||||||
|
eventStore?: EventStoreConfig;
|
||||||
/**
|
/**
|
||||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,27 @@ export const OpenClawSchema = z
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
eventStore: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
natsUrl: z.string().optional(),
|
||||||
|
streamName: z.string().optional(),
|
||||||
|
subjectPrefix: z.string().optional(),
|
||||||
|
agents: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
natsUrl: z.string(),
|
||||||
|
streamName: z.string(),
|
||||||
|
subjectPrefix: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
|
import { shutdownEventStore } from "../infra/event-store.js";
|
||||||
|
|
||||||
export function createGatewayCloseHandler(params: {
|
export function createGatewayCloseHandler(params: {
|
||||||
bonjourStop: (() => Promise<void>) | null;
|
bonjourStop: (() => Promise<void>) | null;
|
||||||
|
|
@ -124,5 +125,8 @@ export function createGatewayCloseHandler(params: {
|
||||||
httpServer.close((err) => (err ? reject(err) : resolve())),
|
httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown Event Store connection
|
||||||
|
await shutdownEventStore().catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
||||||
import { logAcceptedEnvOption } from "../infra/env.js";
|
import { logAcceptedEnvOption } from "../infra/env.js";
|
||||||
|
import { initEventStore, shutdownEventStore } from "../infra/event-store.js";
|
||||||
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
||||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
|
|
@ -216,6 +217,20 @@ export async function startGatewayServer(
|
||||||
}
|
}
|
||||||
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
|
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
|
||||||
initSubagentRegistry();
|
initSubagentRegistry();
|
||||||
|
|
||||||
|
// Initialize Event Store if configured
|
||||||
|
const eventStoreConfig = cfgAtStart.gateway?.eventStore;
|
||||||
|
if (eventStoreConfig?.enabled) {
|
||||||
|
await initEventStore({
|
||||||
|
enabled: true,
|
||||||
|
natsUrl: eventStoreConfig.natsUrl || "nats://localhost:4222",
|
||||||
|
streamName: eventStoreConfig.streamName || "openclaw-events",
|
||||||
|
subjectPrefix: eventStoreConfig.subjectPrefix || "openclaw.events",
|
||||||
|
agents: eventStoreConfig.agents,
|
||||||
|
});
|
||||||
|
log.info("gateway: Event Store initialized");
|
||||||
|
}
|
||||||
|
|
||||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||||
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
||||||
const baseMethods = listGatewayMethods();
|
const baseMethods = listGatewayMethods();
|
||||||
|
|
|
||||||
347
src/infra/event-context.ts
Normal file
347
src/infra/event-context.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
/**
|
||||||
|
* Event Context Builder for OpenClaw
|
||||||
|
*
|
||||||
|
* Phase 2 of RFC-001: Build session context from events
|
||||||
|
*
|
||||||
|
* This replaces file-based memory with event-sourced context.
|
||||||
|
* On session start, we query recent events and build a context document.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
type NatsConnection,
|
||||||
|
type JetStreamClient,
|
||||||
|
StringCodec,
|
||||||
|
AckPolicy,
|
||||||
|
DeliverPolicy,
|
||||||
|
} from "nats";
|
||||||
|
|
||||||
|
const sc = StringCodec();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NATS URL and extract credentials if present
|
||||||
|
* Supports: nats://user:pass@host:port or nats://host:port
|
||||||
|
*/
|
||||||
|
function parseNatsUrl(urlString: string): { servers: string; user?: string; pass?: string } {
|
||||||
|
try {
|
||||||
|
const httpUrl = urlString.replace(/^nats:\/\//, "http://");
|
||||||
|
const url = new URL(httpUrl);
|
||||||
|
const servers = `${url.hostname}:${url.port || 4222}`;
|
||||||
|
|
||||||
|
if (url.username && url.password) {
|
||||||
|
return {
|
||||||
|
servers,
|
||||||
|
user: decodeURIComponent(url.username),
|
||||||
|
pass: decodeURIComponent(url.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { servers };
|
||||||
|
} catch {
|
||||||
|
return { servers: urlString.replace(/^nats:\/\//, "") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventContextConfig = {
|
||||||
|
natsUrl: string;
|
||||||
|
streamName: string;
|
||||||
|
subjectPrefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextOptions = {
|
||||||
|
/** Agent to build context for */
|
||||||
|
agent: string;
|
||||||
|
/** Session key */
|
||||||
|
sessionKey?: string;
|
||||||
|
/** Hours of history to include (default: 24) */
|
||||||
|
hoursBack?: number;
|
||||||
|
/** Max events to process (default: 1000) */
|
||||||
|
maxEvents?: number;
|
||||||
|
/** Include tool calls in context (default: false) */
|
||||||
|
includeTools?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventContext = {
|
||||||
|
/** Recent conversation messages */
|
||||||
|
recentMessages: ConversationMessage[];
|
||||||
|
/** Summary of older conversations */
|
||||||
|
conversationSummary?: string;
|
||||||
|
/** Active topics being discussed */
|
||||||
|
activeTopics: string[];
|
||||||
|
/** Pending decisions/questions */
|
||||||
|
pendingItems: string[];
|
||||||
|
/** Facts learned in this period */
|
||||||
|
facts: string[];
|
||||||
|
/** Timestamp of context build */
|
||||||
|
builtAt: number;
|
||||||
|
/** Number of events processed */
|
||||||
|
eventsProcessed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationMessage = {
|
||||||
|
timestamp: number;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
session: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoredEvent = {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
agent: string;
|
||||||
|
session: string;
|
||||||
|
type: string;
|
||||||
|
visibility: string;
|
||||||
|
payload: {
|
||||||
|
runId: string;
|
||||||
|
stream: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
sessionKey?: string;
|
||||||
|
seq: number;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
stream: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query events from NATS JetStream
|
||||||
|
*/
|
||||||
|
async function queryEvents(
|
||||||
|
config: EventContextConfig,
|
||||||
|
options: ContextOptions,
|
||||||
|
): Promise<StoredEvent[]> {
|
||||||
|
const { servers, user, pass } = parseNatsUrl(config.natsUrl);
|
||||||
|
const nc = await connect({ servers, ...(user && pass ? { user, pass } : {}) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsm = await nc.jetstreamManager();
|
||||||
|
|
||||||
|
// Check if stream exists and get info
|
||||||
|
let streamInfo;
|
||||||
|
try {
|
||||||
|
streamInfo = await jsm.streams.info(config.streamName);
|
||||||
|
} catch {
|
||||||
|
console.log("[event-context] Stream not found, returning empty context");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: StoredEvent[] = [];
|
||||||
|
const hoursBack = options.hoursBack ?? 24;
|
||||||
|
const maxEvents = options.maxEvents ?? 1000;
|
||||||
|
const startTime = Date.now() - hoursBack * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Get message count
|
||||||
|
const lastSeq = streamInfo.state.last_seq;
|
||||||
|
if (lastSeq === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from end and go back
|
||||||
|
const startSeq = Math.max(1, lastSeq - maxEvents);
|
||||||
|
|
||||||
|
// Fetch messages by sequence
|
||||||
|
for (let seq = startSeq; seq <= lastSeq && events.length < maxEvents; seq++) {
|
||||||
|
try {
|
||||||
|
const msg = await jsm.streams.getMessage(config.streamName, { seq });
|
||||||
|
const data = JSON.parse(sc.decode(msg.data)) as StoredEvent;
|
||||||
|
|
||||||
|
// Filter by timestamp
|
||||||
|
if (data.timestamp < startTime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by agent if specified
|
||||||
|
if (options.agent && data.agent !== options.agent && data.agent !== "agent") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by session if specified
|
||||||
|
if (options.sessionKey && data.session !== options.sessionKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out tool events unless requested
|
||||||
|
if (!options.includeTools && data.type.includes("tool")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(data);
|
||||||
|
} catch {
|
||||||
|
// Message may have been deleted
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} finally {
|
||||||
|
await nc.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract conversation messages from events
|
||||||
|
*/
|
||||||
|
function extractConversations(events: StoredEvent[]): ConversationMessage[] {
|
||||||
|
const messages: ConversationMessage[] = [];
|
||||||
|
const seenTexts = new Set<string>();
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
if (event.type !== "conversation.message.out") continue;
|
||||||
|
|
||||||
|
const text = event.payload.data?.text as string;
|
||||||
|
if (!text || seenTexts.has(text)) continue;
|
||||||
|
|
||||||
|
// Only keep final/complete messages (skip deltas)
|
||||||
|
// We detect this by checking if this is the last event with this runId
|
||||||
|
const runId = event.payload.runId;
|
||||||
|
const laterEvents = sorted.filter(
|
||||||
|
(e) => e.payload.runId === runId && e.timestamp > event.timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are later events with same runId, this is a delta - skip
|
||||||
|
if (laterEvents.length > 0) continue;
|
||||||
|
|
||||||
|
seenTexts.add(text);
|
||||||
|
messages.push({
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
role: "assistant",
|
||||||
|
text: text.trim(),
|
||||||
|
session: event.session,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract active topics from recent conversation
|
||||||
|
*/
|
||||||
|
function extractTopics(messages: ConversationMessage[]): string[] {
|
||||||
|
const topics: string[] = [];
|
||||||
|
const recentMessages = messages.slice(-10);
|
||||||
|
|
||||||
|
// Simple keyword extraction (could be enhanced with LLM)
|
||||||
|
const topicKeywords = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const msg of recentMessages) {
|
||||||
|
// Extract capitalized phrases (likely topics)
|
||||||
|
const matches = msg.text.match(/[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*/g) || [];
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.length > 3) {
|
||||||
|
topicKeywords.set(match, (topicKeywords.get(match) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top topics
|
||||||
|
const sorted = [...topicKeywords.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
|
|
||||||
|
for (const [topic] of sorted) {
|
||||||
|
topics.push(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build context document from events
|
||||||
|
*/
|
||||||
|
export async function buildEventContext(
|
||||||
|
config: EventContextConfig,
|
||||||
|
options: ContextOptions,
|
||||||
|
): Promise<EventContext> {
|
||||||
|
const events = await queryEvents(config, options);
|
||||||
|
const messages = extractConversations(events);
|
||||||
|
const topics = extractTopics(messages);
|
||||||
|
|
||||||
|
// Split messages into recent (last 2 hours) and older
|
||||||
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||||
|
const recentMessages = messages.filter((m) => m.timestamp > twoHoursAgo);
|
||||||
|
const olderMessages = messages.filter((m) => m.timestamp <= twoHoursAgo);
|
||||||
|
|
||||||
|
// Create summary of older messages (simple for now)
|
||||||
|
let conversationSummary: string | undefined;
|
||||||
|
if (olderMessages.length > 0) {
|
||||||
|
const count = olderMessages.length;
|
||||||
|
const firstTime = new Date(olderMessages[0].timestamp).toLocaleString();
|
||||||
|
conversationSummary = `[${count} earlier messages starting from ${firstTime}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentMessages,
|
||||||
|
conversationSummary,
|
||||||
|
activeTopics: topics,
|
||||||
|
pendingItems: [], // TODO: Extract from lifecycle events
|
||||||
|
facts: [], // TODO: Extract from fact events
|
||||||
|
builtAt: Date.now(),
|
||||||
|
eventsProcessed: events.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format context for injection into system prompt
|
||||||
|
*/
|
||||||
|
export function formatContextForPrompt(context: EventContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push("## Event-Sourced Context");
|
||||||
|
lines.push(`Built at: ${new Date(context.builtAt).toISOString()}`);
|
||||||
|
lines.push(`Events processed: ${context.eventsProcessed}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (context.activeTopics.length > 0) {
|
||||||
|
lines.push("### Active Topics");
|
||||||
|
for (const topic of context.activeTopics) {
|
||||||
|
lines.push(`- ${topic}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.conversationSummary) {
|
||||||
|
lines.push("### Earlier Conversation");
|
||||||
|
lines.push(context.conversationSummary);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.recentMessages.length > 0) {
|
||||||
|
lines.push("### Recent Messages (last 2h)");
|
||||||
|
for (const msg of context.recentMessages.slice(-5)) {
|
||||||
|
const time = new Date(msg.timestamp).toLocaleTimeString();
|
||||||
|
const preview = msg.text.length > 100 ? msg.text.substring(0, 100) + "..." : msg.text;
|
||||||
|
lines.push(`- [${time}] ${msg.role}: ${preview}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI helper to test context building
|
||||||
|
*/
|
||||||
|
export async function testContextBuild(natsUrl: string = "nats://localhost:4222"): Promise<void> {
|
||||||
|
console.log("Building event context...");
|
||||||
|
|
||||||
|
const config: EventContextConfig = {
|
||||||
|
natsUrl,
|
||||||
|
streamName: "openclaw-events",
|
||||||
|
subjectPrefix: "openclaw.events",
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await buildEventContext(config, {
|
||||||
|
agent: "agent",
|
||||||
|
hoursBack: 1,
|
||||||
|
maxEvents: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n=== Event Context ===");
|
||||||
|
console.log(formatContextForPrompt(context));
|
||||||
|
console.log("\n=== Raw Context ===");
|
||||||
|
console.log(JSON.stringify(context, null, 2));
|
||||||
|
}
|
||||||
203
src/infra/event-store.test.ts
Normal file
203
src/infra/event-store.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock NATS module
|
||||||
|
vi.mock("nats", () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
StringCodec: vi.fn(() => ({
|
||||||
|
encode: (s: string) => Buffer.from(s),
|
||||||
|
decode: (b: Buffer) => b.toString(),
|
||||||
|
})),
|
||||||
|
RetentionPolicy: { Limits: "limits" },
|
||||||
|
StorageType: { File: "file" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Event Store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateEventId", () => {
|
||||||
|
it("should generate time-sortable IDs", async () => {
|
||||||
|
// Import after mocks are set up
|
||||||
|
const { initEventStore, closeEventStore } = await import("./event-store.js");
|
||||||
|
|
||||||
|
// IDs should be string format: timestamp-random
|
||||||
|
const id1 = Date.now().toString(36);
|
||||||
|
const id2 = Date.now().toString(36);
|
||||||
|
|
||||||
|
// Timestamps should be close
|
||||||
|
expect(id1.length).toBeGreaterThan(5);
|
||||||
|
expect(id2.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapStreamToEventType", () => {
|
||||||
|
it("should map lifecycle streams correctly", async () => {
|
||||||
|
// The function maps:
|
||||||
|
// lifecycle + phase:start → lifecycle.start
|
||||||
|
// lifecycle + phase:end → lifecycle.end
|
||||||
|
// lifecycle + phase:error → lifecycle.error
|
||||||
|
// tool + no result → conversation.tool_call
|
||||||
|
// tool + result → conversation.tool_result
|
||||||
|
// default → conversation.message.out
|
||||||
|
|
||||||
|
// These are tested implicitly through integration
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initEventStore", () => {
|
||||||
|
it("should not connect when disabled", async () => {
|
||||||
|
const { connect } = await import("nats");
|
||||||
|
const { initEventStore } = await import("./event-store.js");
|
||||||
|
|
||||||
|
await initEventStore({ enabled: false } as any);
|
||||||
|
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should connect to NATS when enabled", async () => {
|
||||||
|
const mockJetstream = vi.fn();
|
||||||
|
const mockJetstreamManager = vi.fn().mockResolvedValue({
|
||||||
|
streams: {
|
||||||
|
info: vi.fn().mockRejectedValue(new Error("not found")),
|
||||||
|
add: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockConnection = {
|
||||||
|
jetstream: mockJetstream.mockReturnValue({}),
|
||||||
|
jetstreamManager: mockJetstreamManager,
|
||||||
|
isClosed: vi.fn().mockReturnValue(false),
|
||||||
|
drain: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { connect } = await import("nats");
|
||||||
|
(connect as any).mockResolvedValue(mockConnection);
|
||||||
|
|
||||||
|
const { initEventStore, closeEventStore } = await import("./event-store.js");
|
||||||
|
|
||||||
|
await initEventStore({
|
||||||
|
enabled: true,
|
||||||
|
natsUrl: "nats://localhost:4222",
|
||||||
|
streamName: "test-events",
|
||||||
|
subjectPrefix: "test.events",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
servers: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await closeEventStore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event publishing", () => {
|
||||||
|
it("should format events correctly", () => {
|
||||||
|
// Event format:
|
||||||
|
// {
|
||||||
|
// id: string (ulid-like)
|
||||||
|
// timestamp: number (unix ms)
|
||||||
|
// agent: string
|
||||||
|
// session: string
|
||||||
|
// type: EventType
|
||||||
|
// visibility: 'internal'
|
||||||
|
// payload: AgentEventPayload
|
||||||
|
// meta: { runId, seq, stream }
|
||||||
|
// }
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
id: "test-123",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
agent: "main",
|
||||||
|
session: "agent:main:main",
|
||||||
|
type: "conversation.message.out" as const,
|
||||||
|
visibility: "internal" as const,
|
||||||
|
payload: {
|
||||||
|
runId: "run-123",
|
||||||
|
stream: "assistant",
|
||||||
|
data: { text: "Hello" },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
seq: 1,
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
runId: "run-123",
|
||||||
|
seq: 1,
|
||||||
|
stream: "assistant",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.id).toBeDefined();
|
||||||
|
expect(event.type).toBe("conversation.message.out");
|
||||||
|
expect(event.visibility).toBe("internal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-agent support", () => {
|
||||||
|
it("should support per-agent configurations", async () => {
|
||||||
|
const config = {
|
||||||
|
enabled: true,
|
||||||
|
natsUrl: "nats://main:pass@localhost:4222",
|
||||||
|
streamName: "openclaw-events",
|
||||||
|
subjectPrefix: "openclaw.events.main",
|
||||||
|
agents: {
|
||||||
|
"agent-one": {
|
||||||
|
natsUrl: "nats://agent1:pass@localhost:4222",
|
||||||
|
streamName: "events-agent-one",
|
||||||
|
subjectPrefix: "openclaw.events.agent-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(config.agents).toBeDefined();
|
||||||
|
expect(config.agents!["agent-one"].natsUrl).toContain("agent1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Event Context", () => {
|
||||||
|
describe("buildEventContext", () => {
|
||||||
|
it("should extract topics from messages", () => {
|
||||||
|
// Topics are extracted by finding capitalized words and common patterns
|
||||||
|
const text = "Discussing NATS JetStream and EventStore integration";
|
||||||
|
|
||||||
|
// Should identify: NATS, JetStream, EventStore
|
||||||
|
expect(text).toContain("NATS");
|
||||||
|
expect(text).toContain("JetStream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deduplicate conversation messages", () => {
|
||||||
|
const messages = [
|
||||||
|
{ text: "Hello", timestamp: 1 },
|
||||||
|
{ text: "Hello", timestamp: 2 }, // duplicate
|
||||||
|
{ text: "World", timestamp: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const unique = [...new Set(messages.map((m) => m.text))];
|
||||||
|
expect(unique).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format context for system prompt", () => {
|
||||||
|
const context = {
|
||||||
|
eventCount: 100,
|
||||||
|
timeRange: "last 24h",
|
||||||
|
topics: ["NATS", "Events"],
|
||||||
|
recentMessages: ["User asked about X", "Agent responded with Y"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatted = `## Event-Sourced Context
|
||||||
|
Events processed: ${context.eventCount}
|
||||||
|
Topics: ${context.topics.join(", ")}`;
|
||||||
|
|
||||||
|
expect(formatted).toContain("Event-Sourced Context");
|
||||||
|
expect(formatted).toContain("NATS");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
368
src/infra/event-store.ts
Normal file
368
src/infra/event-store.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
/**
|
||||||
|
* Event Store Integration for OpenClaw
|
||||||
|
*
|
||||||
|
* Publishes all agent events to NATS JetStream for persistent storage.
|
||||||
|
* This enables:
|
||||||
|
* - Full audit trail of all interactions
|
||||||
|
* - Context rebuild from events (no more forgetting)
|
||||||
|
* - Multi-agent event sharing with isolation
|
||||||
|
* - Time-travel debugging
|
||||||
|
*
|
||||||
|
* Supports per-agent NATS credentials for hard isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
type NatsConnection,
|
||||||
|
type JetStreamClient,
|
||||||
|
StringCodec,
|
||||||
|
RetentionPolicy,
|
||||||
|
StorageType,
|
||||||
|
} from "nats";
|
||||||
|
import type { AgentEventPayload } from "./agent-events.js";
|
||||||
|
import { onAgentEvent } from "./agent-events.js";
|
||||||
|
|
||||||
|
const sc = StringCodec();
|
||||||
|
|
||||||
|
export type AgentEventStoreConfig = {
|
||||||
|
natsUrl: string;
|
||||||
|
streamName: string;
|
||||||
|
subjectPrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventStoreConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
natsUrl: string;
|
||||||
|
streamName: string;
|
||||||
|
subjectPrefix: string;
|
||||||
|
agents?: Record<string, AgentEventStoreConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClawEvent = {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
agent: string;
|
||||||
|
session: string;
|
||||||
|
type: EventType;
|
||||||
|
visibility: Visibility;
|
||||||
|
payload: AgentEventPayload;
|
||||||
|
meta: {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
stream: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventType =
|
||||||
|
| "conversation.message.in"
|
||||||
|
| "conversation.message.out"
|
||||||
|
| "conversation.tool_call"
|
||||||
|
| "conversation.tool_result"
|
||||||
|
| "lifecycle.start"
|
||||||
|
| "lifecycle.end"
|
||||||
|
| "lifecycle.error";
|
||||||
|
|
||||||
|
export type Visibility = "public" | "internal" | "confidential";
|
||||||
|
|
||||||
|
// Main connection (for default/main agent)
|
||||||
|
let mainConnection: NatsConnection | null = null;
|
||||||
|
let mainJetstream: JetStreamClient | null = null;
|
||||||
|
|
||||||
|
// Per-agent connections for isolated agents
|
||||||
|
const agentConnections = new Map<string, { nc: NatsConnection; js: JetStreamClient }>();
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let eventStoreConfig: EventStoreConfig | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a ULID-like ID (time-sortable)
|
||||||
|
*/
|
||||||
|
function generateEventId(): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).substring(2, 10);
|
||||||
|
return `${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map agent event stream to our event type
|
||||||
|
*/
|
||||||
|
function mapStreamToEventType(stream: string, data: Record<string, unknown>): EventType {
|
||||||
|
if (stream === "lifecycle") {
|
||||||
|
const phase = data?.phase as string;
|
||||||
|
if (phase === "start") return "lifecycle.start";
|
||||||
|
if (phase === "end") return "lifecycle.end";
|
||||||
|
if (phase === "error") return "lifecycle.error";
|
||||||
|
return "lifecycle.start";
|
||||||
|
}
|
||||||
|
if (stream === "tool") {
|
||||||
|
const hasResult = "result" in data || "output" in data;
|
||||||
|
return hasResult ? "conversation.tool_result" : "conversation.tool_call";
|
||||||
|
}
|
||||||
|
if (stream === "assistant") {
|
||||||
|
return "conversation.message.out";
|
||||||
|
}
|
||||||
|
if (stream === "error") {
|
||||||
|
return "lifecycle.error";
|
||||||
|
}
|
||||||
|
return "conversation.message.out";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract agent ID from session key
|
||||||
|
* Formats: "main" | "agent:main:sessionId" | "agentId:sessionId"
|
||||||
|
*/
|
||||||
|
function extractAgentFromSession(sessionKey?: string): string {
|
||||||
|
if (!sessionKey) return "main";
|
||||||
|
if (sessionKey === "main") return "main";
|
||||||
|
|
||||||
|
// Handle "agent:agentId:sessionId" format
|
||||||
|
if (sessionKey.startsWith("agent:")) {
|
||||||
|
const parts = sessionKey.split(":");
|
||||||
|
return parts[1] || "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "agentId:sessionId" format
|
||||||
|
const parts = sessionKey.split(":");
|
||||||
|
return parts[0] || "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AgentEventPayload to ClawEvent
|
||||||
|
*/
|
||||||
|
function toClawEvent(evt: AgentEventPayload): ClawEvent {
|
||||||
|
return {
|
||||||
|
id: generateEventId(),
|
||||||
|
timestamp: evt.ts,
|
||||||
|
agent: extractAgentFromSession(evt.sessionKey),
|
||||||
|
session: evt.sessionKey || "unknown",
|
||||||
|
type: mapStreamToEventType(evt.stream, evt.data),
|
||||||
|
visibility: "internal",
|
||||||
|
payload: evt,
|
||||||
|
meta: {
|
||||||
|
runId: evt.runId,
|
||||||
|
seq: evt.seq,
|
||||||
|
stream: evt.stream,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NATS URL and extract credentials if present
|
||||||
|
* Supports: nats://user:pass@host:port or nats://host:port
|
||||||
|
*/
|
||||||
|
function parseNatsUrl(urlString: string): { servers: string; user?: string; pass?: string } {
|
||||||
|
try {
|
||||||
|
const httpUrl = urlString.replace(/^nats:\/\//, "http://");
|
||||||
|
const url = new URL(httpUrl);
|
||||||
|
const servers = `${url.hostname}:${url.port || 4222}`;
|
||||||
|
|
||||||
|
if (url.username && url.password) {
|
||||||
|
return {
|
||||||
|
servers,
|
||||||
|
user: decodeURIComponent(url.username),
|
||||||
|
pass: decodeURIComponent(url.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { servers };
|
||||||
|
} catch {
|
||||||
|
return { servers: urlString.replace(/^nats:\/\//, "") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a connection for a specific agent
|
||||||
|
*/
|
||||||
|
async function getAgentConnection(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<{ js: JetStreamClient; prefix: string; streamName: string } | null> {
|
||||||
|
if (!eventStoreConfig) return null;
|
||||||
|
|
||||||
|
// Check for agent-specific config
|
||||||
|
const agentConfig = eventStoreConfig.agents?.[agentId];
|
||||||
|
|
||||||
|
if (agentConfig) {
|
||||||
|
// Check if we already have a connection for this agent
|
||||||
|
const existing = agentConnections.get(agentId);
|
||||||
|
if (existing && !existing.nc.isClosed()) {
|
||||||
|
return {
|
||||||
|
js: existing.js,
|
||||||
|
prefix: agentConfig.subjectPrefix || `openclaw.events.${agentId}`,
|
||||||
|
streamName: agentConfig.streamName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new connection for this agent
|
||||||
|
try {
|
||||||
|
const { servers, user, pass } = parseNatsUrl(agentConfig.natsUrl);
|
||||||
|
const nc = await connect({ servers, ...(user && pass ? { user, pass } : {}) });
|
||||||
|
const js = nc.jetstream();
|
||||||
|
|
||||||
|
agentConnections.set(agentId, { nc, js });
|
||||||
|
console.log(`[event-store] Created isolated connection for agent: ${agentId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
js,
|
||||||
|
prefix: agentConfig.subjectPrefix || `openclaw.events.${agentId}`,
|
||||||
|
streamName: agentConfig.streamName,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[event-store] Failed to create connection for agent ${agentId}:`, err);
|
||||||
|
// Fall through to use main connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use main connection for unconfigured agents
|
||||||
|
if (mainJetstream) {
|
||||||
|
return {
|
||||||
|
js: mainJetstream,
|
||||||
|
prefix: eventStoreConfig.subjectPrefix,
|
||||||
|
streamName: eventStoreConfig.streamName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish event to NATS JetStream
|
||||||
|
*/
|
||||||
|
async function publishEvent(evt: AgentEventPayload): Promise<void> {
|
||||||
|
if (!eventStoreConfig) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clawEvent = toClawEvent(evt);
|
||||||
|
const agentId = clawEvent.agent;
|
||||||
|
|
||||||
|
// Get the appropriate connection for this agent
|
||||||
|
const conn = await getAgentConnection(agentId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
|
const subject = `${conn.prefix}.${clawEvent.type.replace(/\./g, "_")}`;
|
||||||
|
const payload = sc.encode(JSON.stringify(clawEvent));
|
||||||
|
|
||||||
|
await conn.js.publish(subject, payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[event-store] Failed to publish event:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the JetStream stream exists
|
||||||
|
*/
|
||||||
|
async function ensureStream(
|
||||||
|
nc: NatsConnection,
|
||||||
|
streamName: string,
|
||||||
|
subjects: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const jsm = await nc.jetstreamManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jsm.streams.info(streamName);
|
||||||
|
} catch {
|
||||||
|
await jsm.streams.add({
|
||||||
|
name: streamName,
|
||||||
|
subjects,
|
||||||
|
retention: RetentionPolicy.Limits,
|
||||||
|
max_msgs: -1,
|
||||||
|
max_bytes: -1,
|
||||||
|
max_age: 0,
|
||||||
|
storage: StorageType.File,
|
||||||
|
num_replicas: 1,
|
||||||
|
duplicate_window: 120_000_000_000,
|
||||||
|
});
|
||||||
|
console.log(`[event-store] Created stream: ${streamName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the event store connection
|
||||||
|
*/
|
||||||
|
export async function initEventStore(config: EventStoreConfig): Promise<void> {
|
||||||
|
if (!config.enabled) {
|
||||||
|
console.log("[event-store] Disabled by config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventStoreConfig = config;
|
||||||
|
|
||||||
|
// Initialize main connection
|
||||||
|
const { servers, user, pass } = parseNatsUrl(config.natsUrl);
|
||||||
|
console.log(`[event-store] Connecting to NATS: ${servers} (auth: ${user ? "yes" : "no"})`);
|
||||||
|
|
||||||
|
mainConnection = await connect({ servers, ...(user && pass ? { user, pass } : {}) });
|
||||||
|
mainJetstream = mainConnection.jetstream();
|
||||||
|
console.log(`[event-store] Connected to NATS at ${servers}`);
|
||||||
|
|
||||||
|
// Ensure main stream exists
|
||||||
|
await ensureStream(mainConnection, config.streamName, [`${config.subjectPrefix}.>`]);
|
||||||
|
|
||||||
|
// Log configured agent isolations
|
||||||
|
if (config.agents) {
|
||||||
|
const agentIds = Object.keys(config.agents);
|
||||||
|
console.log(`[event-store] Agent isolation configured for: ${agentIds.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all agent events
|
||||||
|
unsubscribe = onAgentEvent((evt) => {
|
||||||
|
publishEvent(evt).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[event-store] Event listener registered");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[event-store] Failed to initialize:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the event store connection
|
||||||
|
*/
|
||||||
|
export async function shutdownEventStore(): Promise<void> {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close agent connections
|
||||||
|
for (const [agentId, { nc }] of agentConnections) {
|
||||||
|
try {
|
||||||
|
await nc.drain();
|
||||||
|
console.log(`[event-store] Closed connection for agent: ${agentId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[event-store] Error closing connection for ${agentId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agentConnections.clear();
|
||||||
|
|
||||||
|
// Close main connection
|
||||||
|
if (mainConnection) {
|
||||||
|
await mainConnection.drain();
|
||||||
|
mainConnection = null;
|
||||||
|
mainJetstream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStoreConfig = null;
|
||||||
|
console.log("[event-store] Shutdown complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event store is connected
|
||||||
|
*/
|
||||||
|
export function isEventStoreConnected(): boolean {
|
||||||
|
return mainConnection !== null && !mainConnection.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event store status
|
||||||
|
*/
|
||||||
|
export function getEventStoreStatus(): {
|
||||||
|
connected: boolean;
|
||||||
|
config: EventStoreConfig | null;
|
||||||
|
agentConnections: string[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
connected: isEventStoreConnected(),
|
||||||
|
config: eventStoreConfig,
|
||||||
|
agentConnections: Array.from(agentConnections.keys()),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue