Skip to content

Commit 4d2d307

Browse files
committed
feat: add Telegram notification provider and related functionality
- Introduced Telegram support by adding a new provider for sending notifications. - Implemented message formatting and sending logic for Telegram in dedicated modules. - Updated environment configuration to include Telegram bot token and chat ID. - Enhanced the provider registry to include the new Telegram provider alongside Discord.
1 parent ee10cdc commit 4d2d307

File tree

7 files changed

+263
-2
lines changed

7 files changed

+263
-2
lines changed

apps/web/src/env.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ import { string } from "zod/v4";
55
export const env = createEnv({
66
server: {
77
WEBHOOK_INTEGRATION_SECRET: string(),
8-
DISCORD_WEBHOOK_URL: string().url(),
8+
// Discord
9+
DISCORD_WEBHOOK_URL: string().url().optional(),
910
DISCORD_WEBHOOK_USERNAME: string().optional(),
1011
DISCORD_WEBHOOK_AVATAR_URL: string().url().optional(),
12+
// Telegram
13+
TELEGRAM_BOT_TOKEN: string().optional(),
14+
TELEGRAM_CHAT_ID: string().optional(),
15+
// Rate limiting
1116
UPSTASH_REDIS_REST_URL: string().url().optional(),
1217
UPSTASH_REDIS_REST_TOKEN: string().optional(),
1318
},
@@ -17,6 +22,8 @@ export const env = createEnv({
1722
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
1823
DISCORD_WEBHOOK_USERNAME: process.env.DISCORD_WEBHOOK_USERNAME,
1924
DISCORD_WEBHOOK_AVATAR_URL: process.env.DISCORD_WEBHOOK_AVATAR_URL,
25+
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN,
26+
TELEGRAM_CHAT_ID: process.env.TELEGRAM_CHAT_ID,
2027
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
2128
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
2229
},

apps/web/src/providers/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import discord from "./discord";
2+
import telegram from "./telegram";
23
import type { Provider } from "./types";
34

45
// Add new providers here
5-
const ALL_PROVIDERS = [discord] as const;
6+
const ALL_PROVIDERS = [discord, telegram] as const;
67

78
// Filter to only enabled providers (non-null)
89
export const providers: Provider[] = ALL_PROVIDERS.filter(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { env } from "@/env";
2+
import type { TelegramMessage, TelegramResponse } from "./types";
3+
4+
const RETRY_CONFIG = {
5+
maxRetries: 3,
6+
rateLimitDelay: 5000,
7+
backoffMultiplier: 1000,
8+
} as const;
9+
10+
export async function sendMessage(text: string): Promise<void> {
11+
if (!env.TELEGRAM_CHAT_ID) {
12+
throw new Error("Telegram: TELEGRAM_CHAT_ID is not configured");
13+
}
14+
15+
const message: TelegramMessage = {
16+
chat_id: env.TELEGRAM_CHAT_ID,
17+
text,
18+
parse_mode: "HTML",
19+
disable_web_page_preview: true,
20+
};
21+
22+
await sendWithRetry(message);
23+
}
24+
25+
async function sendWithRetry(
26+
message: TelegramMessage,
27+
attempt = 0
28+
): Promise<void> {
29+
if (attempt >= RETRY_CONFIG.maxRetries) {
30+
throw new Error("Telegram: maximum retry attempts reached");
31+
}
32+
33+
const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`;
34+
35+
const response = await fetch(url, {
36+
method: "POST",
37+
headers: { "Content-Type": "application/json" },
38+
body: JSON.stringify(message),
39+
});
40+
41+
const data = (await response.json()) as TelegramResponse;
42+
43+
if (data.ok) {
44+
return;
45+
}
46+
47+
// Rate limited (429)
48+
if (data.error_code === 429) {
49+
await delay(RETRY_CONFIG.rateLimitDelay);
50+
return sendWithRetry(message, attempt);
51+
}
52+
53+
// Retry on server errors
54+
if (data.error_code && data.error_code >= 500) {
55+
if (attempt === RETRY_CONFIG.maxRetries - 1) {
56+
throw new Error(`Telegram API error: ${data.description}`);
57+
}
58+
await delay(RETRY_CONFIG.backoffMultiplier * (attempt + 1));
59+
return sendWithRetry(message, attempt + 1);
60+
}
61+
62+
throw new Error(`Telegram API error: ${data.description}`);
63+
}
64+
65+
function delay(ms: number): Promise<void> {
66+
return new Promise((resolve) => setTimeout(resolve, ms));
67+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { VercelWebhook } from "@/schemas/vercel";
2+
3+
const EMOJIS = {
4+
success: "✅",
5+
error: "❌",
6+
pending: "⏳",
7+
canceled: "🚫",
8+
promoted: "🔗",
9+
info: "ℹ️",
10+
branch: "🌿",
11+
commit: "📝",
12+
url: "🌐",
13+
} as const;
14+
15+
type Formatter = (webhook: VercelWebhook) => string;
16+
17+
const FORMATTERS: Record<string, Formatter> = {
18+
deployment: formatDeployment,
19+
domain: formatDomain,
20+
project: formatProject,
21+
};
22+
23+
export function formatWebhook(webhook: VercelWebhook): string {
24+
const typePrefix = webhook.type.split(".")[0];
25+
const formatter = FORMATTERS[typePrefix];
26+
return formatter ? formatter(webhook) : formatGeneric(webhook);
27+
}
28+
29+
function formatDeployment(webhook: VercelWebhook): string {
30+
const { deployment, links } = webhook.payload;
31+
if (!deployment) {
32+
return formatGeneric(webhook);
33+
}
34+
35+
const state = webhook.type.split(".")[1];
36+
const emoji = getEmoji(state);
37+
const meta = deployment.meta;
38+
39+
const lines: string[] = [
40+
`${emoji} <b>Deployment ${state}</b>`,
41+
"",
42+
`<b>Project:</b> ${escapeHtml(deployment.name)}`,
43+
];
44+
45+
if (meta?.target) {
46+
lines.push(`<b>Environment:</b> ${escapeHtml(meta.target)}`);
47+
}
48+
49+
if (meta?.githubCommitRef) {
50+
lines.push(
51+
`${EMOJIS.branch} <b>Branch:</b> <code>${escapeHtml(meta.githubCommitRef)}</code>`
52+
);
53+
}
54+
55+
if (meta?.githubCommitSha) {
56+
const shortSha = meta.githubCommitSha.slice(0, 7);
57+
const commitUrl = `https://github.com/${meta.githubCommitOrg}/${meta.githubCommitRepo}/commit/${meta.githubCommitSha}`;
58+
lines.push(
59+
`${EMOJIS.commit} <b>Commit:</b> <a href="${commitUrl}">${shortSha}</a>`
60+
);
61+
}
62+
63+
if (meta?.githubCommitMessage) {
64+
const message =
65+
meta.githubCommitMessage.length > 200
66+
? `${meta.githubCommitMessage.slice(0, 200)}...`
67+
: meta.githubCommitMessage;
68+
lines.push("", `<pre>${escapeHtml(message)}</pre>`);
69+
}
70+
71+
if (webhook.type === "deployment.error" && meta?.buildError) {
72+
const errorText =
73+
meta.buildError.length > 300
74+
? `${meta.buildError.slice(0, 300)}...`
75+
: meta.buildError;
76+
lines.push(
77+
"",
78+
"<b>Build Error:</b>",
79+
`<pre>${escapeHtml(errorText)}</pre>`
80+
);
81+
}
82+
83+
if (links?.deployment) {
84+
lines.push(
85+
"",
86+
`${EMOJIS.url} <a href="${links.deployment}">View Deployment</a>`
87+
);
88+
}
89+
90+
return lines.join("\n");
91+
}
92+
93+
function formatDomain(webhook: VercelWebhook): string {
94+
const { domain } = webhook.payload;
95+
if (!domain) {
96+
return formatGeneric(webhook);
97+
}
98+
99+
const state = webhook.type.split(".")[1];
100+
return [
101+
`${EMOJIS.url} <b>Domain ${state}</b>`,
102+
"",
103+
`<b>Domain:</b> ${escapeHtml(domain.name)}`,
104+
].join("\n");
105+
}
106+
107+
function formatProject(webhook: VercelWebhook): string {
108+
const { project } = webhook.payload;
109+
if (!project) {
110+
return formatGeneric(webhook);
111+
}
112+
113+
const state = webhook.type.split(".")[1];
114+
const emoji = state === "created" ? "🆕" : "🗑️";
115+
116+
return [
117+
`${emoji} <b>Project ${state}</b>`,
118+
"",
119+
`<b>Project:</b> ${escapeHtml(project.name || project.id)}`,
120+
].join("\n");
121+
}
122+
123+
function formatGeneric(webhook: VercelWebhook): string {
124+
const formattedType = webhook.type
125+
.split(".")
126+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
127+
.join(" • ");
128+
129+
return [
130+
`${EMOJIS.info} <b>${escapeHtml(formattedType)}</b>`,
131+
"",
132+
`Event received at ${new Date(webhook.createdAt).toISOString()}`,
133+
].join("\n");
134+
}
135+
136+
function getEmoji(state: string): string {
137+
const map: Record<string, string> = {
138+
created: EMOJIS.pending,
139+
succeeded: EMOJIS.success,
140+
ready: EMOJIS.success,
141+
promoted: EMOJIS.promoted,
142+
error: EMOJIS.error,
143+
canceled: EMOJIS.canceled,
144+
};
145+
return map[state] || EMOJIS.info;
146+
}
147+
148+
function escapeHtml(text: string): string {
149+
return text
150+
.replace(/&/g, "&amp;")
151+
.replace(/</g, "&lt;")
152+
.replace(/>/g, "&gt;");
153+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { env } from "@/env";
2+
import type { VercelWebhook } from "@/schemas/vercel";
3+
import type { Provider } from "../types";
4+
import { sendMessage } from "./client";
5+
import { formatWebhook } from "./formatter";
6+
7+
const telegramProvider: Provider | null =
8+
env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID
9+
? {
10+
name: "telegram",
11+
async send(webhook: VercelWebhook): Promise<void> {
12+
const message = formatWebhook(webhook);
13+
await sendMessage(message);
14+
},
15+
}
16+
: null;
17+
18+
export default telegramProvider;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type TelegramMessage = {
2+
chat_id: string;
3+
text: string;
4+
parse_mode: "HTML" | "MarkdownV2";
5+
disable_web_page_preview?: boolean;
6+
};
7+
8+
export type TelegramResponse = {
9+
ok: boolean;
10+
result?: unknown;
11+
description?: string;
12+
error_code?: number;
13+
};

turbo.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"DISCORD_WEBHOOK_AVATAR_URL",
1111
"DISCORD_WEBHOOK_URL",
1212
"DISCORD_WEBHOOK_USERNAME",
13+
"TELEGRAM_BOT_TOKEN",
14+
"TELEGRAM_CHAT_ID",
1315
"UPSTASH_REDIS_REST_TOKEN",
1416
"UPSTASH_REDIS_REST_URL",
1517
"WEBHOOK_INTEGRATION_SECRET"

0 commit comments

Comments
 (0)