Merge branch 'zaps' into 'main'
Zaps See merge request soapbox-pub/ditto!102
This commit is contained in:
commit
6ef76af0ca
|
@ -64,6 +64,7 @@ import {
|
|||
statusController,
|
||||
unbookmarkController,
|
||||
unpinController,
|
||||
zapController,
|
||||
} from './controllers/api/statuses.ts';
|
||||
import { streamingController } from './controllers/api/streaming.ts';
|
||||
import {
|
||||
|
@ -168,6 +169,7 @@ app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkC
|
|||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController);
|
||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController);
|
||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController);
|
||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requirePubkey, zapController);
|
||||
app.post('/api/v1/statuses', requirePubkey, createStatusController);
|
||||
|
||||
app.post('/api/v1/media', requireRole('user', { validatePayload: false }), mediaController);
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import { type AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
|
||||
import { type Event, ISO6391, z } from '@/deps.ts';
|
||||
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
|
||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||
import { addTag, deleteTag } from '@/tags.ts';
|
||||
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||
import { renderEventAccounts } from '@/views.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { getLnurl } from '@/utils/lnurl.ts';
|
||||
|
||||
const createStatusSchema = z.object({
|
||||
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
|
||||
|
@ -261,6 +264,47 @@ const unpinController: AppController = async (c) => {
|
|||
}
|
||||
};
|
||||
|
||||
const zapSchema = z.object({
|
||||
amount: z.number().int().positive(),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
const zapController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await parseBody(c.req.raw);
|
||||
const params = zapSchema.safeParse(body);
|
||||
|
||||
if (!params.success) {
|
||||
return c.json({ error: 'Bad request', schema: params.error }, 400);
|
||||
}
|
||||
|
||||
const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
|
||||
const author = target?.author;
|
||||
const meta = jsonMetaContentSchema.parse(author?.content);
|
||||
const lnurl = getLnurl(meta);
|
||||
|
||||
if (target && lnurl) {
|
||||
await createEvent({
|
||||
kind: 9734,
|
||||
content: params.data.comment ?? '',
|
||||
tags: [
|
||||
['e', target.id],
|
||||
['p', target.pubkey],
|
||||
['amount', params.data.amount.toString()],
|
||||
['relays', Conf.relay],
|
||||
['lnurl', lnurl],
|
||||
],
|
||||
}, c);
|
||||
|
||||
const status = await renderStatus(target, c.get('pubkey'));
|
||||
status.zapped = true;
|
||||
|
||||
return c.json(status);
|
||||
} else {
|
||||
return c.json({ error: 'Event not found.' }, 404);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
bookmarkController,
|
||||
contextController,
|
||||
|
@ -272,4 +316,5 @@ export {
|
|||
statusController,
|
||||
unbookmarkController,
|
||||
unpinController,
|
||||
zapController,
|
||||
};
|
||||
|
|
|
@ -87,8 +87,10 @@ export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0';
|
|||
// @deno-types="npm:@types/debug@^4.1.12"
|
||||
export { default as Debug } from 'npm:debug@^4.3.4';
|
||||
export {
|
||||
LNURL,
|
||||
type LNURLDetails,
|
||||
type MapCache,
|
||||
NIP05,
|
||||
} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/46be9e985950547574b1735d0ae52a6a7217d056/mod.ts';
|
||||
} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/5d711597f3b2a163817cc1fb0f1f3ce8cede7cf7/mod.ts';
|
||||
|
||||
export type * as TypeFest from 'npm:type-fest@^4.3.0';
|
||||
|
|
|
@ -10,7 +10,7 @@ const debug = Debug('ditto:firehose');
|
|||
// side-effects based on them, such as trending hashtag tracking
|
||||
// and storing events for notifications and the home feed.
|
||||
pool.subscribe(
|
||||
[{ kinds: [0, 1, 3, 5, 6, 7, 10002], limit: 0, since: nostrNow() }],
|
||||
[{ kinds: [0, 1, 3, 5, 6, 7, 9735, 10002], limit: 0, since: nostrNow() }],
|
||||
activeRelays,
|
||||
handleEvent,
|
||||
undefined,
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Conf } from '@/config.ts';
|
||||
import { encryptAdmin } from '@/crypto.ts';
|
||||
import { addRelays } from '@/db/relays.ts';
|
||||
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
||||
import { findUser } from '@/db/users.ts';
|
||||
import { Debug, type Event } from '@/deps.ts';
|
||||
import { Debug, type Event, LNURL } from '@/deps.ts';
|
||||
import { isEphemeralKind } from '@/kinds.ts';
|
||||
import { isLocallyFollowed } from '@/queries.ts';
|
||||
import { updateStats } from '@/stats.ts';
|
||||
|
@ -10,9 +11,12 @@ import { client, eventsDB, memorelay, reqmeister } from '@/storages.ts';
|
|||
import { Sub } from '@/subs.ts';
|
||||
import { getTagSet } from '@/tags.ts';
|
||||
import { type EventData } from '@/types.ts';
|
||||
import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts';
|
||||
import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts';
|
||||
import { fetchWorker } from '@/workers/fetch.ts';
|
||||
import { TrendsWorker } from '@/workers/trends.ts';
|
||||
import { verifySignatureWorker } from '@/workers/verify.ts';
|
||||
import { signAdminEvent } from '@/sign.ts';
|
||||
import { lnurlCache } from '@/utils/lnurl.ts';
|
||||
|
||||
const debug = Debug('ditto:pipeline');
|
||||
|
||||
|
@ -21,6 +25,7 @@ const debug = Debug('ditto:pipeline');
|
|||
* It is idempotent, so it can be called multiple times for the same event.
|
||||
*/
|
||||
async function handleEvent(event: Event): Promise<void> {
|
||||
const signal = AbortSignal.timeout(5000);
|
||||
if (!(await verifySignatureWorker(event))) return;
|
||||
const wanted = reqmeister.isWanted(event);
|
||||
if (await encounterEvent(event)) return;
|
||||
|
@ -32,8 +37,9 @@ async function handleEvent(event: Event): Promise<void> {
|
|||
processDeletions(event),
|
||||
trackRelays(event),
|
||||
trackHashtags(event),
|
||||
fetchRelatedEvents(event, data),
|
||||
fetchRelatedEvents(event, data, signal),
|
||||
processMedia(event, data),
|
||||
payZap(event, data, signal),
|
||||
streamOut(event, data),
|
||||
broadcast(event, data),
|
||||
]);
|
||||
|
@ -138,9 +144,9 @@ function trackRelays(event: Event) {
|
|||
}
|
||||
|
||||
/** Queue related events to fetch. */
|
||||
function fetchRelatedEvents(event: Event, data: EventData) {
|
||||
function fetchRelatedEvents(event: Event, data: EventData, signal: AbortSignal) {
|
||||
if (!data.user) {
|
||||
reqmeister.req({ kinds: [0], authors: [event.pubkey] }).catch(() => {});
|
||||
reqmeister.req({ kinds: [0], authors: [event.pubkey] }, { signal }).catch(() => {});
|
||||
}
|
||||
for (const [name, id, relay] of event.tags) {
|
||||
if (name === 'e' && !memorelay.count([{ ids: [id] }])) {
|
||||
|
@ -157,6 +163,51 @@ function processMedia({ tags, pubkey }: Event, { user }: EventData) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Emit Nostr Wallet Connect event from zaps so users may pay. */
|
||||
async function payZap(event: Event, data: EventData, signal: AbortSignal) {
|
||||
if (event.kind !== 9734 || !data.user) return;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const { pr } = await LNURL.callback(
|
||||
details.callback,
|
||||
{ amount, nostr: event, lnurl },
|
||||
{ fetch: fetchWorker, signal },
|
||||
);
|
||||
|
||||
const nwcRequestEvent = await signAdminEvent({
|
||||
kind: 23194,
|
||||
content: await encryptAdmin(
|
||||
event.pubkey,
|
||||
JSON.stringify({ method: 'pay_invoice', params: { invoice: pr } }),
|
||||
),
|
||||
created_at: nostrNow(),
|
||||
tags: [
|
||||
['p', event.pubkey],
|
||||
['e', event.id],
|
||||
],
|
||||
});
|
||||
|
||||
await handleEvent(nwcRequestEvent);
|
||||
} catch (e) {
|
||||
debug('lnurl error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if the event is being received in a timely manner. */
|
||||
const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10);
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ interface GetEventOpts<K extends number> {
|
|||
const getEvent = async <K extends number = number>(
|
||||
id: string,
|
||||
opts: GetEventOpts<K> = {},
|
||||
): Promise<Event<K> | undefined> => {
|
||||
): Promise<DittoEvent<K> | undefined> => {
|
||||
debug(`getEvent: ${id}`);
|
||||
const { kind, relations, signal = AbortSignal.timeout(1000) } = opts;
|
||||
const microfilter: IdMicrofilter = { ids: [id] };
|
||||
|
|
|
@ -70,6 +70,7 @@ const metaContentSchema = z.object({
|
|||
picture: z.string().optional().catch(undefined),
|
||||
banner: z.string().optional().catch(undefined),
|
||||
nip05: z.string().optional().catch(undefined),
|
||||
lud06: z.string().optional().catch(undefined),
|
||||
lud16: z.string().optional().catch(undefined),
|
||||
}).partial().passthrough();
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ const tagConditions: Record<string, TagCondition> = {
|
|||
'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind),
|
||||
'e': ({ event, count, value, opts }) => ((opts.data?.user && event.kind === 10003) || count < 15) && isNostrId(value),
|
||||
'media': ({ count, value, opts }) => (opts.data?.user || count < 4) && isURL(value),
|
||||
'P': ({ event, count, value }) => event.kind === 9735 && count === 0 && isNostrId(value),
|
||||
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
||||
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
||||
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { Debug, LNURL, type LNURLDetails } from '@/deps.ts';
|
||||
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
||||
import { Time } from '@/utils/time.ts';
|
||||
import { fetchWorker } from '@/workers/fetch.ts';
|
||||
|
||||
const debug = Debug('ditto:lnurl');
|
||||
|
||||
const lnurlCache = new SimpleLRU<string, LNURLDetails>(
|
||||
async (lnurl, { signal }) => {
|
||||
debug(`Lookup ${lnurl}`);
|
||||
try {
|
||||
const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal });
|
||||
debug(`Found: ${lnurl}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
debug(`Not found: ${lnurl}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
{ max: 1000, ttl: Time.minutes(30) },
|
||||
);
|
||||
|
||||
/** Get an LNURL from a lud06 or lud16. */
|
||||
function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: number): string | undefined {
|
||||
if (lud06) return lud06;
|
||||
if (lud16) {
|
||||
const [name, host] = lud16.split('@');
|
||||
if (name && host) {
|
||||
const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`);
|
||||
return LNURL.encode(url, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { getLnurl, lnurlCache };
|
|
@ -3,6 +3,7 @@ import { findUser } from '@/db/users.ts';
|
|||
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
|
||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||
import { type DittoEvent } from '@/storages/types.ts';
|
||||
import { getLnurl } from '@/utils/lnurl.ts';
|
||||
import { nip05Cache } from '@/utils/nip05.ts';
|
||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||
|
@ -24,6 +25,8 @@ async function renderAccount(
|
|||
picture = Conf.local('/images/avi.png'),
|
||||
banner = Conf.local('/images/banner.png'),
|
||||
about,
|
||||
lud06,
|
||||
lud16,
|
||||
} = jsonMetaContentSchema.parse(event.content);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
@ -67,6 +70,9 @@ async function renderAccount(
|
|||
statuses_count: event.author_stats?.notes_count ?? 0,
|
||||
url: Conf.local(`/users/${pubkey}`),
|
||||
username: parsed05?.nickname || npub.substring(0, 8),
|
||||
ditto: {
|
||||
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
||||
},
|
||||
pleroma: {
|
||||
is_admin: user?.admin || false,
|
||||
is_moderator: user?.admin || false,
|
||||
|
|
|
@ -38,6 +38,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) {
|
|||
? await eventsDB.filter([
|
||||
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
])
|
||||
|
@ -48,6 +49,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) {
|
|||
const repostEvent = relatedEvents.find((event) => event.kind === 6);
|
||||
const pinEvent = relatedEvents.find((event) => event.kind === 10001);
|
||||
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
|
||||
const zapEvent = relatedEvents.find((event) => event.kind === 9734);
|
||||
|
||||
const content = buildInlineRecipients(mentions) + html;
|
||||
|
||||
|
@ -91,6 +93,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) {
|
|||
poll: null,
|
||||
uri: Conf.local(`/posts/${event.id}`),
|
||||
url: Conf.local(`/posts/${event.id}`),
|
||||
zapped: Boolean(zapEvent),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue