@yaebal/cron

typed cron jobs for yaebal bots: declarative schedules (5/6-field cron expressions, @aliases, or a plain millisecond interval), per-job timezones, retries with backoff, overlap control, cooperative timeouts, catch-up after downtime, a distributed-lock hook for multi-instance deployments, runtime job management, and a chat-native /cron admin surface — with graceful shutdown throughout. @yaebal/broadcast is close but purpose-built for mass messaging — cron is a generic scheduler for periodic tasks; close over bot.api in your task to send anything.

install

terminal
pnpm add @yaebal/cron

as a bot plugin

jobs arm on bot.onStart and drain on bot.onStopawait bot.stop() won't resolve until any in-flight run finishes (see graceful shutdown). the plugin also decorates ctx.cron with the same handle, so any handler can reach trigger/pause/states without a separate import.

bot.ts
import { createBot } from "yaebal";
import { cron } from "@yaebal/cron";

const bot = createBot(process.env.BOT_TOKEN!).install(
  cron({
    tz: "Europe/Moscow", // default zone for every job; UTC if omitted
    jobs: {
      digest: {
        schedule: "0 9 * * *", // every day at 09:00 local — real deploys wait for this
        task: async (ctx) => {
          // ctx.attempt is 1 on the first try, 2 on the first retry, ...
          console.log(`sending digest (attempt ${ctx.attempt})`);
        },
        retries: 1,
      },
    },
  }),
);

// the plugin decorates ctx.cron — any handler can trigger/inspect a job directly
bot.command("run-digest", async (ctx) => {
  const outcome = await ctx.cron.trigger("digest"); // "ran" | "skipped", bypasses the schedule
  await ctx.reply(`digest: ${outcome}`);
});

bot.start();
bot.ts
import { cron } from "@yaebal/cron";

bot.install(
  cron({
    tz: "Europe/Moscow", // default zone for every job below; UTC if omitted
    jobs: {
      digest: {
        schedule: "0 9 * * *", // every day at 09:00 local
        task: async () => {
          await bot.api.sendMessage({ chat_id: adminId, text: "good morning" });
        },
        retries: 2,
        timeoutMs: 10_000,
      },
      heartbeat: {
        schedule: 30_000, // every 30s
        task: () => probe.ping(),
      },
    },
  }),
);

// the plugin also decorates ctx.cron — any handler can reach trigger/pause/resume/states/nextRuns
bot.command("run-digest", async (ctx) => {
  await ctx.reply(`digest: ${await ctx.cron.trigger("digest")}`);
});

standalone (webhooks / serverless)

for webhook and serverless deployments, where bot.onStart/onStop never fire, use createCron() and call start()/stop() yourself. chained .job() calls accumulate valid names, so jobs.trigger("cleanup") and jobs.state("digest") are typo-checked at compile time.

standalone.ts
import { createCron } from "@yaebal/cron";

const jobs = createCron()
  .job("cleanup", "*/15 * * * *", () => cleanupExpiredSessions())
  .job("digest", "0 9 * * *", sendDailyDigest, { timeoutMs: 60_000 });

jobs.start();
// ...
await jobs.stop();

admin commands

cronAdmin installs a telegram-native ops surface for the jobs cron() added — list every job's state, trigger one on demand, pause/resume its schedule, or preview upcoming runs — straight from a chat, gated by an isAdmin check you provide. isolated via Composer.filter (not guard) — a rejected check continues the outer chain instead of halting it, so installing this doesn't gate any handler registered elsewhere on the same composer.

bot.ts
import { createBot } from "yaebal";
import { cron, cronAdmin } from "@yaebal/cron";

const bot = createBot(process.env.BOT_TOKEN!)
  .install(
    cron({
      jobs: {
        digest: { schedule: "0 9 * * *", task: () => {} },
        cleanup: { schedule: 60_000, task: () => {} },
      },
    }),
  )
  // isAdmin: () => true for the demo — check ctx.from?.id against a real allow-list in production
  .install(cronAdmin({ isAdmin: () => true }));

bot.start();
bot.ts
import { cron, cronAdmin } from "@yaebal/cron";

bot
  .install(cron({ jobs: { digest: { schedule: "0 9 * * *", task: sendDailyDigest } } }))
  .install(cronAdmin({ isAdmin: (ctx) => ctx.from?.id === adminId }));

// /cron                — every job's state: paused/running, run/failure counts, next & last run
// /cron run digest      — trigger a job immediately, respecting its overlap policy
// /cron pause digest     — stop its automatic schedule (trigger() still works)
// /cron resume digest    — restart it
// /cron next digest      — preview its next 3 scheduled fire times

timezones

set tz globally (every job's default) or per job (overriding it). resolved per-instant via Intl — DST-correct, no timezone database bundled: a skipped spring-forward hour is simply never matched, a repeated fall-back hour fires once. millisecond intervals ignore tz — they're anchored on absolute time, not wall-clock fields.

bot.ts
cron({
  tz: "Europe/Moscow", // default zone for every job — UTC if omitted
  jobs: {
    // inherits the "Europe/Moscow" default above
    digest: { schedule: "0 9 * * *", task: sendDailyDigest },
    // overrides it per job
    nyReport: { schedule: "0 17 * * *", task: sendReport, tz: "America/New_York" },
  },
});
// resolved per-instant via Intl — DST-correct, no timezone database bundled.
// a wall-clock time that's skipped on a spring-forward day is simply never matched.

retries & cooperative timeouts

timeoutMs races the task against a timer and aborts ctx.signal — cooperative, so pass it into your own fetch/api calls to actually cancel work. either way the scheduler stops waiting, fails the run, and re-arms — a task that never resolves can never wedge the schedule. retries/retryDelayMs re-run a failed (or timed-out) attempt before giving up; only the final attempt counts toward state().failures and calls onError.

bot.ts
jobs.job(
  "syncInventory",
  "*/5 * * * *",
  async (ctx) => {
    // ctx.attempt is 1 on the first try, 2 on the first retry, ...
    await fetch("https://example.com/inventory", { signal: ctx.signal });
  },
  {
    retries: 2, // up to 2 extra attempts after the first failure
    retryDelayMs: (attempt) => attempt * 1_000, // 1s, then 2s
    timeoutMs: 10_000, // races the task; the scheduler stops waiting either way
  },
);
// only the FINAL attempt counts toward state().failures and calls onError —
// an eventual success after retries is not a failure.

overlap

decide what happens when a job's previous run is still going when it's due again.

overlap.ts
jobs.job("sync", 5_000, syncInventory, { overlap: "wait" });
// "skip"  (default) drops a run that arrives while the previous one is still going.
// "wait"  queues exactly one run to fire right after the current one finishes.
// "allow" fires concurrently — no skipping or queueing at all.

catch-up after downtime

with a store configured, a job with catchUp: true fires once at start() if its schedule had an occurrence due between the last recorded run and now — so a restart during a deploy window doesn't silently drop a day's digest.

bot.ts
import { fileStorage } from "@yaebal/sklad/file";

const jobs = createCron({ store: fileStorage("./cron-state.json") })
  .job("digest", "0 9 * * *", sendDailyDigest, { catchUp: true });
// with catchUp + a store, a missed occurrence (the process was down at 09:00) fires
// once at the next start() instead of silently vanishing until tomorrow.
// store only needs get/set (delete optional), sync or async — any @yaebal/sklad
// adapter (MemoryStorage, redisStorage, sqliteStorage, kvStorage, fileStorage) works.

distributed locks

acquireLock gates each fire behind a lock you control — for a fleet running several instances of the same bot, so a job only actually executes on one of them. no backend is bundled; wire it to whatever your infrastructure already has.

bot.ts
const jobs = createCron({
  // one instance wins per job name; the rest skip that fire with reason: "lock"
  acquireLock: async (name) => {
    const acquired = await redis.set(`lock:${name}`, "1", "NX", "PX", 30_000);
    return acquired ? () => redis.del(`lock:${name}`) : false;
  },
}).job("digest", "0 9 * * *", sendDailyDigest);
// no backend bundled — wire it to redis, postgres advisory locks, or whatever your fleet has.
// a throwing/rejecting hook is treated as "denied", never as a scheduler crash.

runtime management

manage jobs after the scheduler is already running — e.g. from an admin command.

bot.ts
jobs.pause("digest");             // stop its automatic schedule; trigger() still works
jobs.resume("digest");            // re-arm it
jobs.reschedule("digest", "0 8 * * *"); // swap the schedule in place, re-arms immediately
jobs.remove("digest");            // unregister it and clear its timer
jobs.nextRuns("digest", 3);       // preview the next 3 fire times, without arming anything

graceful shutdown & events

stop() clears every timer immediately (no new runs start) and, by default, waits for in-flight runs — and any queued overlap: "wait" follow-up they spawn — to finish, up to drainTimeoutMs (30s), then aborts ctx.signal on anything still going. pass { graceful: false } to abort and return immediately instead.

events.ts
const jobs = createCron({
  onEvent: (event) => console.log(event.type, event),
  graceful: true,
  drainTimeoutMs: 30_000,
});
// scheduled / run_started / run_completed / run_failed / run_retry /
// run_skipped (reason: "overlap" | "lock") / run_timeout / store_error / schedule_error
// every event carries `at`; run-scoped ones carry `run`/`attempt` too.

schedule syntax

formexamplemeaning
cron expression0 9 * * *minute hour day-of-month month day-of-week
with seconds30 0 9 * * *optional 6th leading field — 09:00:30 every day
step*/15 * * * *every 15 minutes
list / range0 9-17 * * 1-5hourly, 9-17, Mon-Fri
names0 9 * jan mon-friJANDEC / SUNSAT, case-insensitive
alias@daily@yearly/@monthly/@weekly/@daily/@hourly/@midnight/@annually/@reboot
interval30_000a plain number of milliseconds — fires every 30s from when the job was armed
an expression that can never match (0 0 31 2 * — February never has 31 days) is rejected at registration, not discovered later. @reboot fires once when the scheduler starts and never arms a timer.

exports

exportkinduse
cronfunctioninstallable bot plugin — wires bot.onStart/onStop, decorates ctx.cron
createCronfunctionstandalone scheduler — call start()/stop() yourself
cronAdminfunction/cron ops commands, gated by isAdmin
Cronclassthe scheduler — see the api table below
parseCronfunctionparse an expression into a pure, unit-testable CronSchedule
CronExpressionErrorclassthrown by parseCron/job() on a malformed or unsatisfiable expression, or an unrecognized tz
CronJobExistsErrorclassthrown by job() for a name registered twice
CronJobNotFoundErrorclassthrown by trigger()/pause()/… for an unregistered name
CronTimeoutErrorclassthe reason ctx.signal aborts with when timeoutMs elapses
CronStoppedErrorclassthe reason ctx.signal aborts with when stop()'s drain window elapses

Cron methods

methodreturnsdescription
job(name, schedule, task, options?)Cronregister a job; chainable, accumulates typed names
start() / stop(options?)this / Promise<void>arm / disarm every job
trigger(name)Promise<"ran" | "skipped">run now, respecting overlap — never disturbs the schedule
pause(name) / resume(name)voidstop/restart automatic firing; trigger() still works while paused
reschedule(name, schedule)voidreplace the schedule in place, re-arms immediately
remove(name)booleanunregister and clear its timer
nextRuns(name, count)Date[]preview upcoming fire times, without arming anything
state(name) / states()CronJobState | undefined / CronJobState[]paused, running, run/failure counts, next/last run, last error

testing

Cron doesn't touch ctxcreateCron() + trigger() is enough for most job logic. for schedule-driven behavior, drive @yaebal/test's virtual clock instead of real sleep(); cronAdmin() touches ctx, so test it through a real bot and createTestEnv.

cron.test.ts
import { createCron } from "@yaebal/cron";
import { installTestClock } from "@yaebal/test";

const jobs = createCron().job("digest", 60_000, sendDigest);
await jobs.trigger("digest"); // run it once, right now, bypassing the schedule — resolves "ran"

// schedule-driven behavior (start(), timers, retry delays): drive the virtual clock
const clock = installTestClock();
try {
  jobs.start();
  await clock.advance(60_000);
  assert.equal(jobs.state("digest")?.runs, 1);
} finally {
  clock.restore();
}
pairs with sklad. store only needs get/set (sync or async) — the same shape as @yaebal/sklad's StorageAdapter<T>, so any of its adapters work without an explicit dependency on the package.
no panel widget. @yaebal/panel is a chat-inbox ui without a plugin/widget extension point, so job management ships as cronAdmin's bot commands instead of a panel page — it works with or without the panel installed, and on every runtime yaebal supports (including edge/serverless).