mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 13:24:58 +00:00
feat: add Nostr channel plugin and onboarding install defaults
Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
This commit is contained in:
242
extensions/nostr/src/nostr-profile.ts
Normal file
242
extensions/nostr/src/nostr-profile.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Nostr Profile Management (NIP-01 kind:0)
|
||||
*
|
||||
* Profile events are "replaceable" - the latest created_at wins.
|
||||
* This module handles profile event creation and publishing.
|
||||
*/
|
||||
|
||||
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
|
||||
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Result of a profile publish attempt */
|
||||
export interface ProfilePublishResult {
|
||||
/** Event ID of the published profile */
|
||||
eventId: string;
|
||||
/** Relays that successfully received the event */
|
||||
successes: string[];
|
||||
/** Relays that failed with their error messages */
|
||||
failures: Array<{ relay: string; error: string }>;
|
||||
/** Unix timestamp when the event was created */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** NIP-01 profile content (JSON inside kind:0 event) */
|
||||
export interface ProfileContent {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
website?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Content Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert our config profile schema to NIP-01 content format.
|
||||
* Strips undefined fields and validates URLs.
|
||||
*/
|
||||
export function profileToContent(profile: NostrProfile): ProfileContent {
|
||||
const validated = NostrProfileSchema.parse(profile);
|
||||
|
||||
const content: ProfileContent = {};
|
||||
|
||||
if (validated.name !== undefined) content.name = validated.name;
|
||||
if (validated.displayName !== undefined) content.display_name = validated.displayName;
|
||||
if (validated.about !== undefined) content.about = validated.about;
|
||||
if (validated.picture !== undefined) content.picture = validated.picture;
|
||||
if (validated.banner !== undefined) content.banner = validated.banner;
|
||||
if (validated.website !== undefined) content.website = validated.website;
|
||||
if (validated.nip05 !== undefined) content.nip05 = validated.nip05;
|
||||
if (validated.lud16 !== undefined) content.lud16 = validated.lud16;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NIP-01 content format back to our config profile schema.
|
||||
* Useful for importing existing profiles from relays.
|
||||
*/
|
||||
export function contentToProfile(content: ProfileContent): NostrProfile {
|
||||
const profile: NostrProfile = {};
|
||||
|
||||
if (content.name !== undefined) profile.name = content.name;
|
||||
if (content.display_name !== undefined) profile.displayName = content.display_name;
|
||||
if (content.about !== undefined) profile.about = content.about;
|
||||
if (content.picture !== undefined) profile.picture = content.picture;
|
||||
if (content.banner !== undefined) profile.banner = content.banner;
|
||||
if (content.website !== undefined) profile.website = content.website;
|
||||
if (content.nip05 !== undefined) profile.nip05 = content.nip05;
|
||||
if (content.lud16 !== undefined) profile.lud16 = content.lud16;
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Creation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a signed kind:0 profile event.
|
||||
*
|
||||
* @param sk - Private key as Uint8Array (32 bytes)
|
||||
* @param profile - Profile data to include
|
||||
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
|
||||
* @returns Signed Nostr event
|
||||
*/
|
||||
export function createProfileEvent(
|
||||
sk: Uint8Array,
|
||||
profile: NostrProfile,
|
||||
lastPublishedAt?: number
|
||||
): Event {
|
||||
const content = profileToContent(profile);
|
||||
const contentJson = JSON.stringify(content);
|
||||
|
||||
// Ensure monotonic timestamp (new event > previous)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 0,
|
||||
content: contentJson,
|
||||
tags: [],
|
||||
created_at: createdAt,
|
||||
},
|
||||
sk
|
||||
);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Publishing
|
||||
// ============================================================================
|
||||
|
||||
/** Per-relay publish timeout (ms) */
|
||||
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Publish a profile event to multiple relays.
|
||||
*
|
||||
* Best-effort: publishes to all relays in parallel, reports per-relay results.
|
||||
* Does NOT retry automatically - caller should handle retries if needed.
|
||||
*
|
||||
* @param pool - SimplePool instance for relay connections
|
||||
* @param relays - Array of relay WebSocket URLs
|
||||
* @param event - Signed profile event (kind:0)
|
||||
* @returns Publish results with successes and failures
|
||||
*/
|
||||
export async function publishProfileEvent(
|
||||
pool: SimplePool,
|
||||
relays: string[],
|
||||
event: Event
|
||||
): Promise<ProfilePublishResult> {
|
||||
const successes: string[] = [];
|
||||
const failures: Array<{ relay: string; error: string }> = [];
|
||||
|
||||
// Publish to each relay in parallel with timeout
|
||||
const publishPromises = relays.map(async (relay) => {
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await Promise.race([pool.publish([relay], event), timeoutPromise]);
|
||||
|
||||
successes.push(relay);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
failures.push({ relay, error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(publishPromises);
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
successes,
|
||||
failures,
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and publish a profile event in one call.
|
||||
*
|
||||
* @param pool - SimplePool instance
|
||||
* @param sk - Private key as Uint8Array
|
||||
* @param relays - Array of relay URLs
|
||||
* @param profile - Profile data
|
||||
* @param lastPublishedAt - Previous timestamp for monotonic ordering
|
||||
* @returns Publish results
|
||||
*/
|
||||
export async function publishProfile(
|
||||
pool: SimplePool,
|
||||
sk: Uint8Array,
|
||||
relays: string[],
|
||||
profile: NostrProfile,
|
||||
lastPublishedAt?: number
|
||||
): Promise<ProfilePublishResult> {
|
||||
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
||||
return publishProfileEvent(pool, relays, event);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate a profile without throwing (returns result object).
|
||||
*/
|
||||
export function validateProfile(profile: unknown): {
|
||||
valid: boolean;
|
||||
profile?: NostrProfile;
|
||||
errors?: string[];
|
||||
} {
|
||||
const result = NostrProfileSchema.safeParse(profile);
|
||||
|
||||
if (result.success) {
|
||||
return { valid: true, profile: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize profile text fields to prevent XSS when displaying in UI.
|
||||
* Escapes HTML special characters.
|
||||
*/
|
||||
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
|
||||
const escapeHtml = (str: string | undefined): string | undefined => {
|
||||
if (str === undefined) return undefined;
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
return {
|
||||
name: escapeHtml(profile.name),
|
||||
displayName: escapeHtml(profile.displayName),
|
||||
about: escapeHtml(profile.about),
|
||||
picture: profile.picture, // URLs already validated by schema
|
||||
banner: profile.banner,
|
||||
website: profile.website,
|
||||
nip05: escapeHtml(profile.nip05),
|
||||
lud16: escapeHtml(profile.lud16),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user