From 40bf1751dfc5bf9356d0695da170ad770d6f3d45 Mon Sep 17 00:00:00 2001 From: arcabotai <261107832+arcabotai@users.noreply.github.com> Date: Fri, 29 May 2026 21:08:08 -0400 Subject: [PATCH] feat(workers): reconstruct scheduled-cast senders (gated off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the two scheduled-send workers that were stubbed out of the fork: - src/scripts/sendScheduledDrafts.ts: sends due Drafts (sendStatus=SCHEDULED, nextScheduledAt<=now) via existing @/utils/drafts sendDraftToFarcaster, which handles recurring reschedule + scheduled reactions + error states. - src/scripts/sendScheduledCasts.ts: sends due ScheduledCast rows (status=PENDING, scheduledAt<=now) directly via the Neynar client, with idempotency keys. Standalone casts only; thread-grouped casts are a follow-up. Both routes keep the CRON_SECRET guard and are gated behind CASTORA_ENABLE_SCHEDULED_SENDER (off by default) AND are NOT in vercel.json crons, so they are fully inert until deliberately enabled. typecheck passes; routes compile and 401 without auth. End-to-end posting intentionally untested (would post real casts) — verify on a test account before enabling. No changelog entry yet: not user-facing until turned on. --- AGENTS.md | 17 +++-- .../api/cast/send-scheduled-drafts/route.ts | 19 ++++- src/app/api/cast/send-scheduled/route.ts | 21 ++++-- src/scripts/sendScheduledCasts.ts | 75 +++++++++++++++++++ src/scripts/sendScheduledDrafts.ts | 47 ++++++++++++ 5 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 src/scripts/sendScheduledCasts.ts create mode 100644 src/scripts/sendScheduledDrafts.ts diff --git a/AGENTS.md b/AGENTS.md index dc9b3dc..ccd1d51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,13 +61,16 @@ npm run dev # http://localhost:3000 ## Roadmap / known work -- **Disabled workers (`src/scripts/*` were never in this fork).** Five routes are gated stubs that - return `501 "...disabled in Castora bootstrap"`: `index-superanon-scores-from-dune`, - `cancel-expired-subscriptions`, `send-expiration-reminders`, `cast/send-scheduled`, - `cast/send-scheduled-drafts`. Re-enabling means reconstructing the modules (send logic exists in - `@/utils/drafts`). The cron auth + `CRON_SECRET` (set in Vercel prod/dev) are already in place. - Be careful: these post casts / cancel Stripe subscriptions / send DMs — test behind a flag, and - only add to `vercel.json` crons with explicit sign-off. +- **Workers (`src/scripts/*` were never in this fork; being reconstructed one by one).** All are + gated by `CRON_SECRET` (`isCronAuthorized`, set in Vercel prod/dev). Status: + - ✅ **`cast/send-scheduled-drafts`** → `sendScheduledDrafts` (Draft table; recurring + reactions + via `@/utils/drafts`) and **`cast/send-scheduled`** → `sendScheduledCasts` (ScheduledCast table, + standalone casts only — threads TODO). Both **reconstructed but OFF**: gated behind + `CASTORA_ENABLE_SCHEDULED_SENDER=true` AND not in `vercel.json` crons, so they post nothing until + you set the flag + add the cron. End-to-end posting was NOT tested (would post real casts) — + verify against a test account / empty schedule before enabling. + - ⬜ `cancel-expired-subscriptions` (Stripe — cancel logic exists in `stripe/cancel-subscription`; build for review, keep disabled). + - ⬜ `send-expiration-reminders` (DMs via `@/utils/direct-casts` — needs a Farcaster Direct Cast API key; see Direct Casts note below). - **`index-superanon-scores-from-dune` is SHELVED** (kept, not removed). Superanon was a Supercast-only feature; its account `@superanon` (fid 862100) has been dormant since 2025-10-28 and the Dune query was never in this fork, so there is no live data to index. The diff --git a/src/app/api/cast/send-scheduled-drafts/route.ts b/src/app/api/cast/send-scheduled-drafts/route.ts index 51fbe4b..51dfcbf 100644 --- a/src/app/api/cast/send-scheduled-drafts/route.ts +++ b/src/app/api/cast/send-scheduled-drafts/route.ts @@ -1,15 +1,26 @@ import type { NextRequest } from 'next/server'; import { isCronAuthorized } from '@/utils/auth/requireCronAuth'; +import { sendScheduledDrafts } from '@/scripts/sendScheduledDrafts'; export const maxDuration = 240; +// Reconstructed scheduled-draft sender. OFF by default and NOT wired into +// vercel.json crons, so it never runs automatically and posts nothing until +// deliberately enabled. To turn on: set CASTORA_ENABLE_SCHEDULED_SENDER=true +// and add a cron entry to vercel.json (e.g. */5 * * * *). Always requires +// CRON_SECRET — Vercel attaches it to cron invocations automatically. export async function GET(request: NextRequest) { if (!isCronAuthorized(request)) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - return Response.json( - { success: false, error: 'sendScheduledDrafts worker is disabled in Castora bootstrap' }, - { status: 501 }, - ); + if (process.env.CASTORA_ENABLE_SCHEDULED_SENDER !== 'true') { + return Response.json( + { success: false, error: 'scheduled sender disabled (set CASTORA_ENABLE_SCHEDULED_SENDER=true to enable)' }, + { status: 503 }, + ); + } + + const result = await sendScheduledDrafts(); + return Response.json({ success: true, ...result }); } diff --git a/src/app/api/cast/send-scheduled/route.ts b/src/app/api/cast/send-scheduled/route.ts index 6117760..10a5c68 100644 --- a/src/app/api/cast/send-scheduled/route.ts +++ b/src/app/api/cast/send-scheduled/route.ts @@ -1,15 +1,26 @@ import type { NextRequest } from 'next/server'; import { isCronAuthorized } from '@/utils/auth/requireCronAuth'; +import { sendScheduledCasts } from '@/scripts/sendScheduledCasts'; export const maxDuration = 240; -export function GET(request: NextRequest) { +// Reconstructed scheduled-cast sender (the ScheduledCast table). OFF by default +// and NOT wired into vercel.json crons, so it never runs automatically and posts +// nothing until deliberately enabled. To turn on: set +// CASTORA_ENABLE_SCHEDULED_SENDER=true and add a cron entry to vercel.json. +// Always requires CRON_SECRET — Vercel attaches it to cron invocations. +export async function GET(request: NextRequest) { if (!isCronAuthorized(request)) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - return Response.json( - { success: false, error: 'sendScheduledCasts worker is disabled in Castora bootstrap' }, - { status: 501 }, - ); + if (process.env.CASTORA_ENABLE_SCHEDULED_SENDER !== 'true') { + return Response.json( + { success: false, error: 'scheduled sender disabled (set CASTORA_ENABLE_SCHEDULED_SENDER=true to enable)' }, + { status: 503 }, + ); + } + + const result = await sendScheduledCasts(); + return Response.json({ success: true, ...result }); } diff --git a/src/scripts/sendScheduledCasts.ts b/src/scripts/sendScheduledCasts.ts new file mode 100644 index 0000000..39827a3 --- /dev/null +++ b/src/scripts/sendScheduledCasts.ts @@ -0,0 +1,75 @@ +import { prisma } from '@/prisma/client' +import { SCHEDULED_CAST_STATUS } from '@prisma/client' +import { neynar } from '@/lib/neynar' + +/** + * Sends one-off scheduled casts (the `ScheduledCast` table, distinct from rich + * Drafts). A cast is due when `status === PENDING` and `scheduledAt <= now`. + * + * v1 handles standalone casts only. Thread-grouped casts (`scheduledThreadId` + * set) need ordered, parent-chained posting and are skipped here — see the + * roadmap in AGENTS.md. Each cast is posted with an idempotency key, so a retry + * of this worker won't double-post. + * + * Gated by the route behind CRON_SECRET + the CASTORA_ENABLE_SCHEDULED_SENDER + * flag; this module never runs on import. + */ +export async function sendScheduledCasts() { + const now = new Date() + + const dueCasts = await prisma.scheduledCast.findMany({ + where: { + status: SCHEDULED_CAST_STATUS.PENDING, + scheduledAt: { lte: now }, + scheduledThreadId: null, // standalone casts only (threads are a follow-up) + }, + include: { SupercastFarcasterAccount: true }, + }) + + let sent = 0 + let failed = 0 + let skipped = 0 + + for (const cast of dueCasts) { + const account = cast.SupercastFarcasterAccount + + if (!account?.signerUUID) { + skipped++ + console.warn(`sendScheduledCasts: cast ${cast.id} has no approved signer; skipping`) + continue + } + + // Neynar embeds: URL embeds as { url }, plus any recast/quote embeds stored + // as JSON (already in Neynar's embed shape from the scheduling request). + const embeds = [ + ...cast.embedURLs.map((url) => ({ url })), + ...(Array.isArray(cast.embedRecasts) ? cast.embedRecasts : []), + ] + + const castData = { + text: cast.text, + signer_uuid: account.signerUUID, + parent: cast.parentURL || '', + embeds, + idem: cast.id.slice(0, 16), + } + + try { + await neynar.post('/v2/farcaster/cast/', castData) + await prisma.scheduledCast.update({ + where: { id: cast.id }, + data: { status: SCHEDULED_CAST_STATUS.SUCCESS }, + }) + sent++ + } catch (error) { + await prisma.scheduledCast.update({ + where: { id: cast.id }, + data: { status: SCHEDULED_CAST_STATUS.FAILED }, + }) + failed++ + console.error(`sendScheduledCasts: cast ${cast.id} failed:`, (error as Error).message) + } + } + + return { due: dueCasts.length, sent, failed, skipped } +} diff --git a/src/scripts/sendScheduledDrafts.ts b/src/scripts/sendScheduledDrafts.ts new file mode 100644 index 0000000..7fc0d69 --- /dev/null +++ b/src/scripts/sendScheduledDrafts.ts @@ -0,0 +1,47 @@ +import { prisma } from '@/prisma/client' +import { DRAFT_SEND_STATUS } from '@prisma/client' +import { sendDraftToFarcaster } from '@/utils/drafts' + +/** + * Sends scheduled Drafts whose time has come. + * + * A Draft is due when `sendStatus === SCHEDULED` and `nextScheduledAt <= now`. + * `sendDraftToFarcaster` does the heavy lifting: it posts via Neynar, fires any + * scheduled reactions, advances `nextScheduledAt` for recurring drafts (or marks + * one-offs SENT), and flips the draft to ERROR on failure — so this worker just + * selects the due rows and delegates. Failures are isolated per draft. + * + * Gated by the route behind CRON_SECRET + the CASTORA_ENABLE_SCHEDULED_SENDER + * flag; this module never runs on import. + */ +export async function sendScheduledDrafts() { + const now = new Date() + + const dueDrafts = await prisma.draft.findMany({ + where: { + sendStatus: DRAFT_SEND_STATUS.SCHEDULED, + nextScheduledAt: { lte: now }, + }, + include: { + author: true, + creator: true, + parentDraft: true, + replyDraft: true, + }, + }) + + let sent = 0 + let failed = 0 + + for (const draft of dueDrafts) { + try { + await sendDraftToFarcaster(draft) + sent++ + } catch (error) { + failed++ + console.error(`sendScheduledDrafts: draft ${draft.id} failed:`, (error as Error).message) + } + } + + return { due: dueDrafts.length, sent, failed } +}