Proactive push: internal scheduler + reusable Telegram delivery pipeline #5

Closed
opened 2026-07-04 21:55:25 +08:00 by weiwen · 1 comment
Owner

Let evie push messages to Telegram unprompted (e.g. a morning note summary), on an internal schedule, breaking the current pull-only request/response model.

Design decisions (from grilling)

  • Internal scheduler, not an external cron/systemd timer. evie owns the schedule and communicates results over channels.
  • One delivery pipeline, shared. A scheduled push goes through the exact same Telegram rendering path as an interactive message — including live streaming via editMessageText, pagination, and source-note buttons. There is no separate push path; the scheduler is just another producer of (chat_id, prompt) into the shared pipeline.
  • Prompts are file-backed, mirroring pi.system_prompt_file: a [[schedule]] entry points at a prompt_file path, not an inline string. (Inline-string support is a separate follow-up — #6 — that depends on this issue.)
  • No per-schedule chat_id. evie is single-user; scheduled pushes go to the global chat id (the single entry in telegram.allowed_chat_ids; use the first if more than one).
  • Cron is evaluated in the server's local timezone. No timezone field; document it in the config comment. Single-user box — TZ is the user's to control.
  • Missed schedules are skipped, not caught up. evie down at 08:00 -> no stale digest fired on restart.
  • Non-ephemeral: the digest starts a NEW session keyed to the global chat (not --no-session). It begins fresh (any prior idle session for that chat is replaced), so it is not contaminated by earlier conversation. Because it is a normal persisted session under the usual idle timeout, if the user replies within the timeout window the reply continues the conversation with full digest context; otherwise it expires like any session.

Part 1 — Refactor delivery into a reusable pipeline (prerequisite)

Today handle_message (src/telegram/mod.rs) inlines everything: placeholder send, typing task, snapshot channel, MessageView, markdown::paginate, throttled edits, final render. Extract a delivery unit that takes (chat_id, prompt) and produces the streamed/rendered messages via MessageView, so it can be driven by:

  • the interactive message handler (current caller), and
  • the scheduler (new caller).
    Both callers get identical behaviour — streaming, pagination, and the source-note buttons (#4) — because it is literally the same code. Since the digest session is keyed to the same tg-<chat> namespace, continuation on reply is free via the existing session routing.

Part 2 — Internal scheduler

  • New config section: a [[schedule]] array in config.toml, each entry carrying a cron expression and a prompt_file path. Add a ScheduleConfig struct (#[serde(default)] on the vec so it is optional), expand_tilde the path, extend default_toml() with a commented-out example, validate at startup.
  • A tokio scheduler task (a tz-aware-capable cron crate, e.g. croner) evaluates each entry in local time and fires it; on fire, starts a fresh session for the global chat and sends the resolved prompt into the shared delivery pipeline over an mpsc channel.
  • The consumer holds the teloxide::Bot and runs the shared delivery pipeline — this is how the scheduler reaches Telegram without owning the Bot.

Config

# Proactive scheduled prompts (optional). Each fires on its cron schedule
# (evaluated in the server's LOCAL timezone) and streams pi's answer to the
# global chat (telegram.allowed_chat_ids). Reply to continue the conversation.
[[schedule]]
cron = "0 8 * * *"                              # every day at 08:00 local
prompt_file = "~/.config/evie/schedules/morning.md"

Constraints

  • No new HTTP endpoint; scheduling is internal and channel-driven.
  • A schedule with an invalid cron expression, or a prompt_file that does not exist, is a config error at startup (extend Config::validate).
  • Schedules require telegram.enabled and a non-empty telegram.allowed_chat_ids; reject at startup otherwise.
  • Missed fires (downtime) are skipped, never replayed on restart.
  • Edge case (single-user, accepted): a digest firing mid-conversation replaces the active session with the fresh digest session.

Acceptance

  • A configured [[schedule]] fires on cron (local time) and streams a rendered answer into the global Telegram chat with no user message, identical to an interactive reply.
  • Replying to the digest within the idle timeout continues the same session with digest context.
  • The interactive path is behaviourally unchanged after the refactor.
  • Invalid cron / missing prompt_file fails fast at startup.
Let evie push messages to Telegram unprompted (e.g. a morning note summary), on an internal schedule, breaking the current pull-only request/response model. ## Design decisions (from grilling) - **Internal scheduler**, not an external cron/systemd timer. evie owns the schedule and communicates results over channels. - **One delivery pipeline, shared.** A scheduled push goes through the *exact same* Telegram rendering path as an interactive message — including **live streaming** via `editMessageText`, pagination, and source-note buttons. There is no separate push path; the scheduler is just another producer of `(chat_id, prompt)` into the shared pipeline. - **Prompts are file-backed**, mirroring `pi.system_prompt_file`: a `[[schedule]]` entry points at a `prompt_file` path, not an inline string. (Inline-string support is a separate follow-up — #6 — that depends on this issue.) - **No per-schedule chat_id.** evie is single-user; scheduled pushes go to the global chat id (the single entry in `telegram.allowed_chat_ids`; use the first if more than one). - **Cron is evaluated in the server's local timezone.** No timezone field; document it in the config comment. Single-user box — `TZ` is the user's to control. - **Missed schedules are skipped**, not caught up. evie down at 08:00 -> no stale digest fired on restart. - **Non-ephemeral: the digest starts a NEW session** keyed to the global chat (not `--no-session`). It begins fresh (any prior idle session for that chat is replaced), so it is not contaminated by earlier conversation. Because it is a normal persisted session under the usual idle timeout, if the user **replies** within the timeout window the reply continues the conversation with full digest context; otherwise it expires like any session. ## Part 1 — Refactor delivery into a reusable pipeline (prerequisite) Today `handle_message` (`src/telegram/mod.rs`) inlines everything: placeholder send, typing task, snapshot channel, `MessageView`, `markdown::paginate`, throttled edits, final render. Extract a delivery unit that takes `(chat_id, prompt)` and produces the streamed/rendered messages via `MessageView`, so it can be driven by: - the interactive message handler (current caller), and - the scheduler (new caller). Both callers get identical behaviour — streaming, pagination, and the source-note buttons (#4) — because it is literally the same code. Since the digest session is keyed to the same `tg-<chat>` namespace, continuation on reply is free via the existing session routing. ## Part 2 — Internal scheduler - New config section: a `[[schedule]]` array in `config.toml`, each entry carrying a cron expression and a `prompt_file` path. Add a `ScheduleConfig` struct (`#[serde(default)]` on the vec so it is optional), `expand_tilde` the path, extend `default_toml()` with a commented-out example, validate at startup. - A tokio scheduler task (a tz-aware-capable cron crate, e.g. `croner`) evaluates each entry in **local time** and fires it; on fire, starts a fresh session for the global chat and sends the resolved prompt into the shared delivery pipeline over an mpsc channel. - The consumer holds the `teloxide::Bot` and runs the shared delivery pipeline — this is how the scheduler reaches Telegram without owning the `Bot`. ## Config ```toml # Proactive scheduled prompts (optional). Each fires on its cron schedule # (evaluated in the server's LOCAL timezone) and streams pi's answer to the # global chat (telegram.allowed_chat_ids). Reply to continue the conversation. [[schedule]] cron = "0 8 * * *" # every day at 08:00 local prompt_file = "~/.config/evie/schedules/morning.md" ``` ## Constraints - No new HTTP endpoint; scheduling is internal and channel-driven. - A schedule with an invalid cron expression, or a `prompt_file` that does not exist, is a config error at startup (extend `Config::validate`). - Schedules require `telegram.enabled` and a non-empty `telegram.allowed_chat_ids`; reject at startup otherwise. - Missed fires (downtime) are skipped, never replayed on restart. - Edge case (single-user, accepted): a digest firing mid-conversation replaces the active session with the fresh digest session. ## Acceptance - A configured `[[schedule]]` fires on cron (local time) and **streams** a rendered answer into the global Telegram chat with no user message, identical to an interactive reply. - Replying to the digest within the idle timeout continues the same session with digest context. - The interactive path is behaviourally unchanged after the refactor. - Invalid cron / missing prompt_file fails fast at startup.
Author
Owner

Implemented in branch sandcastle/issue-5 (commit a9c199e).

Part 1 — Refactor delivery: Extracted deliver_response() from handle_message() into a public reusable function that streams pi answers with live pagination, typing indicator, and final render. Both interactive handler and scheduler call the same function.

Part 2 — Internal scheduler: Added [[schedule]] config section with cron + prompt_file. scheduler_task() evaluates cron in local time, sleeps to the next fire, and sends (chat_id, prompt) over an mpsc channel. delivery_consumer() holds a Bot clone, clears the session (fresh start), and runs the shared pipeline. Missed schedules are naturally skipped on restart.

Tests: 5 new tests for config parsing and validation. All 31 tests pass.

Blockers for next iteration: none known.

Implemented in branch sandcastle/issue-5 (commit a9c199e). **Part 1 — Refactor delivery:** Extracted `deliver_response()` from `handle_message()` into a public reusable function that streams pi answers with live pagination, typing indicator, and final render. Both interactive handler and scheduler call the same function. **Part 2 — Internal scheduler:** Added `[[schedule]]` config section with cron + prompt_file. `scheduler_task()` evaluates cron in local time, sleeps to the next fire, and sends (chat_id, prompt) over an mpsc channel. `delivery_consumer()` holds a Bot clone, clears the session (fresh start), and runs the shared pipeline. Missed schedules are naturally skipped on restart. **Tests:** 5 new tests for config parsing and validation. All 31 tests pass. Blockers for next iteration: none known.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
weiwen/evie#5
No description provided.