@gakr-gakr/nostr 0.1.0
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/api.ts +10 -0
- package/autobot.plugin.json +15 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +95 -0
- package/package.json +68 -0
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +11 -0
- package/src/channel.setup.ts +234 -0
- package/src/channel.ts +215 -0
- package/src/config-schema.ts +98 -0
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +321 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +458 -0
- package/src/nostr-bus.ts +789 -0
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.ts +583 -0
- package/src/nostr-profile-import.ts +262 -0
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.ts +144 -0
- package/src/nostr-state-store.ts +206 -0
- package/src/runtime.ts +9 -0
- package/src/seen-tracker.ts +289 -0
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +267 -0
- package/src/types.ts +117 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr Profile Import
|
|
3
|
+
*
|
|
4
|
+
* Fetches and verifies kind:0 profile events from relays.
|
|
5
|
+
* Used to import existing profiles before editing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SimplePool, verifyEvent, type Event } from "nostr-tools";
|
|
9
|
+
import type { NostrProfile } from "./config-schema.js";
|
|
10
|
+
import { validateUrlSafety } from "./nostr-profile-url-safety.js";
|
|
11
|
+
import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
interface ProfileImportResult {
|
|
18
|
+
/** Whether the import was successful */
|
|
19
|
+
ok: boolean;
|
|
20
|
+
/** The imported profile (if found and valid) */
|
|
21
|
+
profile?: NostrProfile;
|
|
22
|
+
/** The raw event (for advanced users) */
|
|
23
|
+
event?: {
|
|
24
|
+
id: string;
|
|
25
|
+
pubkey: string;
|
|
26
|
+
created_at: number;
|
|
27
|
+
};
|
|
28
|
+
/** Error message if import failed */
|
|
29
|
+
error?: string;
|
|
30
|
+
/** Which relays responded */
|
|
31
|
+
relaysQueried: string[];
|
|
32
|
+
/** Which relay provided the winning event */
|
|
33
|
+
sourceRelay?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ProfileImportOptions {
|
|
37
|
+
/** The public key to fetch profile for */
|
|
38
|
+
pubkey: string;
|
|
39
|
+
/** Relay URLs to query */
|
|
40
|
+
relays: string[];
|
|
41
|
+
/** Timeout per relay in milliseconds (default: 5000) */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Constants
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Profile Import
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sanitize URLs in an imported profile to prevent SSRF attacks.
|
|
57
|
+
* Removes any URLs that don't pass SSRF validation.
|
|
58
|
+
*/
|
|
59
|
+
function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
|
|
60
|
+
const result = { ...profile };
|
|
61
|
+
const urlFields = ["picture", "banner", "website"] as const;
|
|
62
|
+
|
|
63
|
+
for (const field of urlFields) {
|
|
64
|
+
const value = result[field];
|
|
65
|
+
if (value && typeof value === "string") {
|
|
66
|
+
const validation = validateUrlSafety(value);
|
|
67
|
+
if (!validation.ok) {
|
|
68
|
+
// Remove unsafe URL
|
|
69
|
+
delete result[field];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetch the latest kind:0 profile event for a pubkey from relays.
|
|
79
|
+
*
|
|
80
|
+
* - Queries all relays in parallel
|
|
81
|
+
* - Takes the event with the highest created_at
|
|
82
|
+
* - Verifies the event signature
|
|
83
|
+
* - Parses and returns the profile
|
|
84
|
+
*/
|
|
85
|
+
export async function importProfileFromRelays(
|
|
86
|
+
opts: ProfileImportOptions,
|
|
87
|
+
): Promise<ProfileImportResult> {
|
|
88
|
+
const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
|
89
|
+
|
|
90
|
+
if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "Invalid pubkey format (must be 64 hex characters)",
|
|
94
|
+
relaysQueried: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (relays.length === 0) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: "No relays configured",
|
|
102
|
+
relaysQueried: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const pool = new SimplePool();
|
|
107
|
+
const relaysQueried: string[] = [];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Query all relays for kind:0 events from this pubkey
|
|
111
|
+
const events: Array<{ event: Event; relay: string }> = [];
|
|
112
|
+
|
|
113
|
+
// Create timeout promise
|
|
114
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
115
|
+
setTimeout(resolve, timeoutMs);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Create subscription promise
|
|
119
|
+
const subscriptionPromise = new Promise<void>((resolve) => {
|
|
120
|
+
let completed = 0;
|
|
121
|
+
|
|
122
|
+
for (const relay of relays) {
|
|
123
|
+
relaysQueried.push(relay);
|
|
124
|
+
|
|
125
|
+
const sub = pool.subscribeMany(
|
|
126
|
+
[relay],
|
|
127
|
+
[
|
|
128
|
+
{
|
|
129
|
+
kinds: [0],
|
|
130
|
+
authors: [pubkey],
|
|
131
|
+
limit: 1,
|
|
132
|
+
},
|
|
133
|
+
] as unknown as Parameters<typeof pool.subscribeMany>[1],
|
|
134
|
+
{
|
|
135
|
+
onevent(event) {
|
|
136
|
+
events.push({ event, relay });
|
|
137
|
+
},
|
|
138
|
+
oneose() {
|
|
139
|
+
completed++;
|
|
140
|
+
if (completed >= relays.length) {
|
|
141
|
+
resolve();
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
onclose() {
|
|
145
|
+
completed++;
|
|
146
|
+
if (completed >= relays.length) {
|
|
147
|
+
resolve();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Clean up subscription after timeout
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
sub.close();
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Wait for either all relays to respond or timeout
|
|
161
|
+
await Promise.race([subscriptionPromise, timeoutPromise]);
|
|
162
|
+
|
|
163
|
+
// No events found
|
|
164
|
+
if (events.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
error: "No profile found on any relay",
|
|
168
|
+
relaysQueried,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Find the event with the highest created_at (newest wins for replaceable events)
|
|
173
|
+
let bestEvent: { event: Event; relay: string } | null = null;
|
|
174
|
+
for (const item of events) {
|
|
175
|
+
if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
|
|
176
|
+
bestEvent = item;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!bestEvent) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error: "No valid profile event found",
|
|
184
|
+
relaysQueried,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Verify the event signature
|
|
189
|
+
const isValid = verifyEvent(bestEvent.event);
|
|
190
|
+
if (!isValid) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: "Profile event has invalid signature",
|
|
194
|
+
relaysQueried,
|
|
195
|
+
sourceRelay: bestEvent.relay,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Parse the profile content
|
|
200
|
+
let content: ProfileContent;
|
|
201
|
+
try {
|
|
202
|
+
content = JSON.parse(bestEvent.event.content) as ProfileContent;
|
|
203
|
+
} catch {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "Profile event has invalid JSON content",
|
|
207
|
+
relaysQueried,
|
|
208
|
+
sourceRelay: bestEvent.relay,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Convert to our profile format
|
|
213
|
+
const profile = contentToProfile(content);
|
|
214
|
+
|
|
215
|
+
// Sanitize URLs from imported profile to prevent SSRF when auto-merging
|
|
216
|
+
const sanitizedProfile = sanitizeProfileUrls(profile);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
profile: sanitizedProfile,
|
|
221
|
+
event: {
|
|
222
|
+
id: bestEvent.event.id,
|
|
223
|
+
pubkey: bestEvent.event.pubkey,
|
|
224
|
+
created_at: bestEvent.event.created_at,
|
|
225
|
+
},
|
|
226
|
+
relaysQueried,
|
|
227
|
+
sourceRelay: bestEvent.relay,
|
|
228
|
+
};
|
|
229
|
+
} finally {
|
|
230
|
+
pool.close(relays);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Merge imported profile with local profile.
|
|
236
|
+
*
|
|
237
|
+
* Strategy:
|
|
238
|
+
* - For each field, prefer local if set, otherwise use imported
|
|
239
|
+
* - This preserves user customizations while filling in missing data
|
|
240
|
+
*/
|
|
241
|
+
export function mergeProfiles(
|
|
242
|
+
local: NostrProfile | undefined,
|
|
243
|
+
imported: NostrProfile | undefined,
|
|
244
|
+
): NostrProfile {
|
|
245
|
+
if (!imported) {
|
|
246
|
+
return local ?? {};
|
|
247
|
+
}
|
|
248
|
+
if (!local) {
|
|
249
|
+
return imported;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
name: local.name ?? imported.name,
|
|
254
|
+
displayName: local.displayName ?? imported.displayName,
|
|
255
|
+
about: local.about ?? imported.about,
|
|
256
|
+
picture: local.picture ?? imported.picture,
|
|
257
|
+
banner: local.banner ?? imported.banner,
|
|
258
|
+
website: local.website ?? imported.website,
|
|
259
|
+
nip05: local.nip05 ?? imported.nip05,
|
|
260
|
+
lud16: local.lud16 ?? imported.lud16,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isBlockedHostnameOrIp } from "autobot/plugin-sdk/ssrf-runtime";
|
|
2
|
+
|
|
3
|
+
export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(urlStr);
|
|
6
|
+
|
|
7
|
+
if (url.protocol !== "https:") {
|
|
8
|
+
return { ok: false, error: "URL must use https:// protocol" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const hostname = url.hostname.trim().toLowerCase();
|
|
12
|
+
|
|
13
|
+
if (isBlockedHostnameOrIp(hostname)) {
|
|
14
|
+
return { ok: false, error: "URL must not point to private/internal addresses" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { ok: true };
|
|
18
|
+
} catch {
|
|
19
|
+
return { ok: false, error: "Invalid URL format" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr Profile Management (NIP-01 kind:0)
|
|
3
|
+
*
|
|
4
|
+
* Profile events are "replaceable" - the latest created_at wins.
|
|
5
|
+
* This module handles profile event creation and publishing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
|
|
9
|
+
import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
|
|
10
|
+
import type { NostrProfile } from "./config-schema.js";
|
|
11
|
+
import { profileToContent } from "./nostr-profile-core.js";
|
|
12
|
+
export {
|
|
13
|
+
contentToProfile,
|
|
14
|
+
profileToContent,
|
|
15
|
+
sanitizeProfileForDisplay,
|
|
16
|
+
validateProfile,
|
|
17
|
+
type ProfileContent,
|
|
18
|
+
} from "./nostr-profile-core.js";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Result of a profile publish attempt */
|
|
25
|
+
export interface ProfilePublishResult {
|
|
26
|
+
/** Event ID of the published profile */
|
|
27
|
+
eventId: string;
|
|
28
|
+
/** Relays that successfully received the event */
|
|
29
|
+
successes: string[];
|
|
30
|
+
/** Relays that failed with their error messages */
|
|
31
|
+
failures: Array<{ relay: string; error: string }>;
|
|
32
|
+
/** Unix timestamp when the event was created */
|
|
33
|
+
createdAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Event Creation
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a signed kind:0 profile event.
|
|
42
|
+
*
|
|
43
|
+
* @param sk - Private key as Uint8Array (32 bytes)
|
|
44
|
+
* @param profile - Profile data to include
|
|
45
|
+
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
|
|
46
|
+
* @returns Signed Nostr event
|
|
47
|
+
*/
|
|
48
|
+
export function createProfileEvent(
|
|
49
|
+
sk: Uint8Array,
|
|
50
|
+
profile: NostrProfile,
|
|
51
|
+
lastPublishedAt?: number,
|
|
52
|
+
): Event {
|
|
53
|
+
const content = profileToContent(profile);
|
|
54
|
+
const contentJson = JSON.stringify(content);
|
|
55
|
+
|
|
56
|
+
// Ensure monotonic timestamp (new event > previous)
|
|
57
|
+
const now = Math.floor(Date.now() / 1000);
|
|
58
|
+
const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
|
|
59
|
+
|
|
60
|
+
const event = finalizeEvent(
|
|
61
|
+
{
|
|
62
|
+
kind: 0,
|
|
63
|
+
content: contentJson,
|
|
64
|
+
tags: [],
|
|
65
|
+
created_at: createdAt,
|
|
66
|
+
},
|
|
67
|
+
sk,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return event;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Profile Publishing
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/** Per-relay publish timeout (ms) */
|
|
78
|
+
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Publish a profile event to multiple relays.
|
|
82
|
+
*
|
|
83
|
+
* Best-effort: publishes to all relays in parallel, reports per-relay results.
|
|
84
|
+
* Does NOT retry automatically - caller should handle retries if needed.
|
|
85
|
+
*
|
|
86
|
+
* @param pool - SimplePool instance for relay connections
|
|
87
|
+
* @param relays - Array of relay WebSocket URLs
|
|
88
|
+
* @param event - Signed profile event (kind:0)
|
|
89
|
+
* @returns Publish results with successes and failures
|
|
90
|
+
*/
|
|
91
|
+
async function publishProfileEvent(
|
|
92
|
+
pool: SimplePool,
|
|
93
|
+
relays: string[],
|
|
94
|
+
event: Event,
|
|
95
|
+
): Promise<ProfilePublishResult> {
|
|
96
|
+
const successes: string[] = [];
|
|
97
|
+
const failures: Array<{ relay: string; error: string }> = [];
|
|
98
|
+
|
|
99
|
+
// Publish to each relay in parallel with timeout
|
|
100
|
+
const publishPromises = relays.map(async (relay) => {
|
|
101
|
+
try {
|
|
102
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
103
|
+
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await Promise.race([...pool.publish([relay], event), timeoutPromise]);
|
|
107
|
+
|
|
108
|
+
successes.push(relay);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const errorMessage = formatErrorMessage(err);
|
|
111
|
+
failures.push({ relay, error: errorMessage });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await Promise.all(publishPromises);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
eventId: event.id,
|
|
119
|
+
successes,
|
|
120
|
+
failures,
|
|
121
|
+
createdAt: event.created_at,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create and publish a profile event in one call.
|
|
127
|
+
*
|
|
128
|
+
* @param pool - SimplePool instance
|
|
129
|
+
* @param sk - Private key as Uint8Array
|
|
130
|
+
* @param relays - Array of relay URLs
|
|
131
|
+
* @param profile - Profile data
|
|
132
|
+
* @param lastPublishedAt - Previous timestamp for monotonic ordering
|
|
133
|
+
* @returns Publish results
|
|
134
|
+
*/
|
|
135
|
+
export async function publishProfile(
|
|
136
|
+
pool: SimplePool,
|
|
137
|
+
sk: Uint8Array,
|
|
138
|
+
relays: string[],
|
|
139
|
+
profile: NostrProfile,
|
|
140
|
+
lastPublishedAt?: number,
|
|
141
|
+
): Promise<ProfilePublishResult> {
|
|
142
|
+
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
|
143
|
+
return publishProfileEvent(pool, relays, event);
|
|
144
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { safeParseJsonWithSchema } from "autobot/plugin-sdk/extension-shared";
|
|
4
|
+
import { privateFileStore } from "autobot/plugin-sdk/security-runtime";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { getNostrRuntime } from "./runtime.js";
|
|
7
|
+
|
|
8
|
+
const STORE_VERSION = 2;
|
|
9
|
+
const PROFILE_STATE_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
type NostrBusState = {
|
|
12
|
+
version: 2;
|
|
13
|
+
/** Unix timestamp (seconds) of the last processed event */
|
|
14
|
+
lastProcessedAt: number | null;
|
|
15
|
+
/** Gateway startup timestamp (seconds) - events before this are old */
|
|
16
|
+
gatewayStartedAt: number | null;
|
|
17
|
+
/** Recent processed event IDs for overlap dedupe across restarts */
|
|
18
|
+
recentEventIds: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Profile publish state (separate from bus state) */
|
|
22
|
+
type NostrProfileState = {
|
|
23
|
+
version: 1;
|
|
24
|
+
/** Unix timestamp (seconds) of last successful profile publish */
|
|
25
|
+
lastPublishedAt: number | null;
|
|
26
|
+
/** Event ID of the last published profile */
|
|
27
|
+
lastPublishedEventId: string | null;
|
|
28
|
+
/** Per-relay publish results from last attempt */
|
|
29
|
+
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null);
|
|
33
|
+
const NostrBusStateV1Schema = z.object({
|
|
34
|
+
version: z.literal(1),
|
|
35
|
+
lastProcessedAt: NullableFiniteNumberSchema,
|
|
36
|
+
gatewayStartedAt: NullableFiniteNumberSchema,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const NostrBusStateSchema = z.object({
|
|
40
|
+
version: z.literal(2),
|
|
41
|
+
lastProcessedAt: NullableFiniteNumberSchema,
|
|
42
|
+
gatewayStartedAt: NullableFiniteNumberSchema,
|
|
43
|
+
recentEventIds: z
|
|
44
|
+
.array(z.unknown())
|
|
45
|
+
.catch([])
|
|
46
|
+
.transform((ids) => ids.filter((id): id is string => typeof id === "string")),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const NostrProfileStateSchema = z.object({
|
|
50
|
+
version: z.literal(1),
|
|
51
|
+
lastPublishedAt: NullableFiniteNumberSchema,
|
|
52
|
+
lastPublishedEventId: z.string().nullable().catch(null),
|
|
53
|
+
lastPublishResults: z
|
|
54
|
+
.record(z.string(), z.enum(["ok", "failed", "timeout"]))
|
|
55
|
+
.nullable()
|
|
56
|
+
.catch(null),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function normalizeAccountId(accountId?: string): string {
|
|
60
|
+
const trimmed = accountId?.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return "default";
|
|
63
|
+
}
|
|
64
|
+
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveNostrStatePath(accountId?: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
68
|
+
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
|
69
|
+
const normalized = normalizeAccountId(accountId);
|
|
70
|
+
return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveNostrProfileStatePath(
|
|
74
|
+
accountId?: string,
|
|
75
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
76
|
+
): string {
|
|
77
|
+
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
|
78
|
+
const normalized = normalizeAccountId(accountId);
|
|
79
|
+
return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeParseState(raw: string): NostrBusState | null {
|
|
83
|
+
const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw);
|
|
84
|
+
if (parsedV2) {
|
|
85
|
+
return parsedV2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw);
|
|
89
|
+
if (!parsedV1) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Back-compat: v1 state files
|
|
94
|
+
return {
|
|
95
|
+
version: 2,
|
|
96
|
+
lastProcessedAt: parsedV1.lastProcessedAt,
|
|
97
|
+
gatewayStartedAt: parsedV1.gatewayStartedAt,
|
|
98
|
+
recentEventIds: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readNostrBusState(params: {
|
|
103
|
+
accountId?: string;
|
|
104
|
+
env?: NodeJS.ProcessEnv;
|
|
105
|
+
}): Promise<NostrBusState | null> {
|
|
106
|
+
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
|
107
|
+
try {
|
|
108
|
+
const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
|
|
109
|
+
path.basename(filePath),
|
|
110
|
+
);
|
|
111
|
+
if (raw === null) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return safeParseState(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function writeNostrBusState(params: {
|
|
121
|
+
accountId?: string;
|
|
122
|
+
lastProcessedAt: number;
|
|
123
|
+
gatewayStartedAt: number;
|
|
124
|
+
recentEventIds?: string[];
|
|
125
|
+
env?: NodeJS.ProcessEnv;
|
|
126
|
+
}): Promise<void> {
|
|
127
|
+
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
|
128
|
+
const payload: NostrBusState = {
|
|
129
|
+
version: STORE_VERSION,
|
|
130
|
+
lastProcessedAt: params.lastProcessedAt,
|
|
131
|
+
gatewayStartedAt: params.gatewayStartedAt,
|
|
132
|
+
recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
|
|
133
|
+
};
|
|
134
|
+
await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
|
|
135
|
+
trailingNewline: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Determine the `since` timestamp for subscription.
|
|
141
|
+
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
|
|
142
|
+
* falling back to `now` for fresh starts.
|
|
143
|
+
*/
|
|
144
|
+
export function computeSinceTimestamp(
|
|
145
|
+
state: NostrBusState | null,
|
|
146
|
+
nowSec: number = Math.floor(Date.now() / 1000),
|
|
147
|
+
): number {
|
|
148
|
+
if (!state) {
|
|
149
|
+
return nowSec;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Use the most recent timestamp we have
|
|
153
|
+
const candidates = [state.lastProcessedAt, state.gatewayStartedAt].filter(
|
|
154
|
+
(t): t is number => t !== null && t > 0,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (candidates.length === 0) {
|
|
158
|
+
return nowSec;
|
|
159
|
+
}
|
|
160
|
+
return Math.max(...candidates);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Profile State Management
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
function safeParseProfileState(raw: string): NostrProfileState | null {
|
|
168
|
+
return safeParseJsonWithSchema(NostrProfileStateSchema, raw);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function readNostrProfileState(params: {
|
|
172
|
+
accountId?: string;
|
|
173
|
+
env?: NodeJS.ProcessEnv;
|
|
174
|
+
}): Promise<NostrProfileState | null> {
|
|
175
|
+
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
|
176
|
+
try {
|
|
177
|
+
const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
|
|
178
|
+
path.basename(filePath),
|
|
179
|
+
);
|
|
180
|
+
if (raw === null) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return safeParseProfileState(raw);
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function writeNostrProfileState(params: {
|
|
190
|
+
accountId?: string;
|
|
191
|
+
lastPublishedAt: number;
|
|
192
|
+
lastPublishedEventId: string;
|
|
193
|
+
lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
|
|
194
|
+
env?: NodeJS.ProcessEnv;
|
|
195
|
+
}): Promise<void> {
|
|
196
|
+
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
|
197
|
+
const payload: NostrProfileState = {
|
|
198
|
+
version: PROFILE_STATE_VERSION,
|
|
199
|
+
lastPublishedAt: params.lastPublishedAt,
|
|
200
|
+
lastPublishedEventId: params.lastPublishedEventId,
|
|
201
|
+
lastPublishResults: params.lastPublishResults,
|
|
202
|
+
};
|
|
203
|
+
await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
|
|
204
|
+
trailingNewline: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PluginRuntime } from "autobot/plugin-sdk/core";
|
|
2
|
+
import { createPluginRuntimeStore } from "autobot/plugin-sdk/runtime-store";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>({
|
|
6
|
+
pluginId: "nostr",
|
|
7
|
+
errorMessage: "Nostr runtime not initialized",
|
|
8
|
+
});
|
|
9
|
+
export { getNostrRuntime, setNostrRuntime };
|