From ad612299ca65d4863f9c3a6275f4df82f29e1914 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 19:49:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(06-07):=20scheduled()=20cron=20tick=20wire?= =?UTF-8?q?d=20=E2=80=94=20factory,=20loadGraph=20street=20memo,=20crons?= =?UTF-8?q?=20trigger=20(GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webhook.ts: createExportedHandler(opts) factory (offline send capture via baseFetch); scheduled() wraps runGuidanceCronTick in ctx.waitUntil — fail-closed unconfigured gate, paginated guidance: KV list (list_complete respected), loadGraph awaited ONCE per tick (the shared R2 memo), per-key getGuidanceSession → projectTurnCards → sendPhoto to envelope.chat_id ONLY (T-06-02), every log a [guidance-cron] category, zero KV writes - guidance-sessions.ts: GUIDANCE_KEY_PREFIX exported — the ONE key-scheme authority (list scans derive prefix + slice ids from it) - wrangler.jsonc: triggers.crons */1 * * * * (60 s scan, cadence in code) - guidance-cron.test.mjs GREEN completion: GC-C3/C5/C8/C9 wire the recording baseFetch through the factory (the header-documented seam — they defined the recorder but never passed it); GC-C7 uses readdirSync (require is undefined in ESM); GC-C1/C8/C9 drain inside the withConsole window (the tick's logs fire under waitUntil) - guidance-envelope.test.mjs: GE7 allows the GUIDANCE_KEY_PREFIX named import - 125/125 bot tests green; bot:typecheck clean --- bot/src/kv/guidance-sessions.ts | 6 +- bot/src/webhook.ts | 171 ++++++++++++++++++++++------ bot/test/guidance-cron.test.mjs | 48 +++++--- bot/test/guidance-envelope.test.mjs | 1 + bot/wrangler.jsonc | 5 + 5 files changed, 175 insertions(+), 56 deletions(-) diff --git a/bot/src/kv/guidance-sessions.ts b/bot/src/kv/guidance-sessions.ts index 282f2c9..a4777ca 100644 --- a/bot/src/kv/guidance-sessions.ts +++ b/bot/src/kv/guidance-sessions.ts @@ -104,8 +104,12 @@ function isUuidShape(id: string): boolean { return ENVELOPE_ID_PATTERN.test(id); } +/** The envelope key scheme's prefix — ONE authority (KV list scans derive + * their prefix and slice their ids from THIS constant, never a literal). */ +export const GUIDANCE_KEY_PREFIX = "guidance:"; + function keyFor(id: string): string { - return `guidance:${id}`; + return `${GUIDANCE_KEY_PREFIX}${id}`; } /** diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts index fddd03f..41e2932 100644 --- a/bot/src/webhook.ts +++ b/bot/src/webhook.ts @@ -46,6 +46,20 @@ * answers 200 null; rejections are category-only (T-06-04). * Anything else → 404. * + * scheduled() — the repo's FIRST Cron Trigger (06-07, GUID-05, D-11): every + * minute it lists the KV prefix guidance:, reads each envelope through + * getGuidanceSession (a corrupt record reads null and is skipped honestly), + * projects progress along the frozen snapshot via the PURE projectTurnCards, + * and pushes due cards to envelope.chat_id ONLY (T-06-02 — no request path + * exists in a cron tick). Street reconstruction (D-12 default) awaits the + * worker-router's loadGraph memo once per tick; a null graph degrades the + * card to the bare-verb street-less composition — the send still fires. + * STATELESS: the cron performs ZERO KV writes (the free-plan budget law). + * + * The handler object is built by createExportedHandler(opts) — production + * passes nothing (the default export); offline tests inject a recording + * baseFetch through the same factory (the createBot({baseFetch}) seam). + * * D-14: the ONLY log statements are category-only — malformed/tampered/stale * on the initData gate, misconfigured on the fail-closed webhook gate — * never the raw payload, never user ids, never timestamps. Rejection @@ -63,50 +77,133 @@ import { mintGuidanceSession, getGuidanceSession, deleteGuidanceSession, + GUIDANCE_KEY_PREFIX, type GuidanceRouteSnapshot, + type GuidanceSessionEnvelope, } from "./kv/guidance-sessions.ts"; -import { handleRoute } from "./routing/worker-router.ts"; +import { handleRoute, loadGraph } from "./routing/worker-router.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; +import { projectTurnCards, makeStreetAt } from "./guidance/cron.ts"; import { validateInitData } from "./security/initdata.ts"; -import { pickLocale } from "./i18n/strings.ts"; +import { pickLocale, strings } from "./i18n/strings.ts"; -export default { - async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { - const url = new URL(req.url); - if (url.pathname === "/telegram/webhook") { - // WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request - // when its secret is undefined (research-confirmed runtime behavior, - // convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after - // deploy would therefore silently disable webhook authentication and - // admit unsigned POSTs that inject fabricated updates. This guard runs - // BEFORE createBot/webhookCallback; the rejection is a constant 500 - // and the log carries the category only. - if (!env.WEBHOOK_SECRET) { - console.log("[webhook] rejected: misconfigured"); - return new Response("Server misconfigured", { status: 500 }); +export function createExportedHandler(opts: { baseFetch?: typeof fetch } = {}): ExportedHandler { + return { + async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { + const url = new URL(req.url); + if (url.pathname === "/telegram/webhook") { + // WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request + // when its secret is undefined (research-confirmed runtime behavior, + // convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after + // deploy would therefore silently disable webhook authentication and + // admit unsigned POSTs that inject fabricated updates. This guard runs + // BEFORE createBot/webhookCallback; the rejection is a constant 500 + // and the log carries the category only. + if (!env.WEBHOOK_SECRET) { + console.log("[webhook] rejected: misconfigured"); + return new Response("Server misconfigured", { status: 500 }); + } + const bot = createBot(env); // env-bound token → per-request instantiation + const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET); + return cb(req); + } + if (url.pathname === "/api/session") { + return handleSession(req, env); + } + if (url.pathname === "/api/location") { + return handleLocation(req, env); + } + if (url.pathname === "/api/location-fallback") { + return handleLocationFallback(req, env); + } + if (url.pathname === "/api/guidance") { + return handleGuidance(req, env); + } + if (url.pathname === "/api/route") { + return handleRoute(req, env); + } + return new Response("Not found", { status: 404 }); + }, + + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + // waitUntil wraps the whole tick: the runtime keeps the isolate alive + // past the handler's return, so a slow Telegram send still lands. + ctx.waitUntil(runGuidanceCronTick(controller, env, opts)); + }, + } satisfies ExportedHandler; +} + +export default createExportedHandler(); + +/** The envelope key scheme's prefix, sliced into a session id. */ +function envelopeIdOf(keyName: string): string { + return keyName.slice(GUIDANCE_KEY_PREFIX.length); +} + +/** + * scheduled()'s body: scan → validate → project → push. + * + * - FAIL-CLOSED posture (the webhook guard's shape): a missing BOT_TOKEN, + * namespace, or Mini App origin logs `skipped: unconfigured` and returns — + * an unarmed cron must never half-run. + * - The graph memo is awaited ONCE per tick (worker-router's loadGraph — + * the ONLY R2 loader; a second one is forbidden). A null graph is the + * street-less bare-verb degradation, not a tick failure. + * - Per key: getGuidanceSession (defensive — garbage reads null and the key + * is skipped honestly), projectTurnCards, then sendPhoto inside try/catch. + * One bad key cannot kill the tick (delivery variance is best-effort, D-13). + * - EVERY log line is a category — never a chat id, coordinate, or payload. + */ +async function runGuidanceCronTick( + controller: ScheduledController, + env: Env, + opts: { baseFetch?: typeof fetch }, +): Promise { + if (!env.BOT_TOKEN || !env.chat_sessions || !env.MINI_APP_URL) { + console.log("[guidance-cron] skipped: unconfigured"); + return; + } + const nowSec = Math.floor(controller.scheduledTime / 1000); + const graph = await loadGraph(env); + let cursor: string | undefined; + do { + const page = await env.chat_sessions.list({ prefix: GUIDANCE_KEY_PREFIX, cursor }); + for (const key of page.keys) { + let envelope: GuidanceSessionEnvelope | null = null; + try { + envelope = await getGuidanceSession(env.chat_sessions, envelopeIdOf(key.name)); + } catch { + envelope = null; // a throwing read is the same honest skip class (one bad key ≠ a dead tick) + } + if (envelope === null) { + console.log("[guidance-cron] skipped: malformed"); + continue; + } + const streetAt = + graph === null + ? undefined // street-less bare-verb degradation (D-03) — the send still fires + : makeStreetAt(graph, decodePolyline6(envelope.route_snapshot.geometry)); + const card = projectTurnCards(envelope, nowSec, streetAt); + if (!card.due || card.glyph === null || card.caption === null) continue; + try { + await createBot(env, opts).api.sendPhoto(envelope.chat_id, `${env.MINI_APP_URL}/glyphs/${card.glyph}.png`, { + caption: card.caption, + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [ + [{ text: strings.en.cardOpenButton, web_app: { url: `${env.MINI_APP_URL}/?g=${envelope.id}` } }], + ], + }, + }); + } catch { + // Telegram rejected the push (blocked bot, rate limit, delivery + // variance). Category only — never the chat id or error text (D-13). + console.log("[guidance-cron] send failed"); } - const bot = createBot(env); // env-bound token → per-request instantiation - const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET); - return cb(req); } - if (url.pathname === "/api/session") { - return handleSession(req, env); - } - if (url.pathname === "/api/location") { - return handleLocation(req, env); - } - if (url.pathname === "/api/location-fallback") { - return handleLocationFallback(req, env); - } - if (url.pathname === "/api/guidance") { - return handleGuidance(req, env); - } - if (url.pathname === "/api/route") { - return handleRoute(req, env); - } - return new Response("Not found", { status: 404 }); - }, -} satisfies ExportedHandler; + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor !== undefined); +} /** initData is Telegram's verbatim WebApp string — a few hundred bytes on * the wire (research: known-good fixtures ≪ 1 KB). 4 KB is generous diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs index 60c157e..02a4410 100644 --- a/bot/test/guidance-cron.test.mjs +++ b/bot/test/guidance-cron.test.mjs @@ -35,7 +35,7 @@ * synthetic 1e-6-grid points near KL center) — no user coordinate ever does. */ import { strict as assert } from "node:assert"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; @@ -453,10 +453,10 @@ test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-be }; const { ctx, drain } = makeCtx(); const worker = mod.createExportedHandler({ baseFetch }); - const { logs } = await withConsole(() => - worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx), - ); - await drain(); + const { logs } = await withConsole(async () => { + await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx); + await drain(); // INSIDE the capture window — the tick's logs fire under waitUntil + }); const photos = await sentPhotos(rec.calls); assert.equal(photos.length, 1, "exactly the due, alive envelope sends"); assert.equal(photos[0].chat_id, 202, "chat_id comes ONLY from the envelope (T-06-02)"); @@ -496,7 +496,7 @@ test("GC-C2 non-due tick: zero sends (the scan still runs; the cadence gates the }); test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradation, not silence)", async () => { - const { default: worker } = await import("../src/webhook.ts"); + const mod = await import("../src/webhook.ts"); const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 }); const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]); const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]); @@ -506,8 +506,11 @@ test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradati return { ok: true, json: async () => ({ ok: true, result: {} }) }; }; const { ctx, drain } = makeCtx(); - await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx)); - await drain(); + const worker = mod.createExportedHandler({ baseFetch }); + await withConsole(async () => { + await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx); + await drain(); + }); const photos = await sentPhotos(rec.calls); assert.equal(photos.length, 1, "the send fires even without a graph"); assert.ok(photos[0].caption.includes("— TURN LEFT"), "bare verb (graph absent)"); @@ -534,7 +537,7 @@ test("GC-C4 fail-closed: missing BOT_TOKEN → skipped: unconfigured, zero sends }); test("GC-C5 pagination: list_complete respected — envelopes across two pages all processed", async () => { - const { default: worker } = await import("../src/webhook.ts"); + const mod = await import("../src/webhook.ts"); const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 204 }); const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 205 }); const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 206 }); @@ -552,8 +555,11 @@ test("GC-C5 pagination: list_complete respected — envelopes across two pages a return { ok: true, json: async () => ({ ok: true, result: {} }) }; }; const { ctx, drain } = makeCtx(); - await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); - await drain(); + const worker = mod.createExportedHandler({ baseFetch }); + await withConsole(async () => { + await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx); + await drain(); + }); const photos = await sentPhotos(rec.calls); assert.equal(kv.listCalls, 2, "the pagination loop fetched both pages"); assert.equal(photos.length, 2, "both alive envelopes carded across pages"); @@ -572,7 +578,7 @@ test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROA assert.ok(webhookCode.includes("loadGraph"), "scheduled() awaits the worker-router memo"); assert.ok(webhookCode.includes("makeStreetAt"), "the graph-fed street reconstruction is wired"); assert.ok(webhookCode.includes("projectTurnCards"), "the projection is the decision authority"); - const readdir = (dir) => require("node:fs").readdirSync(dir, { withFileTypes: true }); + const readdir = (dir) => readdirSync(dir, { withFileTypes: true }); const walk = (dir, acc = []) => { for (const e of readdir(dir)) { const full = dir + "/" + e.name; @@ -594,7 +600,7 @@ test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROA }); test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the rest still card", async () => { - const { default: worker } = await import("../src/webhook.ts"); + const mod = await import("../src/webhook.ts"); const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 207 }); const BAD_KEY = "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000b8"; const map = new Map(); @@ -607,8 +613,11 @@ test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the return { ok: true, json: async () => ({ ok: true, result: {} }) }; }; const { ctx, drain } = makeCtx(); - const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); - await drain(); + const worker = mod.createExportedHandler({ baseFetch }); + const { logs } = await withConsole(async () => { + await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx); + await drain(); + }); const photos = await sentPhotos(rec.calls); assert.equal(photos.length, 1, "the tick survived the throwing read"); assert.equal(photos[0].chat_id, 207); @@ -616,7 +625,7 @@ test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the }); test("GC-C9 a failed send logs and continues — delivery variance is documented best-effort (D-13)", async () => { - const { default: worker } = await import("../src/webhook.ts"); + const mod = await import("../src/webhook.ts"); const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 208 }); const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 209 }); const map = new Map(); @@ -637,8 +646,11 @@ test("GC-C9 a failed send logs and continues — delivery variance is documented return { ok: true, json: async () => ({ ok: true, result: {} }) }; }; const { ctx, drain } = makeCtx(); - const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); - await drain(); + const worker = mod.createExportedHandler({ baseFetch }); + const { logs } = await withConsole(async () => { + await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx); + await drain(); + }); const photos = await sentPhotos(rec.calls); assert.equal(photos.length, 1, "the failed first send did not stop the second"); assert.equal(photos[0].chat_id, 209); diff --git a/bot/test/guidance-envelope.test.mjs b/bot/test/guidance-envelope.test.mjs index b7688a2..b929c48 100644 --- a/bot/test/guidance-envelope.test.mjs +++ b/bot/test/guidance-envelope.test.mjs @@ -193,6 +193,7 @@ test("GE7 consumer shape — every bot/src import of guidance-sessions is a NAME "GUIDANCE_SESSION_TTL_SECONDS", "GuidanceSessionEnvelope", "GuidanceRouteSnapshot", + "GUIDANCE_KEY_PREFIX", ]); const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g; for (const file of tsFilesUnder(srcDir)) { diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index e0e972b..00c7f76 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -28,5 +28,10 @@ // (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public // configuration, never a secret. "r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }], + // The repo's FIRST Cron Trigger (06-07, D-11): 60 s scan; card cadence + // decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window. The tick + // is STATELESS (zero KV writes — the free-plan budget law). Local test: + // curl "http://localhost:8787/cdn-cgi/local/scheduled" against `wrangler dev`. + "triggers": { "crons": ["*/1 * * * *"] }, "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] }