From 782c0a119f2e7927bee006734ad02fa4d9255a4a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Thu, 24 Sep 2026 19:26:09 +0200 Subject: [PATCH] Merge pull request #53 from TaprootFreakAI/feat/01a0d378-welcome-about-photo 01a0d378 - Count an About-me photo as welcome media --- README.md | 4 +- src/__tests__/gifts-api.test.ts | 69 +++++++++++++++++++ src/__tests__/run.test.ts | 116 ++++++++++++++++++++++++++++++++ src/gifts-api.ts | 25 +++++-- src/run.ts | 26 +++++-- 5 files changed, 227 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5ff40fa..96d8f07 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Amount is the listed `amountUsd` with comment `21gifts moderator`. Same in-proce ### Welcome -Body: JSON `{ "address": string, "kind": "welcome", "messageId": string }`. `messageId` is a required UUID (same 400 as daily): missing or invalid → `400` `{ "error": "Expected a JSON body with address and messageId" }`. A stray `groupMessageId` is ignored. After address parsing, load the live file for the daily `paymentsEnabled` switch only; an unreadable list → `500` `{ "error": "Recipient list is unreadable" }`. Not gated by the daily roster or the funding grant. Off-roster is still accepted. Amount is **1 USD** with invoice comment **`Welcome`**, once per Lightning Address **lifetime** (`STATE_DIR/welcome.jsonl`, optional `welcome.finished`). Daily `paymentsEnabled` applies (`payments_disabled` when off). Independent of `moderatorPaymentsEnabled`. Passkey + living-room `hasMedia` like daily (`no_media` skip, no JSONL). No `postedAt` UTC-day match. No `GET /invoices/eligible` on the payout. The gifts API renders the gift reply from that comment. +Body: JSON `{ "address": string, "kind": "welcome", "messageId": string }`. `messageId` is a required UUID (same 400 as daily): missing or invalid → `400` `{ "error": "Expected a JSON body with address and messageId" }`. A stray `groupMessageId` is ignored. After address parsing, load the live file for the daily `paymentsEnabled` switch only; an unreadable list → `500` `{ "error": "Recipient list is unreadable" }`. Not gated by the daily roster or the funding grant. Off-roster is still accepted. Amount is **1 USD** with invoice comment **`Welcome`**, once per Lightning Address **lifetime** (`STATE_DIR/welcome.jsonl`, optional `welcome.finished`). Daily `paymentsEnabled` applies (`payments_disabled` when off). Independent of `moderatorPaymentsEnabled`. Passkey required. Media is `welcomeHasMedia` when the api sends that boolean (an About-me photo counts and `hasPosted` may be false); when the field is absent, living-room `hasPosted` and `hasMedia` like daily. `no_media` skip, no JSONL. No `postedAt` UTC-day match. No `GET /invoices/eligible` on the payout. The gifts API renders the gift reply from that comment. Match `welcome.jsonl` case-insensitively; the first persisted address is the payout key, otherwise the parsed address. Own-address `paid` / persisted `failed` / own-address `uncertain` in `welcome.jsonl` skip with that reason; daily and moderator files do not skip a welcome ping. A `*halt*` row or another address's `uncertain` in `welcome.jsonl` does not skip this ping. After those skips, `paymentsEnabled` `false` → `200` `{ "status": "skipped", "reason": "payments_disabled" }` (does not write JSONL and does not start a payout). Otherwise `202` `{ "status": "accepted" }` without waiting for Lightning. @@ -118,7 +118,7 @@ Deploy workflows require GitHub Actions secrets `DOCKER_USERNAME`, `DOCKER_PASSW - Invoice-create network/5xx **before any pay** (no invoice id) is `invoice_unreachable`: skipped, not persisted, no `*halt*`, no `.finished`, exit `3`. Later recipients in the same run are still attempted. A later `POST /ping` for that address can retry - `uncertain` covers two cases that both halt the rest of a **daily** live run (`--live` or `SPEND_LIVE=true`), append a `*halt*` JSONL row, `markFinished`, exit `4`, and are **not** retried the same UTC day: (1) invoice-create parse failures (`malformed invoice response` / `malformed paymentHash`) without a pay; (2) after an invoice id / pay attempt (amount mismatch, lndhub.pay error, missing/mismatched preimage, proof failure). Moderator and welcome live runs do not append `*halt*` and do not stop later addresses in the same run - Persisted `failed` (invoice 4xx) is not paid again the same UTC day, does not count in the balance preflight sum, and for daily, when every live recipient is paid/uncertain/failed, `.finished` is set. For moderator, `.moderator.finished` is set when the synthetic stipend list of that run is settled -- Only Lightning Addresses that currently have a passkey on 21.gifts and a live non-profile forum post are paid. Daily also requires a photo or video on that post (`GET /invoices/posted` `hasMedia: true`). Welcome uses the same passkey + `hasMedia` checks (no `postedAt` UTC-day match). The moderator $5 stipend is unchanged: living-room `hasPosted` plus `postedAt` on this UTC day, and **no** `hasMedia` requirement. CLI and other non-ping roster runs also require `{ eligible: true }` from `GET /invoices/eligible` (live `GET /invoices/passkey` and `GET /invoices/posted` each run; eligible only when `checkFundingEligible` is not false). Listed daily pings, all moderator pings, and welcome pings still do not call `GET /invoices/eligible` — the gifts API already gated `eligibleToday` before pinging, and `checkFundingEligible` stays false on ping payout (welcome never calls eligible even if that option is omitted or true). The same-day `insufficient_balance` retry also keeps `checkFundingEligible` false and does not call `GET /invoices/eligible` again. Unlisted daily pings call `GET /invoices/eligible` only to read grant `status` (`admitted`/`trial` → 1 USD; other statuses → `not_listed`; lookup fail → skip `eligible_unreachable`, HTTP 200, not process exit 3). Welcome pings do not look up the grant. CLI / roster runs unchanged: still require `{ eligible: true }` when `checkFundingEligible` is not false; lookup fail on those runs remains abort exit `3` `eligible_unreachable`. Addresses without a passkey (or without an account) are skipped as `no_passkey` with no JSONL row so a later ping can retry the same UTC day; addresses with a passkey but no live forum post are skipped as `no_post` the same way; daily and welcome addresses with a passkey and a live forum post but without photo/video (`hasMedia` not true) skip as `no_media` the same way (no JSONL, retry later; missing or non-true `hasMedia` is that skip, not `posted_unreachable`); addresses the api reports as not eligible today are skipped as `not_eligible` the same way. Invoice-create `403` `Forum post required` is the same `no_post` skip (not persisted `failed`). A failed passkey lookup aborts the whole run with exit `3` (`passkey_unreachable`) and pays no one; a failed posted lookup does the same as `posted_unreachable`; a failed eligible lookup on a CLI/roster run does the same as `eligible_unreachable` +- Daily payouts require a passkey and a live non-profile forum post, plus a photo or video on that post (`GET /invoices/posted` `hasMedia: true`). Welcome requires a passkey and the welcome media gate: `welcomeHasMedia` when the api sends a boolean (an About-me photo counts and `hasPosted` may be false); when the field is absent, `hasPosted` and `hasMedia`. No `postedAt` UTC-day match. The moderator $5 stipend is unchanged: living-room `hasPosted` plus `postedAt` on this UTC day, and **no** `hasMedia` requirement. CLI and other non-ping roster runs also require `{ eligible: true }` from `GET /invoices/eligible` (live `GET /invoices/passkey` and `GET /invoices/posted` each run; eligible only when `checkFundingEligible` is not false). Listed daily pings, all moderator pings, and welcome pings still do not call `GET /invoices/eligible` — the gifts API already gated `eligibleToday` before pinging, and `checkFundingEligible` stays false on ping payout (welcome never calls eligible even if that option is omitted or true). The same-day `insufficient_balance` retry also keeps `checkFundingEligible` false and does not call `GET /invoices/eligible` again. Unlisted daily pings call `GET /invoices/eligible` only to read grant `status` (`admitted`/`trial` → 1 USD; other statuses → `not_listed`; lookup fail → skip `eligible_unreachable`, HTTP 200, not process exit 3). Welcome pings do not look up the grant. CLI / roster runs unchanged: still require `{ eligible: true }` when `checkFundingEligible` is not false; lookup fail on those runs remains abort exit `3` `eligible_unreachable`. Addresses without a passkey (or without an account) are skipped as `no_passkey` with no JSONL row so a later ping can retry the same UTC day; daily and moderator addresses with a passkey but no live non-profile forum post are skipped as `no_post` the same way; daily addresses with a passkey and a live forum post but without photo/video (`hasMedia` not true) skip as `no_media` the same way (no JSONL, retry later; missing or non-true `hasMedia` is that skip, not `posted_unreachable`). Welcome does not use `no_post`. Welcome `no_media` follows `welcomeHasMedia` when that field is a boolean, otherwise `hasPosted && hasMedia`; addresses the api reports as not eligible today are skipped as `not_eligible` the same way. Invoice-create `403` `Forum post required` is `no_post` for daily and moderator, and `no_media` for welcome (not persisted `failed`). A failed passkey lookup aborts the whole run with exit `3` (`passkey_unreachable`) and pays no one; a failed posted lookup does the same as `posted_unreachable`; a failed eligible lookup on a CLI/roster run does the same as `eligible_unreachable` - If a live process crashes, the next run steals the leftover lock once the owner pid is gone, or when this process reused the pid but the lock timestamp predates this incarnation. A lock whose pid belongs to a different live process is never stolen. Steal is serialized by a virgin `O_EXCL` `{day}.taking` file; an existing taking file is never replaced. Remove a lock or taking file by hand only after checking that no spend is running and inspecting `STATE_DIR/YYYY-MM-DD.jsonl`, `STATE_DIR/YYYY-MM-DD.moderator.jsonl`, and `STATE_DIR/welcome.jsonl` - Unreadable JSONL (truncated/corrupt line) aborts with exit `4` (`corrupt_state`) so a damaged `paid`/`uncertain` row cannot be ignored - State: `STATE_DIR/YYYY-MM-DD.jsonl`, `STATE_DIR/YYYY-MM-DD.finished`, `STATE_DIR/YYYY-MM-DD.moderator.jsonl`, `STATE_DIR/YYYY-MM-DD.moderator.finished`, `STATE_DIR/welcome.jsonl`, `STATE_DIR/welcome.finished`, `STATE_DIR/YYYY-MM-DD.retry.jsonl` (owed-address queue for same-UTC-day `insufficient_balance` retries; not a payout log), `STATE_DIR/YYYY-MM-DD.lock` while a run is in progress, and `STATE_DIR/YYYY-MM-DD.taking` (`O_EXCL`, owner pid) briefly while a leftover lock is stolen diff --git a/src/__tests__/gifts-api.test.ts b/src/__tests__/gifts-api.test.ts index e875109..ab808c2 100644 --- a/src/__tests__/gifts-api.test.ts +++ b/src/__tests__/gifts-api.test.ts @@ -180,6 +180,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); expect(seenUrl).toBe('https://api.21.gifts/invoices/posted?address=a%40b.com'); expect(auth).toBe('Bearer tok'); @@ -194,6 +196,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); }); @@ -206,6 +210,8 @@ describe('GiftsApi', () => { hasMedia: true, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); }); @@ -218,6 +224,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); }); @@ -238,6 +246,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); } }); @@ -254,6 +264,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); }); @@ -275,6 +287,8 @@ describe('GiftsApi', () => { hasMedia: false, messageId: null, postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); } }); @@ -295,6 +309,61 @@ describe('GiftsApi', () => { hasMedia: false, messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', postedAt: '2026-08-23T12:00:00.000Z', + welcomeHasMedia: null, + welcomeMessageId: null, + }); + }); + + it('hasPosted returns welcome media when the api sends it', async () => { + const api = new GiftsApi('https://api.21.gifts', 'tok', async () => + new Response( + JSON.stringify({ + hasPosted: false, + hasMedia: false, + welcomeHasMedia: true, + welcomeMessageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }), + { status: 200 }, + ), + ); + await expect(api.hasPosted('a@b.com')).resolves.toEqual({ + hasPosted: false, + hasMedia: false, + messageId: null, + postedAt: null, + welcomeHasMedia: true, + welcomeMessageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }); + const absent = new GiftsApi('https://api.21.gifts', 'tok', async () => + new Response( + JSON.stringify({ + hasPosted: true, + welcomeHasMedia: false, + welcomeMessageId: 'nope', + }), + { status: 200 }, + ), + ); + await expect(absent.hasPosted('a@b.com')).resolves.toEqual({ + hasPosted: true, + hasMedia: false, + messageId: null, + postedAt: null, + welcomeHasMedia: false, + welcomeMessageId: null, + }); + const weird = new GiftsApi('https://api.21.gifts', 'tok', async () => + new Response(JSON.stringify({ hasPosted: true, welcomeHasMedia: 1, welcomeMessageId: 2 }), { + status: 200, + }), + ); + await expect(weird.hasPosted('a@b.com')).resolves.toEqual({ + hasPosted: true, + hasMedia: false, + messageId: null, + postedAt: null, + welcomeHasMedia: null, + welcomeMessageId: null, }); }); diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts index fcd4131..461567f 100644 --- a/src/__tests__/run.test.ts +++ b/src/__tests__/run.test.ts @@ -2437,4 +2437,120 @@ describe('runDay', () => { expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }), ]); }); + + it('welcome bucket pays an About-me photo when welcomeHasMedia is true', async () => { + let invoiceBody: unknown; + const gifts = new GiftsApi('https://api.21.gifts', 'tok', async (url, init) => { + const href = String(url); + if (href.includes('/invoices/passkey')) { + return new Response(JSON.stringify({ hasPasskey: true }), { status: 200 }); + } + if (href.includes('/invoices/posted')) { + return new Response( + JSON.stringify({ + hasPosted: false, + hasMedia: false, + messageId: null, + postedAt: null, + welcomeHasMedia: true, + welcomeMessageId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }), + { status: 200 }, + ); + } + invoiceBody = JSON.parse(String(init?.body ?? '{}')); + return new Response( + JSON.stringify({ id: 'id1', pr: 'lnbc1', paymentHash: HASH, amountMsat: 1_000_000 }), + { status: 200 }, + ); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await runDay( + { ...config, recipients: [{ address: 'a@b.com', amountUsd: 1, comment: 'Welcome' }] }, + { live: false, day: '2026-08-23', bucket: 'welcome' }, + { + gifts, + lndhub: new LndhubClient(target), + state: memoryState('', 'welcome'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); + warn.mockRestore(); + expect(result.exitCode).toBe(0); + expect(invoiceBody).toEqual({ + address: 'a@b.com', + amountMsat: 1_000_000, + amountUsd: '1.00', + comment: 'Welcome', + messageId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }); + }); + + it('welcome bucket skips no_media when welcomeHasMedia is false', async () => { + const gifts = new GiftsApi('https://api.21.gifts', 'tok', async (url) => { + const href = String(url); + if (href.includes('/invoices/passkey')) { + return new Response(JSON.stringify({ hasPasskey: true }), { status: 200 }); + } + if (href.includes('/invoices/posted')) { + return new Response( + JSON.stringify({ + hasPosted: true, + hasMedia: true, + messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + postedAt: '2026-08-23T12:00:00.000Z', + welcomeHasMedia: false, + }), + { status: 200 }, + ); + } + throw new Error('no invoice'); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await runDay( + { ...config, recipients: [{ address: 'a@b.com', amountUsd: 1, comment: 'Welcome' }] }, + { live: false, day: '2026-08-23', bucket: 'welcome' }, + { + gifts, + lndhub: new LndhubClient(target), + state: memoryState('', 'welcome'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); + warn.mockRestore(); + expect(result.exitCode).toBe(0); + expect(result.summary?.skipped).toEqual([ + expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }), + ]); + }); + + it('welcome bucket skips no_media when invoice create says Forum post required', async () => { + const gifts = new GiftsApi( + 'https://api.21.gifts', + 'tok', + giftsFetch(async () => { + return new Response(JSON.stringify({ error: 'Forum post required' }), { status: 403 }); + }), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await runDay( + { ...config, recipients: [{ address: 'a@b.com', amountUsd: 1, comment: 'Welcome' }] }, + { live: false, day: '2026-08-23', bucket: 'welcome' }, + { + gifts, + lndhub: new LndhubClient(target), + state: memoryState('', 'welcome'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); + warn.mockRestore(); + expect(result.exitCode).toBe(0); + expect(result.summary?.skipped).toEqual([ + expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }), + ]); + expect(result.summary?.failed).toEqual([]); + }); }); diff --git a/src/gifts-api.ts b/src/gifts-api.ts index a5324e2..75375bd 100644 --- a/src/gifts-api.ts +++ b/src/gifts-api.ts @@ -53,13 +53,21 @@ export class GiftsApi { * Live forum-post flag, media flag, post UUID, and post timestamp for this Lightning Address. * * @param address - LUD-16 address. - * @returns `{ hasPosted, hasMedia, messageId, postedAt }` — `hasMedia` is boolean - * (missing or non-true JSON → `false`); `messageId` is a UUID or `null`; + * @returns `{ hasPosted, hasMedia, messageId, postedAt, welcomeHasMedia, welcomeMessageId }`. + * `hasMedia` is boolean (missing or non-true JSON → `false`). `messageId` is a UUID or `null`. * `postedAt` is an ISO-8601 instant or `null` when missing or unparseable. + * `welcomeHasMedia` is the boolean from the api, or `null` when the field is missing or + * not a boolean (older api). `welcomeMessageId` is a UUID or `null`. */ - async hasPosted( - address: string, - ): Promise<{ hasPosted: boolean; hasMedia: boolean; messageId: string | null; postedAt: string | null }> { + async hasPosted(address: string): Promise<{ + hasPosted: boolean; + hasMedia: boolean; + messageId: string | null; + postedAt: string | null; + /** `null` when the api omitted the field (older api). */ + welcomeHasMedia: boolean | null; + welcomeMessageId: string | null; + }> { const path = `/invoices/posted?address=${encodeURIComponent(address)}`; const json = await this.getJson(path); const has = json['hasPosted']; @@ -74,7 +82,12 @@ export class GiftsApi { if (typeof rawAt === 'string' && !Number.isNaN(Date.parse(rawAt))) { postedAt = new Date(rawAt).toISOString(); } - return { hasPosted: has, hasMedia, messageId, postedAt }; + const rawWelcome = json['welcomeHasMedia']; + const welcomeHasMedia = typeof rawWelcome === 'boolean' ? rawWelcome : null; + const rawWelcomeId = json['welcomeMessageId']; + const welcomeMessageId = + typeof rawWelcomeId === 'string' && MESSAGE_ID_RE.test(rawWelcomeId) ? rawWelcomeId : null; + return { hasPosted: has, hasMedia, messageId, postedAt, welcomeHasMedia, welcomeMessageId }; } /** diff --git a/src/run.ts b/src/run.ts index 1f5aebc..3fd5595 100644 --- a/src/run.ts +++ b/src/run.ts @@ -27,7 +27,7 @@ export interface RunOptions { * included `groupMessageId`. Used only when {@link RunOptions.bucket} is `'moderator'`. */ groupMessageIdByAddress?: Record; - /** Default `'daily'`. Daily requires `hasPosted` and `hasMedia`. `'moderator'` uses the moderator JSONL, requires living-room `hasPosted` with `postedAt` UTC day === `options.day`, does not inspect `hasMedia`, and does not send `messageId` on `createInvoice`. `'welcome'` uses dateless `welcome.jsonl`, requires `hasPosted` and `hasMedia` like daily (no `postedAt` UTC-day match), never calls `isFundingEligible`, and forwards `messageId` on `createInvoice`. */ + /** Default `'daily'`. Daily requires `hasPosted` and `hasMedia`. `'moderator'` uses the moderator JSONL, requires living-room `hasPosted` with `postedAt` UTC day === `options.day`, does not inspect `hasMedia`, and does not send `messageId` on `createInvoice`. `'welcome'` uses dateless `welcome.jsonl`, pays when `welcomeHasMedia` is true (About-me photo counts) or, when that field is absent, when `hasPosted` and `hasMedia` are both true. No `postedAt` UTC-day match. Never calls `isFundingEligible`. Forwards `messageId` on `createInvoice`. */ bucket?: 'daily' | 'moderator' | 'welcome'; /** Default true. False on HTTP ping: API already gated eligibleToday. Welcome treats this as false even when omitted or true. */ checkFundingEligible?: boolean; @@ -95,8 +95,10 @@ function selectTargets( * moderator JSONL, requires a living-room `hasPosted` whose `postedAt` UTC day matches * {@link RunOptions.day}, does not inspect `hasMedia`, and never sends `messageId` on * `createInvoice`. When {@link RunOptions.bucket} is `'welcome'`, uses dateless - * `welcome.jsonl`, requires `hasPosted` and `hasMedia` like daily (no `postedAt` - * UTC-day match), never calls `isFundingEligible`, and forwards `messageId` like daily. + * `welcome.jsonl`. Media is `welcomeHasMedia` when the api sends a boolean + * (an About-me photo counts); when the field is absent, both `hasPosted` and + * `hasMedia` must be true. No `postedAt` UTC-day match. Never calls + * `isFundingEligible`, and forwards `messageId` like daily. * When * {@link RunOptions.groupMessageIdByAddress} has an entry for the recipient, that id * is sent as `groupMessageId` on `createInvoice` (moderator only). @@ -284,6 +286,19 @@ async function runDayLocked( noPost.add(recipient.address); continue; } + } else if (options.bucket === 'welcome') { + const welcomeMedia = + posted.welcomeHasMedia === null + ? posted.hasPosted && posted.hasMedia + : posted.welcomeHasMedia; + if (!welcomeMedia) { + noMedia.add(recipient.address); + continue; + } + postedMessageId.set( + recipient.address, + posted.welcomeMessageId ?? posted.messageId, + ); } else { if (!posted.hasPosted) { noPost.add(recipient.address); @@ -504,8 +519,9 @@ async function runDayLocked( err.status === 403 && err.message === 'Forum post required' ) { - log('spend.skip', { address: recipient.address, reason: 'no_post' }); - skipped.push({ ...lineBase, reason: 'no_post' }); + const reason = options.bucket === 'welcome' ? 'no_media' : 'no_post'; + log('spend.skip', { address: recipient.address, reason }); + skipped.push({ ...lineBase, reason }); continue; } const status = err instanceof GiftsApiError ? err.status : 0;