Skip to content

bridge: scheduled text messages (timer + fallback, DB-backed) - #15

Open
std-external wants to merge 1 commit into
std-microblock:mainfrom
std-external:feat/scheduled-text-messages
Open

std-external wants to merge 1 commit into
std-microblock:mainfrom
std-external:feat/scheduled-text-messages

Conversation

@std-external

Copy link
Copy Markdown
Contributor

Scheduled text messages (v1)

A Telegram client can now schedule a text message, list it, send it early and delete it. The bridge stores the message and its scheduling state in the database and sends it from its own timer, so delivery no longer depends on any Telegram client staying online.

This is the first slice of the design discussed with the reporter: text only, no repeat, no media. Media scheduling keeps returning SCHEDULED_MESSAGES_UNAVAILABLE instead of pretending to work.

Why this shape

Two tables, body separate from state. mtproto_scheduled_message holds what to send (text, entities, reply target, conversation snapshot); mtproto_scheduled_state holds when and how far it got (dueAt, status, attempts, nextAttemptAt, lease columns, randomId). Editing a body never touches the state machine, and retries never rewrite the body. The existing mtproto_im_message table is not reused: it models messages that already exist on the platform, with a platform-id uniqueness rule, and scheduled rows would pollute history, unread counts and id allocation.

Write first, arm second. Every change commits to the database before the timer is touched. arm is idempotent: a new row only re-arms when it is earlier than the currently armed one, so a burst of tasks does not thrash the timer. If the process dies between commit and arm, the fallback sweeps it up.

One timer for the earliest row + a periodic sweep. cron's CronJob with a Date trigger (the owner's suggestion) replaces our own delay arithmetic; a 2-minute safety sweep covers restart, a throwing callback, suspend and clock jumps. Both paths go through the same claim, so they cannot double-send. Waits are clamped to 24 days because setTimeout fires immediately past 2^31-1 ms.

Claim = state machine + lease. A send first moves the row to sending with a 60 s lease; a crashed sender's expired lease can be stolen on the next pass. In practice the claim is serialized per database instance: @cordisjs/plugin-database implements set() as SELECT-then-UPDATE (see plugin-database-sqlite/lib/index.js), so two concurrent claimers inside one process could both match. The lease columns still carry the crash-recovery semantics, and crossgram runs one bridge process today.

Scheduled ids live in their own counter scope (scheduled:<session>:<conversation>), so they never consume the timestamp-bucket ids used for real messages. When the message is actually sent, the ordinary send path allocates a normal id; clients receive updateDeleteScheduledMessages (with sent_messages) next to the usual updateNewMessage.

What is implemented

  • messages.sendMessage with schedule_date (text only) → persists the row, returns updateNewScheduledMessage.
  • messages.getScheduledMessages, messages.getScheduledHistory (was an always-empty stub).
  • messages.sendScheduledMessages (send now) and messages.deleteScheduledMessages.
  • messages.sendMedia / sendMultiMedia now explicitly reject schedule_date; before, they silently sent immediately.
  • Dispatcher started through ctx.effect; sessions are rebuilt on demand, so a due message is sent even when no Telegram client is connected.

Not implemented (still METHOD_NOT_IMPLEMENTED / SCHEDULED_MESSAGES_UNAVAILABLE): scheduling media, forwarded messages, rich messages, topic subchannels, editing a scheduled message, and repeat (schedule_repeat_period is parsed but ignored).

What a library swap would and would not remove

  • cron already replaced the delay/setTimeout glue: deleting CronJob would put ~20 lines back.
  • The fallback sweep, the two tables, the claim/lease and the update publishing cannot be dropped by any cron library: they survive restarts and guarantee single delivery, which in-process timers do not.
  • Replacing the tables with a persistent job queue (Agenda/BullMQ/pg-boss) is a larger change: the default deployment is SQLite (app.yml), and those queues would still need a mapping layer for Telegram scheduled ids and updates.

Verification

  • packages/bridge/src/scheduled-messages.test.ts (11 tests): row pair creation, listing, cancel, send + sent state, restart recovery from an expired lease, two dispatchers racing for one row, cancelled rows not sent, no re-arm for a later row, retry/park after repeated failures, the 24-day clamp, and RPC round-trips (create → list → deliver → delete; send-now).
  • packages/bridge/src/dialogs.test.ts and the rest of packages/bridge/src pass (811 tests); the only failure is voice-worker-client.test.ts, which needs a cargo toolchain that this machine does not expose to the test user.
  • tsc -p tsconfig.test.json reports no errors in packages/bridge. The four remaining errors are the known platform-crossgram baseline (memory #76) and are untouched by this PR.
  • Not verified on a real client: the desktop/android section rendering of updateNewScheduledMessage, and whether scheduled ids overlapping real id ranges is harmless (the client keeps them in a separate container).

Known limitations

  • Delivery is at-least-once: a crash after the platform accepted the message but before sent is written can deliver it twice. randomId is persisted for a future reconciliation pass.
  • Messages already overdue by more than the fallback window (for example after days of downtime) are delivered as soon as the bridge starts; a "mark stale rows failed" policy is a product decision, not implemented.
  • Media bytes are not staged for scheduled rows yet; the pin-the-upload design is documented but deliberately out of this slice.

Source: relay research task from a crossgram tester in the community group; design notes in reports/crossgram-scheduled-messages-design.md (not part of this repository).

@std-external
std-external force-pushed the feat/scheduled-text-messages branch from 6ce6abc to b1b68b3 Compare October 18, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant