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); +}