Merge branch 'main' into cashu

This commit is contained in:
P. Reis 2025-02-07 22:53:34 -03:00
commit 55cc109376
13 changed files with 184 additions and 14 deletions

View file

@ -23,6 +23,7 @@
"clean:deps": "deno cache --reload src/app.ts", "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-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: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" "vapid": "deno run scripts/vapid.ts"
}, },
"unstable": [ "unstable": [

View file

@ -0,0 +1,52 @@
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
const kysely = await Storages.kysely();
const statsQuery = kysely.selectFrom('author_stats').select('pubkey');
const { streakWindow } = Conf;
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 > streakWindow) {
break; // streak broken
}
end = createdAt;
}
if (start && (start - createdAt > streakWindow)) {
break; // streak broken
}
start = createdAt;
}
if (start && end) {
await kysely
.updateTable('author_stats')
.set({
streak_end: end,
streak_start: start,
})
.where('pubkey', '=', pubkey)
.execute();
}
}
Deno.exit();

View file

@ -357,6 +357,10 @@ class Conf {
return Number(Deno.env.get('PROFILE_FIELDS_VALUE_LENGTH') || 2047); return Number(Deno.env.get('PROFILE_FIELDS_VALUE_LENGTH') || 2047);
}, },
}; };
/** Maximum time between events before a streak is broken, *in seconds*. */
static get streakWindow(): number {
return Number(Deno.env.get('STREAK_WINDOW') || 129600);
}
} }
const optionalBooleanSchema = z const optionalBooleanSchema = z

View file

@ -17,6 +17,8 @@ interface AuthorStatsRow {
following_count: number; following_count: number;
notes_count: number; notes_count: number;
search: string; search: string;
streak_start: number | null;
streak_end: number | null;
} }
interface EventStatsRow { 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: { ditto: {
accepts_zaps: boolean; accepts_zaps: boolean;
external_url: string; external_url: string;
streak: {
days: number;
start: string | null;
end: string | null;
};
}; };
domain?: string; domain?: string;
pleroma: { pleroma: {

View file

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

View file

@ -68,7 +68,13 @@ class EventsDB extends NPostgres {
if (event.kind === 1) { if (event.kind === 1) {
ext.reply = event.tags.some(([name]) => name === 'e').toString(); ext.reply = event.tags.some(([name]) => name === 'e').toString();
} else if (event.kind === 1111) {
ext.reply = event.tags.some(([name]) => ['e', 'E'].includes(name)).toString();
} else if (event.kind === 6) {
ext.reply = 'false';
}
if ([1, 20, 30023].includes(event.kind)) {
const language = detectLanguage(event.content, 0.90); const language = detectLanguage(event.content, 0.90);
if (language) { if (language) {

View file

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

View file

@ -35,9 +35,17 @@ Deno.test('Detects definitive texts', () => {
assertEquals(detectLanguage('Γειά σου!', 1), 'el'); assertEquals(detectLanguage('Γειά σου!', 1), 'el');
assertEquals(detectLanguage('שלום!', 1), 'he'); assertEquals(detectLanguage('שלום!', 1), 'he');
assertEquals(detectLanguage('こんにちは。', 1), 'ja'); assertEquals(detectLanguage('こんにちは。', 1), 'ja');
assertEquals(
detectLanguage(
'最近、長女から「中学生男子全員クソ」という話を良く聞き中学生女子側の視点が分かってよかった。父からは「中学生男子は自分がクソだということを3年間かかって学習するんだよ」と言っておいた',
1,
),
'ja',
);
// ambiguous // ambiguous
assertEquals(detectLanguage('你好', 1), undefined); assertEquals(detectLanguage('你好', 1), undefined);
assertEquals(detectLanguage('東京', 1), undefined);
assertEquals(detectLanguage('Привет', 1), undefined); assertEquals(detectLanguage('Привет', 1), undefined);
assertEquals(detectLanguage('Hello', 1), undefined); assertEquals(detectLanguage('Hello', 1), undefined);
}); });

View file

@ -12,9 +12,10 @@ export function detectLanguage(text: string, minConfidence: number): LanguageCod
// It's better to remove the emojis first // It's better to remove the emojis first
const sanitizedText = linkify.tokenize( const sanitizedText = linkify.tokenize(
text text
.replaceAll(/\p{Extended_Pictographic}/gu, '') .replaceAll(/\p{Extended_Pictographic}/gu, '') // strip emojis
.replaceAll(/[\s\uFEFF\u00A0\u200B-\u200D\u{0FE0E}]+/gu, ' '), .replaceAll(/[\s\uFEFF\u00A0\u200B-\u200D\u{0FE0E}]+/gu, ' '), // strip invisible characters
).reduce((acc, { t, v }) => t === 'text' ? acc + v : acc, '').trim(); )
.reduce((acc, { t, v }) => t === 'text' ? acc + v : acc, '').trim();
// Definite patterns for some languages. // Definite patterns for some languages.
// Text which matches MUST unambiguously be in the given language. // Text which matches MUST unambiguously be in the given language.
@ -30,7 +31,11 @@ export function detectLanguage(text: string, minConfidence: number): LanguageCod
// If any pattern matches, the language is known. // If any pattern matches, the language is known.
for (const [lang, pattern] of Object.entries(languagePatterns) as [LanguageCode, RegExp][]) { for (const [lang, pattern] of Object.entries(languagePatterns) as [LanguageCode, RegExp][]) {
if (pattern.test(text.replace(/[\p{P}\p{S}]/gu, ''))) { // strip punctuation and symbols before checking const text = sanitizedText
.replaceAll(/[\p{P}\p{S}]/gu, '') // strip punctuation and symbols
.replaceAll(/\p{N}/gu, ''); // strip numbers
if (pattern.test(text)) {
return lang; return lang;
} }
} }

View file

@ -1,8 +1,9 @@
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; 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 { SetRequired } from 'type-fest';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
@ -18,6 +19,9 @@ interface UpdateStatsOpts {
export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise<void> { export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise<void> {
switch (event.kind) { switch (event.kind) {
case 1: case 1:
case 20:
case 1111:
case 30023:
return handleEvent1(kysely, event, x); return handleEvent1(kysely, event, x);
case 3: case 3:
return handleEvent3(kysely, event, x, store); return handleEvent3(kysely, event, x, store);
@ -34,7 +38,34 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp
/** Update stats for kind 1 event. */ /** Update stats for kind 1 event. */
async function handleEvent1(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> { 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 > Conf.streakWindow) {
// 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 replyId = findReplyTag(event.tags)?.[1];
const quoteId = findQuoteTag(event.tags)?.[1]; const quoteId = findQuoteTag(event.tags)?.[1];
@ -187,9 +218,9 @@ export function getAuthorStats(
export async function updateAuthorStats( export async function updateAuthorStats(
kysely: Kysely<DittoTables>, kysely: Kysely<DittoTables>,
pubkey: string, pubkey: string,
fn: (prev: DittoTables['author_stats']) => UpdateObject<DittoTables, 'author_stats'>, fn: (prev: Insertable<DittoTables['author_stats']>) => UpdateObject<DittoTables, 'author_stats'>,
): Promise<void> { ): Promise<void> {
const empty: DittoTables['author_stats'] = { const empty: Insertable<DittoTables['author_stats']> = {
pubkey, pubkey,
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,
@ -290,6 +321,8 @@ export async function countAuthorStats(
following_count: getTagSet(followList?.tags ?? [], 'p').size, following_count: getTagSet(followList?.tags ?? [], 'p').size,
notes_count, notes_count,
search, search,
streak_start: null,
streak_end: null,
}; };
} }

View file

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