Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/scheduler/delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;

Expand All @@ -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"
Expand All @@ -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}`;
}
Expand Down Expand Up @@ -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<DeliveryOutcome> {
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}`;
}
}
15 changes: 13 additions & 2 deletions src/scheduler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ export type Schedule = z.infer<typeof ScheduleSchema>;
// 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<typeof JobDeliverySchema>;

Expand Down Expand Up @@ -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);
}
Loading