From 991ee2abda819ca1609dd0833e241597bf65b6d3 Mon Sep 17 00:00:00 2001 From: Clawdia Date: Tue, 24 Feb 2026 23:05:30 -0600 Subject: [PATCH 1/2] feat(spellblock-mentions): UsageCapExceeded flag-file early-exit When Twitter monthly cap is hit, apiGet() now: - Detects UsageCapExceeded (or 429) in the API error response - Writes ~/clawd/data/twitter-cap-exceeded.flag with expiry = 1st of next month - Clears the flag on any successful API response main() now checks the flag at startup (before DB connection or any API call) and exits immediately with a single warning log. This saves DB connections and wasted exec cycles on every 15-min cron tick when the cap is guaranteed to be exceeded until month rollover. Fixes: backlog [NEXT] 'Add UsageCapExceeded early-exit to spellblock-mentions.mjs' Context: Cap hit 2026-02-24 ~19:40 CT; resets ~2026-03-01 --- bot/spellblock-mentions.mjs | 73 +++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/bot/spellblock-mentions.mjs b/bot/spellblock-mentions.mjs index b8288b3..75b8835 100755 --- a/bot/spellblock-mentions.mjs +++ b/bot/spellblock-mentions.mjs @@ -10,7 +10,7 @@ * 4. Save last-seen tweet ID so we don't re-process */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { execSync } from 'child_process'; @@ -23,6 +23,50 @@ const LOG_PREFIX = '[spellblock-mentions]'; const PAYMENT_BASE = 'https://spellblock.app/enter'; const BOT_HANDLE = 'ClawdiaBotAI'; +// ── Twitter Usage Cap Guard ─────────────────────────────────────────── +const CAP_FLAG = join(process.env.HOME, 'clawd/data/twitter-cap-exceeded.flag'); + +/** + * Returns true if the monthly usage cap flag is set and not yet expired. + */ +function isCapFlagActive() { + if (!existsSync(CAP_FLAG)) return false; + try { + const expiry = new Date(readFileSync(CAP_FLAG, 'utf8').trim()); + if (isNaN(expiry.getTime())) return false; // malformed — ignore + if (new Date() < expiry) return true; + // Flag expired — clear it + unlinkSync(CAP_FLAG); + return false; + } catch { + return false; + } +} + +/** + * Write the cap flag with expiry = 1st of next month, 00:00 UTC. + */ +function writeCapFlag() { + const now = new Date(); + const expiry = new Date(Date.UTC( + now.getUTCMonth() === 11 ? now.getUTCFullYear() + 1 : now.getUTCFullYear(), + now.getUTCMonth() === 11 ? 0 : now.getUTCMonth() + 1, + 1 + )); + writeFileSync(CAP_FLAG, expiry.toISOString()); + log(`⚠️ UsageCapExceeded — flag written, skipping until ${expiry.toISOString().slice(0, 10)}`); +} + +/** + * Clear the cap flag on a successful API call. + */ +function clearCapFlag() { + if (existsSync(CAP_FLAG)) { + unlinkSync(CAP_FLAG); + log('✅ Cap flag cleared (successful API response)'); + } +} + // ── Auth ────────────────────────────────────────────────────────────── function getToken() { @@ -73,11 +117,28 @@ async function apiGet(path, params = {}) { const res2 = await fetch(url, { headers: { Authorization: `Bearer ${getToken()}` }, }); - if (!res2.ok) throw new Error(`Twitter API ${res2.status}: ${await res2.text()}`); + if (!res2.ok) { + const body2 = await res2.text(); + if (body2.includes('UsageCapExceeded') || res2.status === 429) { + writeCapFlag(); + throw new Error(`UsageCapExceeded: Twitter monthly cap hit — ${body2.slice(0, 120)}`); + } + throw new Error(`Twitter API ${res2.status}: ${body2}`); + } + clearCapFlag(); return res2.json(); } - if (!res.ok) throw new Error(`Twitter API ${res.status}: ${await res.text()}`); + if (!res.ok) { + const body = await res.text(); + // Detect monthly usage cap — write flag so future runs skip immediately + if (body.includes('UsageCapExceeded') || res.status === 429) { + writeCapFlag(); + throw new Error(`UsageCapExceeded: Twitter monthly cap hit — ${body.slice(0, 120)}`); + } + throw new Error(`Twitter API ${res.status}: ${body}`); + } + clearCapFlag(); return res.json(); } @@ -120,6 +181,12 @@ function log(...args) { async function main() { log('Starting mention scan'); + // 0. Pre-flight: skip if Twitter monthly cap is active + if (isCapFlagActive()) { + log('⏭️ Twitter monthly cap flag active — skipping scan (resets on 1st of month)'); + return; + } + // 1. Load current round const round = await db.getCurrentRound(); if (!round) { From be59928039e96c5b2926cdd70c0842235f555fbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 05:12:42 +0000 Subject: [PATCH 2/2] Fix: Only treat UsageCapExceeded as monthly cap, not all 429 responses Remove the '|| res.status === 429' fallback from the cap detection logic at both lines 122 and 135. Twitter returns HTTP 429 for both per-window rate limits (15-min cooldown) and monthly usage caps. Only the UsageCapExceeded string in the response body reliably identifies the monthly cap. The previous code would incorrectly disable the bot for up to 30 days when hitting a routine rate limit. --- bot/spellblock-mentions.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/spellblock-mentions.mjs b/bot/spellblock-mentions.mjs index 75b8835..6c862b5 100755 --- a/bot/spellblock-mentions.mjs +++ b/bot/spellblock-mentions.mjs @@ -119,7 +119,7 @@ async function apiGet(path, params = {}) { }); if (!res2.ok) { const body2 = await res2.text(); - if (body2.includes('UsageCapExceeded') || res2.status === 429) { + if (body2.includes('UsageCapExceeded')) { writeCapFlag(); throw new Error(`UsageCapExceeded: Twitter monthly cap hit — ${body2.slice(0, 120)}`); } @@ -132,7 +132,7 @@ async function apiGet(path, params = {}) { if (!res.ok) { const body = await res.text(); // Detect monthly usage cap — write flag so future runs skip immediately - if (body.includes('UsageCapExceeded') || res.status === 429) { + if (body.includes('UsageCapExceeded')) { writeCapFlag(); throw new Error(`UsageCapExceeded: Twitter monthly cap hit — ${body.slice(0, 120)}`); }