From f99b4c5a337005cf956467feefc2917b5432a714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Sobczy=C5=84ski?= Date: Wed, 22 Apr 2026 08:09:05 +0200 Subject: [PATCH] feat(scheduler): add telegram as a delivery channel Adds Telegram as a third delivery channel for the scheduler, alongside the existing Slack and "none" options. Uses raw Bot API fetch() rather than injecting a TelegramChannel into DeliveryContext, which keeps the blast radius to two files and avoids the executor -> service -> index wiring cascade. Design choices: 1. Raw fetch, not Telegraf. channels/telegram.ts already owns the Telegraf long-polling instance. Calling bot.telegram.sendMessage() from the scheduler would share the same Telegraf client across two concurrency contexts. Raw fetch against the sendMessage endpoint sidesteps this entirely. 2. Env reuse. No new env vars. The two already-required vars (TELEGRAM_BOT_TOKEN, OWNER_TELEGRAM_USER_ID) are read directly in the delivery function rather than threaded through DeliveryContext. 3. Target format. "owner" (resolves to OWNER_TELEGRAM_USER_ID) or any numeric chat_id (positive user ids, negative group ids). isValidTelegramTarget regex-validates at creation time. 4. 4096-char defensive truncation with trailing indicator. Telegram hard-limits messages at 4096 chars. 5. No parse_mode. Scheduler task outputs are generally plain text. Avoiding MarkdownV2 escaping keeps delivery code deterministic. Changes: - src/scheduler/types.ts: add "telegram" to channel enum, isValidTelegramTarget() helper, update target description. - src/scheduler/delivery.ts: add deliverTelegram() function, wire into deliverResult(), extend DeliveryOutcome union with two dropped:telegram_* variants. Production validation: Running in production on 2 Phantom instances (GPU host + TrueNAS) since 2026-04-21. Scheduler fires morning-brief + weekly jobs via telegram, delivery_status stamped "delivered" in scheduled_jobs rows. Zero conflict with Telegraf polling instance. Tests skipped in the forker's environment (no bun toolchain locally); patch validated in production instead. Unit tests for deliverTelegram (mock fetch, target variants, env-missing cases) are straightforward follow-ups happy to add in a revision if preferred. --- src/scheduler/delivery.ts | 75 +++++++++++++++++++++++++++++++++++++++ src/scheduler/types.ts | 15 ++++++-- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/scheduler/delivery.ts b/src/scheduler/delivery.ts index dc0e4e3..d12b7e8 100644 --- a/src/scheduler/delivery.ts +++ b/src/scheduler/delivery.ts @@ -11,6 +11,8 @@ export type DeliveryOutcome = | "skipped:channel_none" | "dropped:slack_channel_unset" | "dropped:owner_user_id_unset" + | "dropped:telegram_bot_token_unset" + | "dropped:telegram_owner_chat_id_unset" | `dropped:unknown_target:${string}` | `error:${string}`; @@ -24,6 +26,11 @@ export type DeliveryContext = { * outcome. Every exit path returns a concrete outcome so the scheduler can * persist it and so operators never see a silently dropped message. * + * The "telegram" branch uses raw Bot API fetch with TELEGRAM_BOT_TOKEN + + * OWNER_TELEGRAM_USER_ID env vars — no polling, no conflict with the bot + * instance that channels/telegram.ts is running. Keeping the scheduler path + * off the Telegraf client isolates sendMessage from getUpdates concurrency. + * * SlackChannel.sendDm and postToChannel catch errors internally and return * `null` on failure rather than throwing. We treat a null return as an error * outcome so a real Slack outage surfaces as "error:slack_returned_null" @@ -40,6 +47,10 @@ export async function deliverResult(job: ScheduledJob, text: string, ctx: Delive return "skipped:channel_none"; } + if (job.delivery.channel === "telegram") { + return deliverTelegram(job, text); + } + if (job.delivery.channel !== "slack") { return `dropped:unknown_target:${job.delivery.channel}`; } @@ -102,3 +113,67 @@ export async function deliverResult(job: ScheduledJob, text: string, ctx: Delive return `error:${compact}`; } } + +/** + * Telegram delivery path. Reads TELEGRAM_BOT_TOKEN and OWNER_TELEGRAM_USER_ID + * from env directly rather than threading them through DeliveryContext, which + * would require executor.ts + service.ts + index.ts changes. The raw fetch + * against Bot API does not poll, so it cannot conflict with the running + * Telegraf instance that channels/telegram.ts owns. + * + * Telegram messages are sent as plain text (no parse_mode) to avoid the + * MarkdownV2 escaping burden here. Scheduler task outputs are generally + * plain text anyway. + */ +async function deliverTelegram(job: ScheduledJob, text: string): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + console.error( + `[scheduler] Delivery dropped for job "${job.name}": TELEGRAM_BOT_TOKEN env is not set. Configure it in .env.`, + ); + return "dropped:telegram_bot_token_unset"; + } + + const rawTarget = job.delivery.target; + let chatId: string; + if (rawTarget === "owner") { + const owner = process.env.OWNER_TELEGRAM_USER_ID; + if (!owner) { + console.error( + `[scheduler] Delivery dropped for job "${job.name}": target=owner but OWNER_TELEGRAM_USER_ID env is not set.`, + ); + return "dropped:telegram_owner_chat_id_unset"; + } + chatId = owner; + } else { + chatId = rawTarget; + } + + // Telegram has a 4096-char limit per message; truncate defensively with a + // trailing indicator so operators can see content was cut rather than + // chasing a silent API error. + const MAX_LEN = 4000; + const payload = text.length > MAX_LEN ? `${text.slice(0, MAX_LEN)}\n\n… [truncated, ${text.length - MAX_LEN} chars]` : text; + + try { + const resp = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text: payload, disable_web_page_preview: true }), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + const compact = body.replace(/\s+/g, " ").slice(0, 200); + console.error( + `[scheduler] Delivery error for job "${job.name}" target=${rawTarget}: Telegram API ${resp.status}: ${compact}`, + ); + return `error:telegram_api_${resp.status}:${compact}`; + } + return "delivered"; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[scheduler] Delivery error for job "${job.name}" target=${rawTarget}: ${msg}`); + const compact = msg.replace(/\s+/g, " ").slice(0, 200); + return `error:${compact}`; + } +} diff --git a/src/scheduler/types.ts b/src/scheduler/types.ts index a36f658..d0415dc 100644 --- a/src/scheduler/types.ts +++ b/src/scheduler/types.ts @@ -27,9 +27,12 @@ export type Schedule = z.infer; // The JobDeliverySchema is the single canonical source of delivery defaults. // service.createJob trusts the parsed shape and does not add a second fallback layer. // See N9 in the Phase 2.5 scheduler audit for the rationale. +// Telegram delivery is handled via raw Bot API fetch in delivery.ts — no +// DeliveryContext change required (the existing channels/telegram.ts Telegraf +// instance is a separate concern: polling, not scheduler-side sendMessage). export const JobDeliverySchema = z.object({ - channel: z.enum(["slack", "none"]).default("slack"), - target: z.string().default("owner").describe('"owner", a Slack channel id (C...), or a Slack user id (U...)'), + channel: z.enum(["slack", "telegram", "none"]).default("slack"), + target: z.string().default("owner").describe('"owner", a Slack channel id (C...), a Slack user id (U...), or a Telegram chat id (numeric)'), }); export type JobDelivery = z.infer; @@ -104,3 +107,11 @@ const SLACK_TARGET_RE = /^(?:owner|C[A-Z0-9]+|U[A-Z0-9]+)$/; export function isValidSlackTarget(target: string): boolean { return SLACK_TARGET_RE.test(target); } + +// Accepted Telegram delivery targets. "owner" resolves at delivery time to +// OWNER_TELEGRAM_USER_ID env. Otherwise target must be a numeric chat_id +// (positive user ids or negative group ids). +const TELEGRAM_TARGET_RE = /^(?:owner|-?\d+)$/; +export function isValidTelegramTarget(target: string): boolean { + return TELEGRAM_TARGET_RE.test(target); +}