diff --git a/.goosehints b/.goosehints index f22691c7..2d76b934 100644 --- a/.goosehints +++ b/.goosehints @@ -24,6 +24,8 @@ To learn about Nostr, use the fetch tool to read [NIP-01](https://raw.githubuser To read a specific NIP, construct the NIP URL following this template: `https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/{nip}.md` (replace `{nip}` in the URL template with the relevant NIP name, eg `07` for NIP-07, or `C7` for NIP-C7). Then use the fetch tool to read the URL. +To read the definition of a specific kind, construct a URL following this template: `https://nostrbook.dev/kinds/{kind}.md` (replace `{kind}` in the template with the kind number, eg `https://nostrbook.dev/kinds/0.md` for kind 0). + To discover the full list of NIPs, use the fetch tool to read the [NIPs README](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/README.md). It's important that Ditto conforms to Nostr standards. Please read as much of the NIPs as you need to have a full understanding before adding or modifying Nostr events and filters. It is possible to add new ideas to Nostr that don't exist yet in the NIPs, but only after other options have been explored. Care must be taken when adding new Nostr ideas, to ensure they fit seamlessly within the existing Nostr ecosystem. diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index bd86ac51..b4ecbaec 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -16,6 +16,7 @@ import { startSentry } from '@/sentry.ts'; import { DittoAPIStore } from '@/storages/DittoAPIStore.ts'; import { DittoPgStore } from '@/storages/DittoPgStore.ts'; import { DittoPool } from '@/storages/DittoPool.ts'; +import { createNip89 } from '@/utils/nip89.ts'; import { Time } from '@/utils/time.ts'; import { seedZapSplits } from '@/utils/zap-split.ts'; @@ -198,6 +199,7 @@ const pgstore = new DittoPgStore({ const pool = new DittoPool({ conf, relay: pgstore }); const relay = new DittoRelayStore({ db, conf, pool, relay: pgstore }); +await createNip89({ conf, relay }); await seedZapSplits({ conf, relay }); if (conf.firehoseEnabled) { diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index e80441b6..9d44ac56 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -17,6 +17,7 @@ import { languageSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts'; import { getCustomEmojis } from '@/utils/custom-emoji.ts'; +import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { getZapSplits } from '@/utils/zap-split.ts'; @@ -25,6 +26,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; const createStatusSchema = z.object({ + disclose_client: z.boolean().nullish(), in_reply_to_id: n.id().nullish(), language: languageSchema.nullish(), media_ids: z.string().array().nullish(), @@ -265,6 +267,11 @@ const createStatusController: AppController = async (c) => { content += mediaUrls.join('\n'); } + if (data.disclose_client) { + const { name } = await getInstanceMetadata(c.var); + tags.push(['client', name, `31990:${await conf.signer.getPublicKey()}:ditto`, conf.relay]); + } + const event = await createEvent({ kind: 1, content, diff --git a/packages/ditto/interfaces/DittoEvent.ts b/packages/ditto/interfaces/DittoEvent.ts index cdd4343d..62f6c626 100644 --- a/packages/ditto/interfaces/DittoEvent.ts +++ b/packages/ditto/interfaces/DittoEvent.ts @@ -56,4 +56,5 @@ export interface DittoEvent extends NostrEvent { zap_message?: string; /** Language of the event (kind 1s are more accurate). */ language?: LanguageCode; + client?: DittoEvent; } diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index d2c64e90..7568a2b6 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -1,6 +1,6 @@ import { DittoDB, DittoTables } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { NStore } from '@nostrify/nostrify'; +import { type NostrFilter, NStore } from '@nostrify/nostrify'; import { Kysely } from 'kysely'; import { matchFilter } from 'nostr-tools'; import { NSchema as n } from '@nostrify/nostrify'; @@ -50,6 +50,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherClients({ ...opts, events: cache })) { + cache.push(event); + } + const authorStats = await gatherAuthorStats(cache, db.kysely); const eventStats = await gatherEventStats(cache, db.kysely); @@ -128,6 +132,16 @@ export function assembleEvents( event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e)); event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e)); + for (const [name, _value, addr] of event.tags) { + if (name === 'client' && addr) { + const match = addr.match(/^31990:([0-9a-f]{64}):(.+)$/); + if (match) { + const [, pubkey, d] = match; + event.client = b.find((e) => matchFilter({ kinds: [31990], authors: [pubkey], '#d': [d] }, e)); + } + } + } + if (event.kind === 1) { const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content); if (id) { @@ -353,6 +367,28 @@ async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise ); } +function gatherClients({ events, relay, signal }: HydrateOpts): Promise { + const filters: NostrFilter[] = []; + + for (const event of events) { + for (const [name, _value, addr] of event.tags) { + if (name === 'client' && addr) { + const match = addr.match(/^31990:([0-9a-f]{64}):(.+)$/); + if (match) { + const [, pubkey, d] = match; + filters.push({ kinds: [31990], authors: [pubkey], '#d': [d], limit: 1 }); + } + } + } + } + + if (!filters.length) { + return Promise.resolve([]); + } + + return relay.query(filters, { signal }); +} + /** Collect author stats from the events. */ async function gatherAuthorStats( events: DittoEvent[], diff --git a/packages/ditto/utils/instance.ts b/packages/ditto/utils/instance.ts index 52fe7358..df021478 100644 --- a/packages/ditto/utils/instance.ts +++ b/packages/ditto/utils/instance.ts @@ -44,6 +44,7 @@ export async function getInstanceMetadata(opts: GetInstanceMetadataOpts): Promis tagline: meta.tagline ?? meta.about ?? 'Nostr community server', email: meta.email ?? `postmaster@${conf.url.host}`, picture: meta.picture ?? conf.local('/images/thumbnail.png'), + website: meta.website ?? conf.localDomain, event, screenshots: meta.screenshots ?? [], }; diff --git a/packages/ditto/utils/nip89.ts b/packages/ditto/utils/nip89.ts new file mode 100644 index 00000000..2e0cd0d1 --- /dev/null +++ b/packages/ditto/utils/nip89.ts @@ -0,0 +1,34 @@ +import { DittoConf } from '@ditto/conf'; + +import { getInstanceMetadata } from '@/utils/instance.ts'; + +import type { NStore } from '@nostrify/nostrify'; + +interface CreateNip89Opts { + conf: DittoConf; + relay: NStore; + signal?: AbortSignal; +} + +/** + * Creates a NIP-89 application handler event (kind 31990) + * This identifies Ditto as a client that can handle various kinds of events + */ +export async function createNip89(opts: CreateNip89Opts): Promise { + const { conf, relay, signal } = opts; + + const { event: _, ...metadata } = await getInstanceMetadata(opts); + + const event = await conf.signer.signEvent({ + kind: 31990, + tags: [ + ['d', 'ditto'], + ['k', '1'], + ['web', conf.local('/'), 'web'], + ], + content: JSON.stringify(metadata), + created_at: Math.floor(Date.now() / 1000), + }); + + await relay.event(event, { signal }); +} diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index ba2e8d86..579aea2b 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NStore } from '@nostrify/nostrify'; +import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { Conf } from '@/config.ts'; @@ -118,11 +118,27 @@ async function renderStatus( return acc; }, [] as { name: string; count: number; me: boolean; url?: string }[]); + let application: MastodonStatus['application'] = undefined; + + if (event.client) { + const result = n.json().pipe(n.metadata()).safeParse(event.client.content); + if (result.success) { + const name = result.data.name ?? result.data.display_name ?? event.tags.find(([name]) => name === 'client')?.[1]; + if (name) { + application = { + name, + website: result.data.website ?? null, + }; + } + } + } + const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); return { id: event.id, account, + application, card: event.event_stats?.link_preview ?? null, content: compatMentions + html, created_at: nostrDate(event.created_at).toISOString(), @@ -142,7 +158,6 @@ async function renderStatus( bookmarked: Boolean(bookmarkEvent), pinned: Boolean(pinEvent), reblog: null, - application: null, media_attachments: media .map((m) => renderAttachment({ tags: m })) .filter((m): m is MastodonAttachment => Boolean(m)), diff --git a/packages/mastoapi/types/MastodonStatus.ts b/packages/mastoapi/types/MastodonStatus.ts index 019e5a7b..db99d847 100644 --- a/packages/mastoapi/types/MastodonStatus.ts +++ b/packages/mastoapi/types/MastodonStatus.ts @@ -5,6 +5,10 @@ import type { MastodonPreviewCard } from './MastodonPreviewCard.ts'; export interface MastodonStatus { id: string; account: MastodonAccount; + application?: { + name: string; + website: string | null; + }; card: MastodonPreviewCard | null; content: string; created_at: string; @@ -24,7 +28,6 @@ export interface MastodonStatus { bookmarked: boolean; pinned: boolean; reblog: MastodonStatus | null; - application: unknown; media_attachments: MastodonAttachment[]; mentions: unknown[]; tags: unknown[];