diff --git a/src/hooks/useAIChatTools.ts b/src/hooks/useAIChatTools.ts index 2a740105..c48cbb0d 100644 --- a/src/hooks/useAIChatTools.ts +++ b/src/hooks/useAIChatTools.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { generateSecretKey, getPublicKey, finalizeEvent, nip19 } from 'nostr-tools'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -579,6 +579,146 @@ export function useAIChatTools() { } } + case 'publish_events': { + try { + const events = Array.isArray(args.events) + ? args.events as Array<{ kind?: number; content?: string; tags?: string[][] }> + : []; + if (events.length === 0) { + return { result: JSON.stringify({ error: 'At least one event is required.' }) }; + } + + // Use buddy key if available, otherwise ephemeral + const buddySk = getBuddySecretKey(); + const sk = buddySk ?? generateSecretKey(); + const pubkey = getPublicKey(sk); + const currentTimestamp = Math.floor(Date.now() / 1000); + + const finalized: NostrEvent[] = events.map((partial) => + finalizeEvent({ + kind: partial.kind ?? 1, + content: partial.content ?? '', + tags: partial.tags ?? [], + created_at: currentTimestamp, + }, sk) as NostrEvent, + ); + + // If ephemeral (no buddy), publish a fallback profile + if (!buddySk) { + const profileEvent = finalizeEvent({ + kind: 0, + content: JSON.stringify({ name: 'Dork Publisher', about: 'Events published by Dork AI' }), + tags: [], + created_at: currentTimestamp, + }, sk) as NostrEvent; + await nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }); + } + + await Promise.all( + finalized.map((event) => nostr.event(event, { signal: AbortSignal.timeout(5000) })), + ); + + // Return the first event for inline display if it's a kind 1 + const displayEvent = finalized.find((e) => e.kind === 1) ?? finalized[0]; + + return { + result: JSON.stringify({ + success: true, + pubkey, + events_published: finalized.length, + events: finalized.map((e) => ({ + id: e.id, + kind: e.kind, + content: e.content.length > 100 ? `${e.content.slice(0, 100)}...` : e.content, + })), + }), + nostrEvent: displayEvent, + }; + } catch (err) { + return { result: JSON.stringify({ error: `Failed to publish events: ${err instanceof Error ? err.message : 'Unknown error'}` }) }; + } + } + + case 'fetch_event': { + try { + const identifier = typeof args.identifier === 'string' ? args.identifier.trim() : ''; + if (!identifier) { + return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) }; + } + + let decoded: nip19.DecodedResult; + try { + decoded = nip19.decode(identifier); + } catch { + return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) }; + } + + if (decoded.type === 'nsec') { + return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) }; + } + + let event: NostrEvent | undefined; + + switch (decoded.type) { + case 'npub': { + const events = await nostr.query( + [{ kinds: [0], authors: [decoded.data], limit: 1 }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'nprofile': { + const events = await nostr.query( + [{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'note': { + const events = await nostr.query( + [{ ids: [decoded.data] }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'nevent': { + const events = await nostr.query( + [{ ids: [decoded.data.id] }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'naddr': { + const events = await nostr.query( + [{ + kinds: [decoded.data.kind], + authors: [decoded.data.pubkey], + '#d': [decoded.data.identifier], + limit: 1, + }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + default: + return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) }; + } + + if (!event) { + return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) }; + } + + return { result: JSON.stringify(event) }; + } catch (err) { + return { result: JSON.stringify({ error: `Failed to fetch event: ${err instanceof Error ? err.message : 'Unknown error'}` }) }; + } + } + default: return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) }; } diff --git a/src/lib/aiChatSystemPrompt.ts b/src/lib/aiChatSystemPrompt.ts index a883ea08..7c14bc67 100644 --- a/src/lib/aiChatSystemPrompt.ts +++ b/src/lib/aiChatSystemPrompt.ts @@ -117,7 +117,35 @@ Publishes a NIP-30 custom emoji pack (kind 30030) as the logged-in user. Takes a 2. upload_from_url(image_urls) → upload to Blossom, get URLs + shortcodes 3. create_emoji_pack(name, emojis) → publish the pack -When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context.`; +When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context. + +## publish_events +Publishes one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Use this when the user asks you to post, publish, or broadcast something to Nostr. + +**Common kinds:** +- 1 = text note (put post text in content) +- 7 = reaction (content is "+" or an emoji, add an "e" tag referencing the target event) +- 6 = repost (content is the JSON of the reposted event, add an "e" tag) + +**Tag format:** Arrays of strings, e.g. \`[["t", "nostr"], ["p", ""]]\` + +**Examples:** +- Post a note: \`{ events: [{ content: "Hello Nostr!" }] }\` +- Post with hashtags: \`{ events: [{ content: "Building on Nostr", tags: [["t", "nostr"], ["t", "dev"]] }] }\` + +Only publish events when the user explicitly asks you to. Never publish autonomously. + +## fetch_event +Fetches a Nostr event by its NIP-19 identifier. Use this when the user shares a Nostr link or identifier and you need to read its content. + +**Supported identifiers:** +- npub1... → fetches the user's kind 0 profile +- note1... → fetches a specific event by ID +- nevent1... → fetches an event (may include relay hints) +- naddr1... → fetches an addressable event by kind+author+d-tag +- nprofile1... → fetches a user profile with relay hints + +Returns the full event JSON. For profiles (kind 0), the content field contains JSON metadata (name, about, picture, etc.).`; /** The raw default template with {{NAME}} and {{SOUL}} placeholders (for display in settings). */ export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE; diff --git a/src/lib/aiChatTools.ts b/src/lib/aiChatTools.ts index 9c19e1ff..8ce0fd6f 100644 --- a/src/lib/aiChatTools.ts +++ b/src/lib/aiChatTools.ts @@ -304,6 +304,61 @@ After publishing, the emoji pack appears in the user's feed and can be added to }, }, }, + { + type: 'function' as const, + function: { + name: 'publish_events', + description: `Publish one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Defaults: kind 1 (text note), empty content, empty tags, current timestamp. + +Common kinds: 1 = text note, 6 = repost, 7 = reaction (content is "+" or emoji), 30023 = long-form article. + +For text notes (kind 1), put the post text in content. For reactions (kind 7), set content to "+" or an emoji and add an "e" tag referencing the target event. + +Tags are arrays of strings, e.g. [["t", "nostr"], ["p", ""]] for a hashtag and a mention.`, + parameters: { + type: 'object' as const, + properties: { + events: { + type: 'array', + items: { + type: 'object', + properties: { + kind: { type: 'number', description: 'Event kind number (default: 1).' }, + content: { type: 'string', description: 'Event content (default: empty string).' }, + tags: { + type: 'array', + items: { type: 'array', items: { type: 'string' } }, + description: 'Event tags (default: empty array).', + }, + }, + }, + description: 'Array of events to publish.', + }, + }, + required: ['events'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'fetch_event', + description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag). + +Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article. + +Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`, + parameters: { + type: 'object' as const, + properties: { + identifier: { + type: 'string', + description: 'NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).', + }, + }, + required: ['identifier'], + }, + }, + }, ]; -