Merge branch 'zapped-by-pagination-sort-amount' into 'main'

zapped_by: add pagination & sort amount

Closes #159

See merge request soapbox-pub/ditto!394
This commit is contained in:
Alex Gleason 2024-07-02 05:17:11 +00:00
commit fd769c125c
7 changed files with 230 additions and 29 deletions

View file

@ -8,14 +8,21 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { getAmount } from '@/utils/bolt11.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { renderEventAccounts } from '@/views.ts'; import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import {
createEvent,
listPaginationSchema,
paginated,
paginatedList,
paginationSchema,
parseBody,
updateListEvent,
} from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { lookupPubkey } from '@/utils/lookup.ts';
import { addTag, deleteTag } from '@/utils/tags.ts'; import { addTag, deleteTag } from '@/utils/tags.ts';
@ -545,33 +552,26 @@ const zapController: AppController = async (c) => {
const zappedByController: AppController = async (c) => { const zappedByController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = listPaginationSchema.parse(c.req.query());
const store = await Storages.db(); const store = await Storages.db();
const amountSchema = z.coerce.number().int().nonnegative().catch(0); const db = await DittoDB.getInstance();
const events = (await store.query([{ kinds: [9735], '#e': [id], limit: 100 }])).map((event) => { const zaps = await db.selectFrom('event_zaps')
const zapRequestString = event.tags.find(([name]) => name === 'description')?.[1]; .selectAll()
if (!zapRequestString) return; .where('target_event_id', '=', id)
try { .orderBy('amount_millisats', 'desc')
const zapRequest = n.json().pipe(n.event()).parse(zapRequestString); .limit(params.limit)
const amount = zapRequest?.tags.find(([name]: any) => name === 'amount')?.[1]; .offset(params.offset).execute();
if (!amount) {
const amount = getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1]);
if (!amount) return;
zapRequest.tags.push(['amount', amount]);
}
return zapRequest;
} catch {
return;
}
}).filter(Boolean) as DittoEvent[];
await hydrateEvents({ events, store }); const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
const results = (await Promise.all( const results = (await Promise.all(
events.map(async (event) => { zaps.map(async (zap) => {
const amount = amountSchema.parse(event.tags.find(([name]) => name === 'amount')?.[1]); const amount = zap.amount_millisats;
const comment = event?.content ?? ''; const comment = zap.comment;
const account = event?.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
const sender = authors.find((author) => author.pubkey === zap.sender_pubkey);
const account = sender ? await renderAccount(sender) : await accountFromPubkey(zap.sender_pubkey);
return { return {
comment, comment,
@ -581,7 +581,7 @@ const zappedByController: AppController = async (c) => {
}), }),
)).filter(Boolean); )).filter(Boolean);
return c.json(results); return paginatedList(c, params, results);
}; };
export { export {

View file

@ -7,6 +7,7 @@ export interface DittoTables {
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
event_stats: EventStatsRow; event_stats: EventStatsRow;
pubkey_domains: PubkeyDomainRow; pubkey_domains: PubkeyDomainRow;
event_zaps: EventZapRow;
} }
interface AuthorStatsRow { interface AuthorStatsRow {
@ -69,3 +70,11 @@ interface PubkeyDomainRow {
domain: string; domain: string;
last_updated_at: number; last_updated_at: number;
} }
interface EventZapRow {
receipt_id: string;
target_event_id: string;
sender_pubkey: string;
amount_millisats: number;
comment: string;
}

View file

@ -0,0 +1,32 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('event_zaps')
.addColumn('receipt_id', 'text', (col) => col.primaryKey())
.addColumn('target_event_id', 'text', (col) => col.notNull())
.addColumn('sender_pubkey', 'text', (col) => col.notNull())
.addColumn('amount_millisats', 'integer', (col) => col.notNull())
.addColumn('comment', 'text', (col) => col.notNull())
.execute();
await db.schema
.createIndex('idx_event_zaps_amount_millisats')
.on('event_zaps')
.column('amount_millisats')
.ifNotExists()
.execute();
await db.schema
.createIndex('idx_event_zaps_target_event_id')
.on('event_zaps')
.column('target_event_id')
.ifNotExists()
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_event_zaps_amount_millisats').ifExists().execute();
await db.schema.dropIndex('idx_event_zaps_target_event_id').ifExists().execute();
await db.schema.dropTable('event_zaps').execute();
}

View file

@ -4,7 +4,7 @@ import { firehoseEventCounter } from '@/metrics.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import * as pipeline from './pipeline.ts'; import * as pipeline from '@/pipeline.ts';
const console = new Stickynotes('ditto:firehose'); const console = new Stickynotes('ditto:firehose');

125
src/pipeline.test.ts Normal file
View file

@ -0,0 +1,125 @@
import { assertEquals } from '@std/assert';
import { generateSecretKey } from 'nostr-tools';
import { genEvent, getTestDB } from '@/test.ts';
import { handleZaps } from '@/pipeline.ts';
Deno.test('store one zap receipt in nostr_events; convert it into event_zaps table format and store it', async () => {
await using db = await getTestDB();
const kysely = db.kysely;
const sk = generateSecretKey();
const event = genEvent({
'id': '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446',
'pubkey': '9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31',
'created_at': 1674164545,
'kind': 9735,
'tags': [
['p', '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
['P', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'],
['e', '3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8'],
[
'bolt11',
'lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0',
],
[
'description',
'{"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","content":"","id":"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d","created_at":1674164539,"sig":"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d","kind":9734,"tags":[["e","3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["relays","wss://relay.damus.io","wss://nostr-relay.wlvs.space","wss://nostr.fmt.wiz.biz","wss://relay.nostr.bg","wss://nostr.oxtr.dev","wss://nostr.v0l.io","wss://brb.io","wss://nostr.bitcoiner.social","ws://monad.jb55.com:8080","wss://relay.snort.social"]]}',
],
['preimage', '5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f'],
],
'content': '',
}, sk);
await db.store.event(event);
await handleZaps(kysely, event);
await handleZaps(kysely, event);
const zapReceipts = await kysely.selectFrom('nostr_events').selectAll().execute();
const customEventZaps = await kysely.selectFrom('event_zaps').selectAll().execute();
assertEquals(zapReceipts.length, 1); // basic check
assertEquals(customEventZaps.length, 1); // basic check
const expected = {
receipt_id: event.id,
target_event_id: '3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8',
sender_pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
amount_millisats: 1000000,
comment: '',
};
assertEquals(customEventZaps[0], expected);
});
// The function tests below only handle the edge cases and don't assert anything
// If no error happens = ok
Deno.test('zap receipt does not have a "description" tag', async () => {
await using db = await getTestDB();
const kysely = db.kysely;
const sk = generateSecretKey();
const event = genEvent({ kind: 9735 }, sk);
await handleZaps(kysely, event);
// no error happened = ok
});
Deno.test('zap receipt does not have a zap request stringified value in the "description" tag', async () => {
await using db = await getTestDB();
const kysely = db.kysely;
const sk = generateSecretKey();
const event = genEvent({ kind: 9735, tags: [['description', 'yolo']] }, sk);
await handleZaps(kysely, event);
// no error happened = ok
});
Deno.test('zap receipt does not have a "bolt11" tag', async () => {
await using db = await getTestDB();
const kysely = db.kysely;
const sk = generateSecretKey();
const event = genEvent({
kind: 9735,
tags: [[
'description',
'{"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","content":"","id":"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d","created_at":1674164539,"sig":"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d","kind":9734,"tags":[["e","3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["relays","wss://relay.damus.io","wss://nostr-relay.wlvs.space","wss://nostr.fmt.wiz.biz","wss://relay.nostr.bg","wss://nostr.oxtr.dev","wss://nostr.v0l.io","wss://brb.io","wss://nostr.bitcoiner.social","ws://monad.jb55.com:8080","wss://relay.snort.social"]]}',
]],
}, sk);
await handleZaps(kysely, event);
// no error happened = ok
});
Deno.test('zap request inside zap receipt does not have an "e" tag', async () => {
await using db = await getTestDB();
const kysely = db.kysely;
const sk = generateSecretKey();
const event = genEvent({
kind: 9735,
tags: [[
'bolt11',
'lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0',
], [
'description',
'{"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","content":"","id":"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d","created_at":1674164539,"sig":"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d","kind":9734,"tags":[["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["relays","wss://relay.damus.io","wss://nostr-relay.wlvs.space","wss://nostr.fmt.wiz.biz","wss://relay.nostr.bg","wss://nostr.oxtr.dev","wss://nostr.v0l.io","wss://brb.io","wss://nostr.bitcoiner.social","ws://monad.jb55.com:8080","wss://relay.snort.social"]]}',
]],
}, sk);
await handleZaps(kysely, event);
// no error happened = ok
});

View file

@ -1,7 +1,8 @@
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { z } from 'zod';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
@ -18,6 +19,8 @@ import { verifyEventWorker } from '@/workers/verify.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { updateStats } from '@/utils/stats.ts'; import { updateStats } from '@/utils/stats.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { getAmount } from '@/utils/bolt11.ts';
const debug = Debug('ditto:pipeline'); const debug = Debug('ditto:pipeline');
@ -51,8 +54,11 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
throw new RelayError('blocked', 'user is disabled'); throw new RelayError('blocked', 'user is disabled');
} }
const kysely = await DittoDB.getInstance();
await Promise.all([ await Promise.all([
storeEvent(event, signal), storeEvent(event, signal),
handleZaps(kysely, event),
parseMetadata(event, signal), parseMetadata(event, signal),
generateSetEvents(event), generateSetEvents(event),
processMedia(event), processMedia(event),
@ -110,7 +116,7 @@ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<voi
} }
/** Maybe store the event, if eligible. */ /** Maybe store the event, if eligible. */
async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<void> { async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<undefined> {
if (NKinds.ephemeral(event.kind)) return; if (NKinds.ephemeral(event.kind)) return;
const store = await Storages.db(); const store = await Storages.db();
const kysely = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
@ -220,4 +226,33 @@ async function generateSetEvents(event: NostrEvent): Promise<void> {
} }
} }
export { handleEvent }; /** Stores the event in the 'event_zaps' table */
async function handleZaps(kysely: Kysely<DittoTables>, event: NostrEvent) {
if (event.kind !== 9735) return;
const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1];
if (!zapRequestString) return;
const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString);
if (!zapRequest) return;
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1]));
if (!amount_millisats || amount_millisats < 1) return;
const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1];
if (!zappedEventId) return;
try {
await kysely.insertInto('event_zaps').values({
receipt_id: event.id,
target_event_id: zappedEventId,
sender_pubkey: zapRequest.pubkey,
amount_millisats,
comment: zapRequest.content,
}).execute();
} catch {
// receipt_id is unique, do nothing
}
}
export { handleEvent, handleZaps };

View file