2023-09-03 18:49:45 -05:00
|
|
|
import { Conf } from '@/config.ts';
|
2023-08-17 16:47:22 -05:00
|
|
|
import { addRelays } from '@/db/relays.ts';
|
2023-09-09 22:27:16 -05:00
|
|
|
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
2024-01-23 12:07:22 -06:00
|
|
|
import { Debug, LNURL, type NostrEvent } from '@/deps.ts';
|
|
|
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
2023-08-26 13:40:10 -05:00
|
|
|
import { isEphemeralKind } from '@/kinds.ts';
|
2023-08-17 19:32:05 -05:00
|
|
|
import { isLocallyFollowed } from '@/queries.ts';
|
2023-12-10 11:10:11 -06:00
|
|
|
import { updateStats } from '@/stats.ts';
|
2024-01-24 15:47:19 -06:00
|
|
|
import { dehydrateEvent } from '@/storages/hydrate.ts';
|
2024-01-23 17:50:33 -06:00
|
|
|
import { cache, client, eventsDB, reqmeister } from '@/storages.ts';
|
2023-08-23 23:25:38 -05:00
|
|
|
import { Sub } from '@/subs.ts';
|
2023-09-05 21:24:59 -05:00
|
|
|
import { getTagSet } from '@/tags.ts';
|
2024-01-16 17:46:53 -06:00
|
|
|
import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts';
|
2024-01-15 17:14:08 -06:00
|
|
|
import { fetchWorker } from '@/workers/fetch.ts';
|
2023-12-04 16:33:02 -06:00
|
|
|
import { TrendsWorker } from '@/workers/trends.ts';
|
2024-02-12 11:42:25 -06:00
|
|
|
import { verifyEventWorker } from '@/workers/verify.ts';
|
2024-02-12 11:52:05 -06:00
|
|
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
2024-01-22 12:42:39 -06:00
|
|
|
import { lnurlCache } from '@/utils/lnurl.ts';
|
2023-08-17 16:47:22 -05:00
|
|
|
|
2023-12-27 16:21:58 -06:00
|
|
|
const debug = Debug('ditto:pipeline');
|
|
|
|
|
2023-08-17 16:47:22 -05:00
|
|
|
/**
|
|
|
|
* Common pipeline function to process (and maybe store) events.
|
|
|
|
* It is idempotent, so it can be called multiple times for the same event.
|
|
|
|
*/
|
2024-01-23 15:53:29 -06:00
|
|
|
async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
2024-02-12 11:42:25 -06:00
|
|
|
if (!(await verifyEventWorker(event))) return;
|
2023-12-21 14:56:21 -06:00
|
|
|
const wanted = reqmeister.isWanted(event);
|
2024-01-23 14:35:35 -06:00
|
|
|
if (await encounterEvent(event, signal)) return;
|
2024-01-23 12:07:22 -06:00
|
|
|
debug(`NostrEvent<${event.kind}> ${event.id}`);
|
2024-01-23 10:56:17 -06:00
|
|
|
await hydrateEvent(event);
|
2023-08-24 17:00:08 -05:00
|
|
|
|
2023-08-17 18:07:25 -05:00
|
|
|
await Promise.all([
|
2024-01-23 14:35:35 -06:00
|
|
|
storeEvent(event, { force: wanted, signal }),
|
|
|
|
processDeletions(event, signal),
|
2023-08-17 18:07:25 -05:00
|
|
|
trackRelays(event),
|
2023-08-17 20:24:16 -05:00
|
|
|
trackHashtags(event),
|
2024-01-23 10:56:17 -06:00
|
|
|
fetchRelatedEvents(event, signal),
|
|
|
|
processMedia(event),
|
|
|
|
payZap(event, signal),
|
|
|
|
streamOut(event),
|
2024-01-23 14:35:35 -06:00
|
|
|
broadcast(event, signal),
|
2023-08-17 18:07:25 -05:00
|
|
|
]);
|
|
|
|
}
|
2023-08-17 16:47:22 -05:00
|
|
|
|
2023-08-24 17:39:24 -05:00
|
|
|
/** Encounter the event, and return whether it has already been encountered. */
|
2024-01-23 14:35:35 -06:00
|
|
|
async function encounterEvent(event: NostrEvent, signal: AbortSignal): Promise<boolean> {
|
2024-02-12 10:48:26 -06:00
|
|
|
const preexisting = (await cache.count([{ ids: [event.id] }])).count > 0;
|
2024-01-23 17:50:33 -06:00
|
|
|
cache.event(event);
|
2024-01-23 14:35:35 -06:00
|
|
|
reqmeister.event(event, { signal });
|
2023-12-27 23:35:42 -06:00
|
|
|
return preexisting;
|
2023-08-24 17:39:24 -05:00
|
|
|
}
|
|
|
|
|
2024-01-23 10:56:17 -06:00
|
|
|
/** Hydrate the event with the user, if applicable. */
|
|
|
|
async function hydrateEvent(event: DittoEvent): Promise<void> {
|
2024-01-23 14:06:16 -06:00
|
|
|
const [user] = await eventsDB.query([{ kinds: [30361], authors: [Conf.pubkey], limit: 1 }]);
|
2024-01-23 10:56:17 -06:00
|
|
|
event.user = user;
|
2023-08-24 17:00:08 -05:00
|
|
|
}
|
|
|
|
|
2023-09-03 18:49:45 -05:00
|
|
|
/** Check if the pubkey is the `DITTO_NSEC` pubkey. */
|
2024-01-23 12:07:22 -06:00
|
|
|
const isAdminEvent = ({ pubkey }: NostrEvent): boolean => pubkey === Conf.pubkey;
|
2023-09-03 18:49:45 -05:00
|
|
|
|
2023-12-21 14:56:21 -06:00
|
|
|
interface StoreEventOpts {
|
2024-01-23 14:35:35 -06:00
|
|
|
force: boolean;
|
|
|
|
signal: AbortSignal;
|
2023-12-21 14:56:21 -06:00
|
|
|
}
|
|
|
|
|
2023-08-17 18:07:25 -05:00
|
|
|
/** Maybe store the event, if eligible. */
|
2024-01-23 14:35:35 -06:00
|
|
|
async function storeEvent(event: DittoEvent, opts: StoreEventOpts): Promise<void> {
|
2023-08-26 13:40:10 -05:00
|
|
|
if (isEphemeralKind(event.kind)) return;
|
2024-01-23 14:35:35 -06:00
|
|
|
const { force = false, signal } = opts;
|
2023-09-05 20:29:35 -05:00
|
|
|
|
2024-01-23 10:56:17 -06:00
|
|
|
if (force || event.user || isAdminEvent(event) || await isLocallyFollowed(event.pubkey)) {
|
2024-02-12 10:48:26 -06:00
|
|
|
const isDeleted = (await eventsDB.count(
|
2024-01-11 19:05:34 -06:00
|
|
|
[{ kinds: [5], authors: [Conf.pubkey, event.pubkey], '#e': [event.id], limit: 1 }],
|
2024-01-23 14:35:35 -06:00
|
|
|
opts,
|
2024-02-12 10:48:26 -06:00
|
|
|
)).count > 0;
|
2023-09-05 20:29:35 -05:00
|
|
|
|
2024-01-11 19:05:34 -06:00
|
|
|
if (isDeleted) {
|
2023-09-05 20:29:35 -05:00
|
|
|
return Promise.reject(new RelayError('blocked', 'event was deleted'));
|
|
|
|
} else {
|
2023-12-10 11:10:11 -06:00
|
|
|
await Promise.all([
|
2024-01-23 14:35:35 -06:00
|
|
|
eventsDB.event(event, { signal }).catch(debug),
|
2023-12-27 20:19:59 -06:00
|
|
|
updateStats(event).catch(debug),
|
2023-12-10 11:10:11 -06:00
|
|
|
]);
|
2023-09-05 20:29:35 -05:00
|
|
|
}
|
2023-08-17 20:24:16 -05:00
|
|
|
} else {
|
|
|
|
return Promise.reject(new RelayError('blocked', 'only registered users can post'));
|
2023-08-17 16:47:22 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-05 21:24:59 -05:00
|
|
|
/** Query to-be-deleted events, ensure their pubkey matches, then delete them from the database. */
|
2024-01-23 14:35:35 -06:00
|
|
|
async function processDeletions(event: NostrEvent, signal: AbortSignal): Promise<void> {
|
2023-09-05 21:24:59 -05:00
|
|
|
if (event.kind === 5) {
|
|
|
|
const ids = getTagSet(event.tags, 'e');
|
|
|
|
|
2024-01-11 19:11:04 -06:00
|
|
|
if (event.pubkey === Conf.pubkey) {
|
2024-01-23 14:35:35 -06:00
|
|
|
await eventsDB.remove([{ ids: [...ids] }], { signal });
|
2024-01-11 19:11:04 -06:00
|
|
|
} else {
|
2024-01-23 14:35:35 -06:00
|
|
|
const events = await eventsDB.query(
|
|
|
|
[{ ids: [...ids], authors: [event.pubkey] }],
|
|
|
|
{ signal },
|
|
|
|
);
|
2023-09-05 21:24:59 -05:00
|
|
|
|
2024-01-11 19:11:04 -06:00
|
|
|
const deleteIds = events.map(({ id }) => id);
|
2024-01-23 14:35:35 -06:00
|
|
|
await eventsDB.remove([{ ids: deleteIds }], { signal });
|
2024-01-11 19:11:04 -06:00
|
|
|
}
|
2023-09-05 21:24:59 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-17 16:47:22 -05:00
|
|
|
/** Track whenever a hashtag is used, for processing trending tags. */
|
2024-01-23 12:07:22 -06:00
|
|
|
async function trackHashtags(event: NostrEvent): Promise<void> {
|
2023-08-17 16:47:22 -05:00
|
|
|
const date = nostrDate(event.created_at);
|
|
|
|
|
|
|
|
const tags = event.tags
|
|
|
|
.filter((tag) => tag[0] === 't')
|
|
|
|
.map((tag) => tag[1])
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
|
|
if (!tags.length) return;
|
|
|
|
|
|
|
|
try {
|
2023-12-27 22:27:05 -06:00
|
|
|
debug('tracking tags:', JSON.stringify(tags));
|
2023-12-04 16:33:02 -06:00
|
|
|
await TrendsWorker.addTagUsages(event.pubkey, tags, date);
|
2023-08-17 16:47:22 -05:00
|
|
|
} catch (_e) {
|
|
|
|
// do nothing
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Tracks known relays in the database. */
|
2024-01-23 12:07:22 -06:00
|
|
|
function trackRelays(event: NostrEvent) {
|
2023-08-17 16:47:22 -05:00
|
|
|
const relays = new Set<`wss://${string}`>();
|
|
|
|
|
|
|
|
event.tags.forEach((tag) => {
|
|
|
|
if (['p', 'e', 'a'].includes(tag[0]) && isRelay(tag[2])) {
|
|
|
|
relays.add(tag[2]);
|
|
|
|
}
|
|
|
|
if (event.kind === 10002 && tag[0] === 'r' && isRelay(tag[1])) {
|
|
|
|
relays.add(tag[1]);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return addRelays([...relays]);
|
|
|
|
}
|
|
|
|
|
2023-12-26 13:27:48 -06:00
|
|
|
/** Queue related events to fetch. */
|
2024-02-12 10:48:26 -06:00
|
|
|
async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) {
|
2024-01-23 10:56:17 -06:00
|
|
|
if (!event.user) {
|
2024-01-22 14:31:52 -06:00
|
|
|
reqmeister.req({ kinds: [0], authors: [event.pubkey] }, { signal }).catch(() => {});
|
2023-12-21 14:56:21 -06:00
|
|
|
}
|
2024-02-12 10:48:26 -06:00
|
|
|
|
2023-12-21 14:56:21 -06:00
|
|
|
for (const [name, id, relay] of event.tags) {
|
2024-02-12 10:48:26 -06:00
|
|
|
if (name === 'e') {
|
|
|
|
const { count } = await cache.count([{ ids: [id] }]);
|
|
|
|
if (!count) {
|
|
|
|
reqmeister.req({ ids: [id] }, { relays: [relay] }).catch(() => {});
|
|
|
|
}
|
2023-12-21 14:56:21 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-09 22:27:16 -05:00
|
|
|
/** Delete unattached media entries that are attached to the event. */
|
2024-01-23 10:56:17 -06:00
|
|
|
function processMedia({ tags, pubkey, user }: DittoEvent) {
|
2023-09-09 22:27:16 -05:00
|
|
|
if (user) {
|
|
|
|
const urls = getTagSet(tags, 'media');
|
|
|
|
return deleteAttachedMedia(pubkey, [...urls]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-22 14:24:37 -06:00
|
|
|
/** Emit Nostr Wallet Connect event from zaps so users may pay. */
|
2024-01-23 10:56:17 -06:00
|
|
|
async function payZap(event: DittoEvent, signal: AbortSignal) {
|
|
|
|
if (event.kind !== 9734 || !event.user) return;
|
2024-01-22 14:24:37 -06:00
|
|
|
|
|
|
|
const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1];
|
|
|
|
const amount = Number(event.tags.find(([name]) => name === 'amount')?.[1]);
|
|
|
|
|
|
|
|
if (!lnurl || !amount) return;
|
|
|
|
|
|
|
|
try {
|
|
|
|
const details = await lnurlCache.fetch(lnurl, { signal });
|
|
|
|
|
|
|
|
if (details.tag !== 'payRequest' || !details.allowsNostr || !details.nostrPubkey) {
|
|
|
|
throw new Error('invalid lnurl');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (amount > details.maxSendable || amount < details.minSendable) {
|
|
|
|
throw new Error('amount out of range');
|
2024-01-15 17:14:08 -06:00
|
|
|
}
|
2024-01-22 14:24:37 -06:00
|
|
|
|
|
|
|
const { pr } = await LNURL.callback(
|
|
|
|
details.callback,
|
2024-01-24 15:47:19 -06:00
|
|
|
{ amount, nostr: dehydrateEvent(event), lnurl },
|
2024-01-22 14:24:37 -06:00
|
|
|
{ fetch: fetchWorker, signal },
|
|
|
|
);
|
|
|
|
|
2024-02-12 11:52:05 -06:00
|
|
|
const signer = new AdminSigner();
|
|
|
|
|
|
|
|
const nwcRequestEvent = await signer.signEvent({
|
2024-01-22 14:24:37 -06:00
|
|
|
kind: 23194,
|
2024-02-21 14:50:26 -06:00
|
|
|
content: await signer.nip04.encrypt(
|
2024-01-22 14:24:37 -06:00
|
|
|
event.pubkey,
|
|
|
|
JSON.stringify({ method: 'pay_invoice', params: { invoice: pr } }),
|
|
|
|
),
|
|
|
|
created_at: nostrNow(),
|
|
|
|
tags: [
|
|
|
|
['p', event.pubkey],
|
|
|
|
['e', event.id],
|
|
|
|
],
|
|
|
|
});
|
|
|
|
|
2024-01-23 15:53:29 -06:00
|
|
|
await handleEvent(nwcRequestEvent, signal);
|
2024-01-22 14:24:37 -06:00
|
|
|
} catch (e) {
|
|
|
|
debug('lnurl error:', e);
|
2024-01-15 17:14:08 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-24 17:26:46 -05:00
|
|
|
/** Determine if the event is being received in a timely manner. */
|
2024-01-23 12:07:22 -06:00
|
|
|
const isFresh = (event: NostrEvent): boolean => eventAge(event) < Time.seconds(10);
|
2023-08-24 17:26:46 -05:00
|
|
|
|
2023-08-23 23:25:38 -05:00
|
|
|
/** Distribute the event through active subscriptions. */
|
2024-01-23 12:07:22 -06:00
|
|
|
function streamOut(event: NostrEvent) {
|
2023-08-24 17:26:46 -05:00
|
|
|
if (!isFresh(event)) return;
|
|
|
|
|
2024-01-23 10:56:17 -06:00
|
|
|
for (const sub of Sub.matches(event)) {
|
2023-08-25 13:35:20 -05:00
|
|
|
sub.stream(event);
|
2023-08-23 23:25:38 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-05 21:38:15 -05:00
|
|
|
/**
|
|
|
|
* Publish the event to other relays.
|
|
|
|
* This should only be done in certain circumstances, like mentioning a user or publishing deletions.
|
|
|
|
*/
|
2024-02-02 14:51:22 -06:00
|
|
|
async function broadcast(event: DittoEvent, signal: AbortSignal) {
|
2024-01-23 10:56:17 -06:00
|
|
|
if (!event.user || !isFresh(event)) return;
|
2023-09-05 21:38:15 -05:00
|
|
|
|
|
|
|
if (event.kind === 5) {
|
2024-02-02 14:51:22 -06:00
|
|
|
try {
|
|
|
|
await client.event(event, { signal });
|
|
|
|
} catch (e) {
|
|
|
|
debug(e);
|
|
|
|
}
|
2023-09-05 21:38:15 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-17 20:24:16 -05:00
|
|
|
/** NIP-20 command line result. */
|
|
|
|
class RelayError extends Error {
|
|
|
|
constructor(prefix: 'duplicate' | 'pow' | 'blocked' | 'rate-limited' | 'invalid' | 'error', message: string) {
|
|
|
|
super(`${prefix}: ${message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export { handleEvent, RelayError };
|