Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions src/app/api/cast/send-scheduled-drafts/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
21 changes: 16 additions & 5 deletions src/app/api/cast/send-scheduled/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
75 changes: 75 additions & 0 deletions src/scripts/sendScheduledCasts.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
47 changes: 47 additions & 0 deletions src/scripts/sendScheduledDrafts.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading