feat(06-07): scheduled() cron tick wired — factory, loadGraph street memo, crons trigger (GREEN)

- 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
This commit is contained in:
araharah-executor
2026-10-24 19:49:00 +08:00
parent 37ffefe11c
commit ad612299ca
5 changed files with 175 additions and 56 deletions
+134 -37
View File
@@ -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<Response> {
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<Env> {
return {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
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<void> {
// 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<Env>;
}
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<void> {
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<Env>;
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