@actagent/nostr 2026.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/actagent.plugin.json +17 -0
- package/api.ts +11 -0
- package/channel-plugin-api.ts +2 -0
- package/doctor-contract-api.test.ts +105 -0
- package/doctor-contract-api.ts +297 -0
- package/index.ts +96 -0
- package/npm-shrinkwrap.json +137 -0
- package/package.json +67 -0
- package/runtime-api.ts +6 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +10 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +12 -0
- package/src/channel.inbound.test.ts +203 -0
- package/src/channel.lifecycle.test.ts +97 -0
- package/src/channel.outbound.test.ts +175 -0
- package/src/channel.setup.ts +161 -0
- package/src/channel.test.ts +527 -0
- package/src/channel.ts +215 -0
- package/src/config-schema.ts +99 -0
- package/src/default-relays.ts +2 -0
- package/src/gateway.ts +338 -0
- package/src/inbound-direct-dm-runtime.ts +2 -0
- package/src/metrics.ts +454 -0
- package/src/nostr-bus.fuzz.test.ts +383 -0
- package/src/nostr-bus.inbound.test.ts +598 -0
- package/src/nostr-bus.integration.test.ts +491 -0
- package/src/nostr-bus.test.ts +256 -0
- package/src/nostr-bus.ts +799 -0
- package/src/nostr-key-utils.ts +93 -0
- package/src/nostr-profile-core.ts +135 -0
- package/src/nostr-profile-http-runtime.ts +7 -0
- package/src/nostr-profile-http.test.ts +632 -0
- package/src/nostr-profile-http.ts +583 -0
- package/src/nostr-profile-import.test.ts +196 -0
- package/src/nostr-profile-import.ts +273 -0
- package/src/nostr-profile-url-safety.ts +22 -0
- package/src/nostr-profile.fuzz.test.ts +431 -0
- package/src/nostr-profile.test.ts +416 -0
- package/src/nostr-profile.ts +144 -0
- package/src/nostr-state-store.test.ts +172 -0
- package/src/nostr-state-store.ts +132 -0
- package/src/runtime.ts +10 -0
- package/src/seen-tracker.ts +291 -0
- package/src/session-route.ts +26 -0
- package/src/setup-adapter.ts +86 -0
- package/src/setup-surface.ts +204 -0
- package/src/test-fixtures.ts +46 -0
- package/src/types.ts +118 -0
- package/test/setup.ts +5 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @actagent/nostr
|
|
2
|
+
|
|
3
|
+
Nostr DM channel plugin for ACTAgent using NIP-04 encrypted direct messages.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This extension adds Nostr as a messaging channel to ACTAgent. It enables your bot to:
|
|
8
|
+
|
|
9
|
+
- Receive encrypted DMs from Nostr users
|
|
10
|
+
- Send encrypted responses back
|
|
11
|
+
- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
actagent plugins install @actagent/nostr
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Setup
|
|
20
|
+
|
|
21
|
+
1. Generate a Nostr keypair (if you don't have one):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Using nak CLI
|
|
25
|
+
nak key generate
|
|
26
|
+
|
|
27
|
+
# Or use any Nostr key generator
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
2. Add to your config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"channels": {
|
|
35
|
+
"nostr": {
|
|
36
|
+
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
|
37
|
+
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
3. Set the environment variable:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
4. Restart the gateway
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
| Key | Type | Default | Description |
|
|
54
|
+
| ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- |
|
|
55
|
+
| `privateKey` | string | required | Bot's private key (nsec or hex format) |
|
|
56
|
+
| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
|
|
57
|
+
| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
|
|
58
|
+
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
|
|
59
|
+
| `enabled` | boolean | `true` | Enable/disable the channel |
|
|
60
|
+
| `name` | string | - | Display name for the account |
|
|
61
|
+
|
|
62
|
+
## Access Control
|
|
63
|
+
|
|
64
|
+
### DM Policies
|
|
65
|
+
|
|
66
|
+
- **pairing** (default): Unknown senders receive a pairing code to request access
|
|
67
|
+
- **allowlist**: Only pubkeys in `allowFrom` can message the bot
|
|
68
|
+
- **open**: Anyone can message the bot (use with caution)
|
|
69
|
+
- **disabled**: DMs are disabled
|
|
70
|
+
|
|
71
|
+
Inbound event signatures are verified before policy enforcement and NIP-04 decryption.
|
|
72
|
+
Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not
|
|
73
|
+
processed unless approved.
|
|
74
|
+
|
|
75
|
+
### Example: Allowlist Mode
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"channels": {
|
|
80
|
+
"nostr": {
|
|
81
|
+
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
|
82
|
+
"dmPolicy": "allowlist",
|
|
83
|
+
"allowFrom": ["npub1abc...", "0123456789abcdef..."]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Testing
|
|
90
|
+
|
|
91
|
+
### Local Relay (Recommended)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Using strfry
|
|
95
|
+
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
|
96
|
+
|
|
97
|
+
# Configure actagent to use local relay
|
|
98
|
+
"relays": ["ws://localhost:7777"]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Manual Test
|
|
102
|
+
|
|
103
|
+
1. Start the gateway with Nostr configured
|
|
104
|
+
2. Open Damus, Amethyst, or another Nostr client
|
|
105
|
+
3. Send a DM to your bot's npub
|
|
106
|
+
4. Verify the bot responds
|
|
107
|
+
|
|
108
|
+
## Protocol Support
|
|
109
|
+
|
|
110
|
+
| NIP | Status | Notes |
|
|
111
|
+
| ------ | --------- | ---------------------- |
|
|
112
|
+
| NIP-01 | Supported | Basic event structure |
|
|
113
|
+
| NIP-04 | Supported | Encrypted DMs (kind:4) |
|
|
114
|
+
| NIP-17 | Planned | Gift-wrapped DMs (v2) |
|
|
115
|
+
|
|
116
|
+
## Security Notes
|
|
117
|
+
|
|
118
|
+
- Private keys are never logged
|
|
119
|
+
- Event signatures are verified before processing
|
|
120
|
+
- Sender policy is checked before expensive crypto work
|
|
121
|
+
- Inbound DMs are rate-limited and oversized payloads are dropped before decrypt
|
|
122
|
+
- Use environment variables for keys, never commit to config files
|
|
123
|
+
- Consider using `allowlist` mode in production
|
|
124
|
+
|
|
125
|
+
## Troubleshooting
|
|
126
|
+
|
|
127
|
+
### Bot not receiving messages
|
|
128
|
+
|
|
129
|
+
1. Verify private key is correctly configured
|
|
130
|
+
2. Check relay connectivity
|
|
131
|
+
3. Ensure `enabled` is not set to `false`
|
|
132
|
+
4. Check the bot's public key matches what you're sending to
|
|
133
|
+
|
|
134
|
+
### Messages not being delivered
|
|
135
|
+
|
|
136
|
+
1. Check relay URLs are correct (must use `wss://`)
|
|
137
|
+
2. Verify relays are online and accepting connections
|
|
138
|
+
3. Check for rate limiting (reduce message frequency)
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nostr",
|
|
3
|
+
"name": "Nostr",
|
|
4
|
+
"description": "ACTAgent Nostr channel plugin for NIP-04 encrypted direct messages.",
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": false
|
|
7
|
+
},
|
|
8
|
+
"channels": ["nostr"],
|
|
9
|
+
"channelEnvVars": {
|
|
10
|
+
"nostr": ["NOSTR_PRIVATE_KEY"]
|
|
11
|
+
},
|
|
12
|
+
"configSchema": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {}
|
|
16
|
+
}
|
|
17
|
+
}
|
package/api.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Nostr API module exposes the plugin public contract.
|
|
2
|
+
export {
|
|
3
|
+
getPluginRuntimeGatewayRequestScope,
|
|
4
|
+
type ACTAgentConfig,
|
|
5
|
+
type PluginRuntime,
|
|
6
|
+
} from "./runtime-api.js";
|
|
7
|
+
export { nostrPlugin } from "./src/channel.js";
|
|
8
|
+
export { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
|
|
9
|
+
export { getNostrRuntime, setNostrRuntime } from "./src/runtime.js";
|
|
10
|
+
export { resolveNostrAccount } from "./src/types.js";
|
|
11
|
+
export type { ResolvedNostrAccount } from "./src/types.js";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Nostr tests cover doctor contract api plugin behavior.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createPluginStateKeyedStoreForTests,
|
|
7
|
+
resetPluginStateStoreForTests,
|
|
8
|
+
} from "actagent/plugin-sdk/plugin-state-test-runtime";
|
|
9
|
+
import type {
|
|
10
|
+
OpenKeyedStoreOptions,
|
|
11
|
+
PluginDoctorStateMigrationContext,
|
|
12
|
+
} from "actagent/plugin-sdk/runtime-doctor";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
import { stateMigrations } from "./doctor-contract-api.js";
|
|
15
|
+
|
|
16
|
+
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
|
17
|
+
return {
|
|
18
|
+
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
|
19
|
+
return createPluginStateKeyedStoreForTests<T>("nostr", {
|
|
20
|
+
...options,
|
|
21
|
+
env: options.env ?? env,
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("nostr doctor state migration", () => {
|
|
28
|
+
let stateDir = "";
|
|
29
|
+
let env: NodeJS.ProcessEnv;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
resetPluginStateStoreForTests();
|
|
33
|
+
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "actagent-nostr-doctor-"));
|
|
34
|
+
env = { ...process.env, ACTAGENT_STATE_DIR: stateDir };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await fs.rm(stateDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("imports legacy bus and profile state into plugin state", async () => {
|
|
42
|
+
const nostrDir = path.join(stateDir, "nostr");
|
|
43
|
+
const busPath = path.join(nostrDir, "bus-state-main.json");
|
|
44
|
+
const profilePath = path.join(nostrDir, "profile-state-main.json");
|
|
45
|
+
await fs.mkdir(nostrDir, { recursive: true });
|
|
46
|
+
await fs.writeFile(
|
|
47
|
+
busPath,
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
version: 1,
|
|
50
|
+
lastProcessedAt: 1700,
|
|
51
|
+
gatewayStartedAt: 1600,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
await fs.writeFile(
|
|
55
|
+
profilePath,
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
version: 1,
|
|
58
|
+
lastPublishedAt: 1800,
|
|
59
|
+
lastPublishedEventId: "event-1",
|
|
60
|
+
lastPublishResults: { "wss://relay.example": "ok", bad: "nope" },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const context = createDoctorContext(env);
|
|
65
|
+
const busResult = await stateMigrations[0].migrateLegacyState({
|
|
66
|
+
config: {},
|
|
67
|
+
env,
|
|
68
|
+
stateDir,
|
|
69
|
+
oauthDir: path.join(stateDir, "oauth"),
|
|
70
|
+
context,
|
|
71
|
+
});
|
|
72
|
+
const profileResult = await stateMigrations[1].migrateLegacyState({
|
|
73
|
+
config: {},
|
|
74
|
+
env,
|
|
75
|
+
stateDir,
|
|
76
|
+
oauthDir: path.join(stateDir, "oauth"),
|
|
77
|
+
context,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(busResult.warnings).toEqual([]);
|
|
81
|
+
expect(profileResult.warnings).toEqual([]);
|
|
82
|
+
await expect(fs.access(busPath)).rejects.toThrow();
|
|
83
|
+
await expect(fs.access(profilePath)).rejects.toThrow();
|
|
84
|
+
await expect(fs.access(`${busPath}.migrated`)).resolves.toBeUndefined();
|
|
85
|
+
await expect(fs.access(`${profilePath}.migrated`)).resolves.toBeUndefined();
|
|
86
|
+
await expect(
|
|
87
|
+
context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 }).lookup("main"),
|
|
88
|
+
).resolves.toEqual({
|
|
89
|
+
version: 2,
|
|
90
|
+
lastProcessedAt: 1700,
|
|
91
|
+
gatewayStartedAt: 1600,
|
|
92
|
+
recentEventIds: [],
|
|
93
|
+
});
|
|
94
|
+
await expect(
|
|
95
|
+
context
|
|
96
|
+
.openPluginStateKeyedStore({ namespace: "profile-state", maxEntries: 256 })
|
|
97
|
+
.lookup("main"),
|
|
98
|
+
).resolves.toEqual({
|
|
99
|
+
version: 1,
|
|
100
|
+
lastPublishedAt: 1800,
|
|
101
|
+
lastPublishedEventId: "event-1",
|
|
102
|
+
lastPublishResults: { "wss://relay.example": "ok" },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Nostr API module exposes the plugin public contract.
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { PluginDoctorStateMigration } from "actagent/plugin-sdk/runtime-doctor";
|
|
6
|
+
|
|
7
|
+
type NostrBusState = {
|
|
8
|
+
version: 2;
|
|
9
|
+
lastProcessedAt: number | null;
|
|
10
|
+
gatewayStartedAt: number | null;
|
|
11
|
+
recentEventIds: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type NostrProfileState = {
|
|
15
|
+
version: 1;
|
|
16
|
+
lastPublishedAt: number | null;
|
|
17
|
+
lastPublishedEventId: string | null;
|
|
18
|
+
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const BUS_STATE_NAMESPACE = "bus-state";
|
|
22
|
+
const PROFILE_STATE_NAMESPACE = "profile-state";
|
|
23
|
+
const MAX_NOSTR_STATE_ENTRIES = 256;
|
|
24
|
+
|
|
25
|
+
function normalizeAccountId(accountId?: string): string {
|
|
26
|
+
const trimmed = accountId?.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
return "default";
|
|
29
|
+
}
|
|
30
|
+
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finiteNumberOrNull(value: unknown): number | null {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseBusState(value: unknown): NostrBusState | null {
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parsed = value as Record<string, unknown>;
|
|
42
|
+
if (parsed.version !== 1 && parsed.version !== 2) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
version: 2,
|
|
47
|
+
lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt),
|
|
48
|
+
gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt),
|
|
49
|
+
recentEventIds:
|
|
50
|
+
parsed.version === 2 && Array.isArray(parsed.recentEventIds)
|
|
51
|
+
? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string")
|
|
52
|
+
: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseProfileState(value: unknown): NostrProfileState | null {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const parsed = value as Record<string, unknown>;
|
|
61
|
+
if (parsed.version !== 1) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const rawResults = parsed.lastPublishResults;
|
|
65
|
+
const lastPublishResults: Record<string, "ok" | "failed" | "timeout"> = {};
|
|
66
|
+
if (rawResults && typeof rawResults === "object" && !Array.isArray(rawResults)) {
|
|
67
|
+
for (const [relay, result] of Object.entries(rawResults)) {
|
|
68
|
+
if (result === "ok" || result === "failed" || result === "timeout") {
|
|
69
|
+
lastPublishResults[relay] = result;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
version: 1,
|
|
75
|
+
lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt),
|
|
76
|
+
lastPublishedEventId:
|
|
77
|
+
typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
|
|
78
|
+
lastPublishResults:
|
|
79
|
+
rawResults === null || Object.keys(lastPublishResults).length === 0
|
|
80
|
+
? null
|
|
81
|
+
: lastPublishResults,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
86
|
+
try {
|
|
87
|
+
const stat = await fs.stat(filePath);
|
|
88
|
+
return stat.isFile();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readJsonFile(filePath: string): Promise<unknown> {
|
|
95
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listLegacyFiles(params: {
|
|
99
|
+
stateDir: string;
|
|
100
|
+
prefix: string;
|
|
101
|
+
parse: (value: unknown) => unknown;
|
|
102
|
+
}): Promise<Array<{ accountId: string; filePath: string; value: unknown }>> {
|
|
103
|
+
const dir = path.join(params.stateDir, "nostr");
|
|
104
|
+
let entries: Dirent[];
|
|
105
|
+
try {
|
|
106
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const suffix = ".json";
|
|
111
|
+
const files: Array<{ accountId: string; filePath: string; value: unknown }> = [];
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (!entry.isFile() || !entry.name.startsWith(params.prefix) || !entry.name.endsWith(suffix)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const rawAccountId = entry.name.slice(params.prefix.length, -suffix.length);
|
|
117
|
+
const accountId = normalizeAccountId(rawAccountId);
|
|
118
|
+
const filePath = path.join(dir, entry.name);
|
|
119
|
+
try {
|
|
120
|
+
const value = params.parse(await readJsonFile(filePath));
|
|
121
|
+
if (value) {
|
|
122
|
+
files.push({ accountId, filePath, value });
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Malformed legacy cache/cursor files are ignored by migration.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return files;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function archiveLegacySource(params: {
|
|
132
|
+
filePath: string;
|
|
133
|
+
label: string;
|
|
134
|
+
changes: string[];
|
|
135
|
+
warnings: string[];
|
|
136
|
+
}): Promise<void> {
|
|
137
|
+
const archivedPath = `${params.filePath}.migrated`;
|
|
138
|
+
if (await fileExists(archivedPath)) {
|
|
139
|
+
params.warnings.push(
|
|
140
|
+
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await fs.rename(params.filePath, archivedPath);
|
|
146
|
+
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function ensureStoreCapacity(params: {
|
|
153
|
+
files: Array<{ accountId: string }>;
|
|
154
|
+
store: { entries: () => Promise<Array<{ key: string; value: unknown }>> };
|
|
155
|
+
maxEntries: number;
|
|
156
|
+
label: string;
|
|
157
|
+
warnings: string[];
|
|
158
|
+
}): Promise<Set<string> | null> {
|
|
159
|
+
const existingKeys = new Set((await params.store.entries()).map((entry) => entry.key));
|
|
160
|
+
const missingKeys = new Set(
|
|
161
|
+
params.files.map((file) => file.accountId).filter((key) => !existingKeys.has(key)),
|
|
162
|
+
);
|
|
163
|
+
if (missingKeys.size > params.maxEntries - existingKeys.size) {
|
|
164
|
+
params.warnings.push(
|
|
165
|
+
`Skipped migrating ${params.label} because plugin state has room for ${params.maxEntries - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
|
|
166
|
+
);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return existingKeys;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const stateMigrations: PluginDoctorStateMigration[] = [
|
|
173
|
+
{
|
|
174
|
+
id: "nostr-bus-state-json-to-plugin-state",
|
|
175
|
+
label: "Nostr bus state",
|
|
176
|
+
async detectLegacyState(params) {
|
|
177
|
+
const files = await listLegacyFiles({
|
|
178
|
+
stateDir: params.stateDir,
|
|
179
|
+
prefix: "bus-state-",
|
|
180
|
+
parse: parseBusState,
|
|
181
|
+
});
|
|
182
|
+
if (files.length === 0) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
preview: [
|
|
187
|
+
`- Nostr bus state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${BUS_STATE_NAMESPACE})`,
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
async migrateLegacyState(params) {
|
|
192
|
+
const changes: string[] = [];
|
|
193
|
+
const warnings: string[] = [];
|
|
194
|
+
const files = await listLegacyFiles({
|
|
195
|
+
stateDir: params.stateDir,
|
|
196
|
+
prefix: "bus-state-",
|
|
197
|
+
parse: parseBusState,
|
|
198
|
+
});
|
|
199
|
+
const store = params.context.openPluginStateKeyedStore<NostrBusState>({
|
|
200
|
+
namespace: BUS_STATE_NAMESPACE,
|
|
201
|
+
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
|
202
|
+
});
|
|
203
|
+
const existingKeys = await ensureStoreCapacity({
|
|
204
|
+
files,
|
|
205
|
+
store,
|
|
206
|
+
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
|
207
|
+
label: "Nostr bus state",
|
|
208
|
+
warnings,
|
|
209
|
+
});
|
|
210
|
+
if (!existingKeys) {
|
|
211
|
+
return { changes, warnings };
|
|
212
|
+
}
|
|
213
|
+
let imported = 0;
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
if (!existingKeys.has(file.accountId)) {
|
|
216
|
+
await store.register(file.accountId, file.value as NostrBusState);
|
|
217
|
+
existingKeys.add(file.accountId);
|
|
218
|
+
imported++;
|
|
219
|
+
}
|
|
220
|
+
await archiveLegacySource({
|
|
221
|
+
filePath: file.filePath,
|
|
222
|
+
label: "Nostr bus state",
|
|
223
|
+
changes,
|
|
224
|
+
warnings,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (imported > 0) {
|
|
228
|
+
changes.unshift(
|
|
229
|
+
`Migrated ${imported} Nostr bus-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return { changes, warnings };
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "nostr-profile-state-json-to-plugin-state",
|
|
237
|
+
label: "Nostr profile state",
|
|
238
|
+
async detectLegacyState(params) {
|
|
239
|
+
const files = await listLegacyFiles({
|
|
240
|
+
stateDir: params.stateDir,
|
|
241
|
+
prefix: "profile-state-",
|
|
242
|
+
parse: parseProfileState,
|
|
243
|
+
});
|
|
244
|
+
if (files.length === 0) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
preview: [
|
|
249
|
+
`- Nostr profile state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${PROFILE_STATE_NAMESPACE})`,
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
async migrateLegacyState(params) {
|
|
254
|
+
const changes: string[] = [];
|
|
255
|
+
const warnings: string[] = [];
|
|
256
|
+
const files = await listLegacyFiles({
|
|
257
|
+
stateDir: params.stateDir,
|
|
258
|
+
prefix: "profile-state-",
|
|
259
|
+
parse: parseProfileState,
|
|
260
|
+
});
|
|
261
|
+
const store = params.context.openPluginStateKeyedStore<NostrProfileState>({
|
|
262
|
+
namespace: PROFILE_STATE_NAMESPACE,
|
|
263
|
+
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
|
264
|
+
});
|
|
265
|
+
const existingKeys = await ensureStoreCapacity({
|
|
266
|
+
files,
|
|
267
|
+
store,
|
|
268
|
+
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
|
269
|
+
label: "Nostr profile state",
|
|
270
|
+
warnings,
|
|
271
|
+
});
|
|
272
|
+
if (!existingKeys) {
|
|
273
|
+
return { changes, warnings };
|
|
274
|
+
}
|
|
275
|
+
let imported = 0;
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
if (!existingKeys.has(file.accountId)) {
|
|
278
|
+
await store.register(file.accountId, file.value as NostrProfileState);
|
|
279
|
+
existingKeys.add(file.accountId);
|
|
280
|
+
imported++;
|
|
281
|
+
}
|
|
282
|
+
await archiveLegacySource({
|
|
283
|
+
filePath: file.filePath,
|
|
284
|
+
label: "Nostr profile state",
|
|
285
|
+
changes,
|
|
286
|
+
warnings,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (imported > 0) {
|
|
290
|
+
changes.unshift(
|
|
291
|
+
`Migrated ${imported} Nostr profile-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return { changes, warnings };
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
];
|
package/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Nostr plugin entrypoint registers its ACTAgent integration.
|
|
2
|
+
import {
|
|
3
|
+
defineBundledChannelEntry,
|
|
4
|
+
loadBundledEntryExportSync,
|
|
5
|
+
} from "actagent/plugin-sdk/channel-entry-contract";
|
|
6
|
+
import type { ACTAgentConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js";
|
|
7
|
+
|
|
8
|
+
function createNostrProfileHttpHandler() {
|
|
9
|
+
return loadBundledEntryExportSync<
|
|
10
|
+
(params: Record<string, unknown>) => (ctx: unknown) => Promise<void> | void
|
|
11
|
+
>(import.meta.url, {
|
|
12
|
+
specifier: "./api.js",
|
|
13
|
+
exportName: "createNostrProfileHttpHandler",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getNostrRuntime() {
|
|
18
|
+
return loadBundledEntryExportSync<() => PluginRuntime>(import.meta.url, {
|
|
19
|
+
specifier: "./api.js",
|
|
20
|
+
exportName: "getNostrRuntime",
|
|
21
|
+
})();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveNostrAccount(params: { cfg: unknown; accountId: string }) {
|
|
25
|
+
return loadBundledEntryExportSync<
|
|
26
|
+
(params: { cfg: unknown; accountId: string }) => ResolvedNostrAccount
|
|
27
|
+
>(import.meta.url, {
|
|
28
|
+
specifier: "./api.js",
|
|
29
|
+
exportName: "resolveNostrAccount",
|
|
30
|
+
})(params);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default defineBundledChannelEntry({
|
|
34
|
+
id: "nostr",
|
|
35
|
+
name: "Nostr",
|
|
36
|
+
description: "Nostr DM channel plugin via NIP-04",
|
|
37
|
+
importMetaUrl: import.meta.url,
|
|
38
|
+
plugin: {
|
|
39
|
+
specifier: "./channel-plugin-api.js",
|
|
40
|
+
exportName: "nostrPlugin",
|
|
41
|
+
},
|
|
42
|
+
runtime: {
|
|
43
|
+
specifier: "./api.js",
|
|
44
|
+
exportName: "setNostrRuntime",
|
|
45
|
+
},
|
|
46
|
+
registerFull(api) {
|
|
47
|
+
const httpHandler = createNostrProfileHttpHandler()({
|
|
48
|
+
getConfigProfile: (accountId: string) => {
|
|
49
|
+
const runtime = getNostrRuntime();
|
|
50
|
+
const cfg = runtime.config.current() as ACTAgentConfig;
|
|
51
|
+
const account = resolveNostrAccount({ cfg, accountId });
|
|
52
|
+
return account.profile;
|
|
53
|
+
},
|
|
54
|
+
updateConfigProfile: async (_accountId: string, profile: unknown) => {
|
|
55
|
+
const runtime = getNostrRuntime();
|
|
56
|
+
|
|
57
|
+
await runtime.config.mutateConfigFile({
|
|
58
|
+
afterWrite: { mode: "auto" },
|
|
59
|
+
mutate: (draft) => {
|
|
60
|
+
const channels = (draft.channels ?? {}) as Record<string, unknown>;
|
|
61
|
+
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
|
|
62
|
+
|
|
63
|
+
draft.channels = {
|
|
64
|
+
...channels,
|
|
65
|
+
nostr: {
|
|
66
|
+
...nostrConfig,
|
|
67
|
+
profile,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
getAccountInfo: (accountId: string) => {
|
|
74
|
+
const runtime = getNostrRuntime();
|
|
75
|
+
const cfg = runtime.config.current() as ACTAgentConfig;
|
|
76
|
+
const account = resolveNostrAccount({ cfg, accountId });
|
|
77
|
+
if (!account.configured || !account.publicKey) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
pubkey: account.publicKey,
|
|
82
|
+
relays: account.relays,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
log: api.logger,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
api.registerHttpRoute({
|
|
89
|
+
path: "/api/channels/nostr",
|
|
90
|
+
auth: "gateway",
|
|
91
|
+
match: "prefix",
|
|
92
|
+
gatewayRuntimeScopeSurface: "trusted-operator",
|
|
93
|
+
handler: httpHandler,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
});
|