Merge branch 'streak2' into 'main'

Streak API: Version 2

See merge request soapbox-pub/ditto!639
This commit is contained in:
Alex Gleason 2025-02-06 21:57:04 +00:00
commit 673dd152d5
9 changed files with 145 additions and 10 deletions

View file

@ -23,6 +23,7 @@
"clean:deps": "deno cache --reload src/app.ts",
"db:populate-search": "deno run -A --env-file --deny-read=.env scripts/db-populate-search.ts",
"db:populate-extensions": "deno run -A --env-file --deny-read=.env scripts/db-populate-extensions.ts",
"db:streak:recompute": "deno run -A --env-file --deny-read=.env scripts/db-streak-recompute.ts",
"vapid": "deno run scripts/vapid.ts"
},
"unstable": [

View file

@ -0,0 +1,48 @@
import { Storages } from '@/storages.ts';
const kysely = await Storages.kysely();
const statsQuery = kysely.selectFrom('author_stats').select('pubkey');
for await (const { pubkey } of statsQuery.stream(10)) {
const eventsQuery = kysely
.selectFrom('nostr_events')
.select('created_at')
.where('pubkey', '=', pubkey)
.where('kind', 'in', [1, 20, 1111, 30023])
.orderBy('nostr_events.created_at', 'desc')
.orderBy('nostr_events.id', 'asc');
let end: number | null = null;
let start: number | null = null;
for await (const { created_at } of eventsQuery.stream(20)) {
const createdAt = Number(created_at);
if (!end) {
const now = Math.floor(Date.now() / 1000);
if (now - createdAt > 86400) {
break; // streak broken
}
end = createdAt;
}
if (start && (start - createdAt > 86400)) {
break; // streak broken
}
start = createdAt;
}
await kysely
.updateTable('author_stats')
.set({
streak_end: end,
streak_start: start,
})
.where('pubkey', '=', pubkey)
.execute();
}
Deno.exit();

View file

@ -17,6 +17,8 @@ interface AuthorStatsRow {
following_count: number;
notes_count: number;
search: string;
streak_start: number | null;
streak_end: number | null;
}
interface EventStatsRow {

View file

@ -0,0 +1,17 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable('author_stats')
.addColumn('streak_start', 'integer')
.addColumn('streak_end', 'integer')
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable('author_stats')
.dropColumn('streak_start')
.dropColumn('streak_end')
.execute();
}

View file

@ -45,6 +45,11 @@ export interface MastodonAccount {
ditto: {
accepts_zaps: boolean;
external_url: string;
streak: {
days: number;
start: string | null;
end: string | null;
};
};
domain?: string;
pleroma: {

View file

@ -6,6 +6,8 @@ export interface AuthorStats {
followers_count: number;
following_count: number;
notes_count: number;
streak_start?: number;
streak_end?: number;
}
/** Ditto internal stats for the event. */

View file

@ -89,10 +89,22 @@ export function assembleEvents(
): DittoEvent[] {
const admin = Conf.pubkey;
const eventStats = stats.events.map((stat) => ({
...stat,
reactions: JSON.parse(stat.reactions),
}));
const authorStats = stats.authors.reduce((result, { pubkey, ...stat }) => {
result[pubkey] = {
...stat,
streak_start: stat.streak_start ?? undefined,
streak_end: stat.streak_end ?? undefined,
};
return result;
}, {} as Record<string, DittoEvent['author_stats']>);
const eventStats = stats.events.reduce((result, { event_id, ...stat }) => {
result[event_id] = {
...stat,
reactions: JSON.parse(stat.reactions),
};
return result;
}, {} as Record<string, DittoEvent['event_stats']>);
for (const event of a) {
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
@ -161,8 +173,8 @@ export function assembleEvents(
event.zap_message = zapRequest?.content ?? '';
}
event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey);
event.event_stats = eventStats.find((stats) => stats.event_id === event.id);
event.author_stats = authorStats[event.pubkey];
event.event_stats = eventStats[event.id];
}
return a;
@ -383,6 +395,8 @@ async function gatherAuthorStats(
following_count: Math.max(0, row.following_count),
notes_count: Math.max(0, row.notes_count),
search: row.search,
streak_start: row.streak_start,
streak_end: row.streak_end,
}));
}

View file

@ -1,5 +1,5 @@
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { Kysely, UpdateObject } from 'kysely';
import { Insertable, Kysely, UpdateObject } from 'kysely';
import { SetRequired } from 'type-fest';
import { z } from 'zod';
@ -18,6 +18,9 @@ interface UpdateStatsOpts {
export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise<void> {
switch (event.kind) {
case 1:
case 20:
case 1111:
case 30023:
return handleEvent1(kysely, event, x);
case 3:
return handleEvent3(kysely, event, x, store);
@ -34,7 +37,34 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp
/** Update stats for kind 1 event. */
async function handleEvent1(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> {
await updateAuthorStats(kysely, event.pubkey, ({ notes_count }) => ({ notes_count: Math.max(0, notes_count + x) }));
await updateAuthorStats(kysely, event.pubkey, (prev) => {
const now = event.created_at;
let start = prev.streak_start;
let end = prev.streak_end;
if (start && end) { // Streak exists.
if (now <= end) {
// Streak cannot go backwards in time. Skip it.
} else if (now - end > 86400) {
// Streak is broken. Start a new streak.
start = now;
end = now;
} else {
// Extend the streak.
end = now;
}
} else { // New streak.
start = now;
end = now;
}
return {
notes_count: Math.max(0, prev.notes_count + x),
streak_start: start || null,
streak_end: end || null,
};
});
const replyId = findReplyTag(event.tags)?.[1];
const quoteId = findQuoteTag(event.tags)?.[1];
@ -187,9 +217,9 @@ export function getAuthorStats(
export async function updateAuthorStats(
kysely: Kysely<DittoTables>,
pubkey: string,
fn: (prev: DittoTables['author_stats']) => UpdateObject<DittoTables, 'author_stats'>,
fn: (prev: Insertable<DittoTables['author_stats']>) => UpdateObject<DittoTables, 'author_stats'>,
): Promise<void> {
const empty: DittoTables['author_stats'] = {
const empty: Insertable<DittoTables['author_stats']> = {
pubkey,
followers_count: 0,
following_count: 0,
@ -290,6 +320,8 @@ export async function countAuthorStats(
following_count: getTagSet(followList?.tags ?? [], 'p').size,
notes_count,
search,
streak_start: null,
streak_end: null,
};
}

View file

@ -69,6 +69,15 @@ async function renderAccount(
verified_at: null,
})) ?? [];
let streakDays = 0;
const streakStart = event.author_stats?.streak_start;
const streakEnd = event.author_stats?.streak_end;
if (streakStart && streakEnd) {
const delta = streakEnd - streakStart;
streakDays = Math.max(Math.ceil(delta / 86400), 1);
}
return {
id: pubkey,
acct,
@ -113,6 +122,11 @@ async function renderAccount(
ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
external_url: Conf.external(nprofile),
streak: {
days: streakDays,
start: streakStart ? nostrDate(streakStart).toISOString() : null,
end: streakEnd ? nostrDate(streakEnd).toISOString() : null,
},
},
domain: parsed05?.domain,
pleroma: {