diff --git a/TELEGRAM.md b/TELEGRAM.md new file mode 100644 index 00000000..75a9558d --- /dev/null +++ b/TELEGRAM.md @@ -0,0 +1,76 @@ +# Telegram Group Integration Technical Documentation + +This document outlines the technical architecture, security measures, and configuration requirements for the Telegram bot integration in OpenStatus. + +## Overview + +The integration follows a two-step "QR Code" flow to securely link a Telegram group to an OpenStatus workspace: +1. **Phase 1 (Private Connection)**: Link a user's Telegram account to a workspace via a one-time token. +2. **Phase 2 (Group Addition)**: Detect when the bot is added to a group by that specific user. + +--- + +## Environment Variables + +The following environment variables must be configured in both the dashboard and the API packages. + +| Variable | Scope | Description | +| :--- | :--- | :--- | +| `TELEGRAM_BOT_TOKEN` | Backend | The API token provided by BotFather. Used for `getUpdates`. | +| `NEXT_PUBLIC_TELEGRAM_BOT_USERNAME` | Frontend & Backend | The username of the bot (without `@`). Used for QR links and identity verification. | + +--- + +## Technical Architecture + +### 1. Token Management (Redis) +We use a **Unified Single-Key Strategy** to manage integration tokens. + +- **Redis Key Pattern**: `telegram:workspace_token:${workspaceId}` +- **Storage**: Stores a plain 12-character random ID (generated using `nanoid(12)`). +- **Override Logic**: Only **one** active token is allowed per workspace at any time. Generating a new token automatically replaces the previous one. +- **Security Lifecycle**: + - **Expiry**: Tokens have a hard expiry of **30 minutes**. + - **One-Time Use**: The token is immediately deleted from Redis once the private chat connection (Phase 1) is successful. + +### 2. Session Isolation & Stale Data Prevention +Telegram's `getUpdates` API returns updates from the last 24 hours. To prevent old messages from triggering new integrations, we implement **Timestamp Filtering**: + +- **Session Start Time**: When the user clicks "Connect with QR", the frontend captures the current unix timestamp (`sessionStartTime`). +- **Query Filter**: The backend `getTelegramUpdates` query accepts a `since` parameter and skips any update with a `date < since`. +- **Reset Handling**: If a user resets the group ID, a new `sessionStartTime` is generated, ensuring only *subsequent* group additions are processed. + +### 3. Update Processing Logic + +#### Phase 1: Private Connection +- **Trigger**: User scans QR and sends `/start `. +- **Verification**: + - Extract `token` from message text. + - Look up `storedRandomId` using the workspace ID from the request context. + - If `token === storedRandomId`, the connection is valid. + - The `privateChatId` is returned to the frontend. + +#### Phase 2: Group Addition +- **Trigger**: User adds the bot to a group. +- **Verification**: + - Detects `new_chat_participant` or `new_chat_member` in the message object. + - **Bot Identity**: Verifies that `participant.username === NEXT_PUBLIC_TELEGRAM_BOT_USERNAME`. + - **Ownership**: Verifies that `message.from.id` matches the `privateChatId` linked in Phase 1. + - **Session**: Verifies `message.date >= since`. + +--- + +## UI Components + +- **`FormTelegram`**: Orchestrates the flow, manages polling, and handles session state. +- **`TelegramQRConnection`**: Displays the QR code and the "Reset Group ID" button. +- **`TelegramQRCode`**: Generates the `t.me` URL with the appropriate `start` or `startgroup` parameters. + +--- + +## Group Reset Mechanism + +Users can clear an accidental group connection using the **"Reset Group ID"** button. +1. Clears the `chatId` in the form. +2. Sets a new `sessionStartTime` (current time). +3. Polling continues, but will ignore the previous group addition because its timestamp is now older than the new session start time. diff --git a/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx b/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx new file mode 100644 index 00000000..9b923db3 --- /dev/null +++ b/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useTelegramConnection } from "@/hooks/use-telegram-connection"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@openstatus/ui/components/ui/tabs"; +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../notifications/form-telegram"; +import { TelegramManualInput } from "./telegram-manual-input"; +import { TelegramQRConnection } from "./telegram-qr-connection"; + +interface TelegramConnectionFlowProps { + form: UseFormReturn; + mode: "qr" | "manual" | null; + onModeChange: (mode: "qr" | "manual" | null) => void; +} + +export function TelegramConnectionFlow({ + form, + mode, + onModeChange, +}: TelegramConnectionFlowProps) { + const { + tokenData, + isTokenLoading, + flowStep, + privateChatId, + userName, + groupTitle, + isPolling, + resetConnection, + confirmPrivateChat, + } = useTelegramConnection({ form, mode }); + + return ( + onModeChange(v as "qr" | "manual")} + > + + + Connect with QR + + + Enter ChatID manually + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx b/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx new file mode 100644 index 00000000..a34b0bab --- /dev/null +++ b/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useTRPC } from "@/lib/trpc/client"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { useMutation } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { useTransition } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { toast } from "sonner"; +import type { FormValues } from "../notifications/form-telegram"; + +interface TelegramFormActionsProps { + form: UseFormReturn; + isPending: boolean; +} + +export function TelegramFormActions({ + form, + isPending, +}: TelegramFormActionsProps) { + const [_, startTransition] = useTransition(); + const trpc = useTRPC(); + const sendTestMutation = useMutation( + trpc.notification.sendTest.mutationOptions(), + ); + + function testAction() { + if (isPending) return; + + startTransition(async () => { + try { + const provider = form.getValues("provider"); + const data = form.getValues("data"); + const promise = sendTestMutation.mutateAsync({ + provider, + data: { + telegram: { chatId: data.chatId }, + }, + }); + toast.promise(promise, { + loading: "Sending test...", + success: "Test sent", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return "Failed to send test"; + }, + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/forms/components/telegram-manual-input.tsx b/apps/dashboard/src/components/forms/components/telegram-manual-input.tsx new file mode 100644 index 00000000..d7734bbc --- /dev/null +++ b/apps/dashboard/src/components/forms/components/telegram-manual-input.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@openstatus/ui/components/ui/form"; +import { Input } from "@openstatus/ui/components/ui/input"; +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../notifications/form-telegram"; + +interface TelegramManualInputProps { + form: UseFormReturn; + successMsg?: string; + showDescription?: boolean; +} + +export function TelegramManualInput({ + form, + successMsg, + showDescription = true, +}: TelegramManualInputProps) { + return ( + ( + + Telegram Chat ID + + + + + {successMsg && ( +
+ {successMsg} +
+ )} + {showDescription && ( + + Enter the Telegram chat ID to send notifications to.{" "} + + Learn more + + + )} +
+ )} + /> + ); +} diff --git a/apps/dashboard/src/components/forms/components/telegram-qr-connection.tsx b/apps/dashboard/src/components/forms/components/telegram-qr-connection.tsx new file mode 100644 index 00000000..989a71a0 --- /dev/null +++ b/apps/dashboard/src/components/forms/components/telegram-qr-connection.tsx @@ -0,0 +1,114 @@ +import { Button } from "@openstatus/ui/components/ui/button"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { Label } from "@openstatus/ui/components/ui/label"; +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../notifications/form-telegram"; +import { TelegramManualInput } from "./telegram-manual-input"; +import TelegramQRCode from "./telegram-qrcode"; + +interface TelegramQRConnectionProps { + form: UseFormReturn; + token?: string; + isLoading: boolean; + isPolling?: boolean; + flowStep: "private" | "group"; + privateChatId: string | null; + userName?: string | null; + groupTitle?: string | null; + onReset?: () => void; + onConfirmPrivateChat?: () => void; +} + +export function TelegramQRConnection({ + form, + token, + isLoading, + isPolling, + flowStep, + privateChatId, + userName, + groupTitle, + onReset, + onConfirmPrivateChat, +}: TelegramQRConnectionProps) { + const chatId = form.watch("data.chatId"); + const isGroup = !!groupTitle; + + // When we have a chat ID (group or private), show the manual input with connection info + if (chatId) { + const successMsg = isGroup + ? `Connected to ${groupTitle}` + : `Connected to ${userName || "Unknown"}'s private chat`; + return ( +
+ + +
+ ); + } + + // When we have a private chat ID, show read-only info with second QR code + if (privateChatId && flowStep === "group") { + return ( +
+ {/* Show read-only private chat info */} +
+ + + {userName && ( +
+ {`Connected to: ${userName}`} +
+ )} +
+ + {/* Show second QR code for group connection */} +
+ Step 2 of 2: Add bot to your group +
+ + +
+ ); + } + + // Initial state: show first QR code for private chat connection + return ( +
+
+ Step 1 of 2: Connect your Telegram account +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/forms/components/telegram-qrcode.tsx b/apps/dashboard/src/components/forms/components/telegram-qrcode.tsx new file mode 100644 index 00000000..a671cd53 --- /dev/null +++ b/apps/dashboard/src/components/forms/components/telegram-qrcode.tsx @@ -0,0 +1,51 @@ +import { QRCode } from "@openstatus/ui/components/ui/qr-code"; +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; +import { Loader2 } from "lucide-react"; + +export default function TelegramQRCode({ + chatType, + token, + isLoading, + isPolling, +}: { + chatType: "group" | "private"; + token?: string | undefined; + isLoading: boolean; + isPolling?: boolean; +}) { + const telegramBotUserName = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME; + + // Grpoup : t.me/?startgroup=&admin= + // Private Chat: t.me/?start= + + const qrURL = + chatType === "group" + ? `https://t.me/${telegramBotUserName}?startgroup=${token}&admin=post_messages` + : `https://t.me/${telegramBotUserName}?start=${token}`; + + return ( +
+ {isLoading ? ( + + ) : token ? ( + + ) : null} +
+ {isLoading ? ( + "Generating QR Code..." + ) : isPolling ? ( + <> + + {chatType === "private" + ? "Retrieving your account..." + : "Waiting for group connection..."} + + ) : chatType === "private" ? ( + "Scan the QR code to connect your account" + ) : ( + "Scan to add the bot to your group" + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/forms/notifications/form-telegram.tsx b/apps/dashboard/src/components/forms/notifications/form-telegram.tsx index 765c75f4..d6515201 100644 --- a/apps/dashboard/src/components/forms/notifications/form-telegram.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-telegram.tsx @@ -10,25 +10,24 @@ import { FormMessage, } from "@openstatus/ui/components/ui/form"; -import { Link } from "@/components/common/link"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; -import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; -import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { TelegramConnectionFlow } from "../components/telegram-connection-flow"; +import { TelegramFormActions } from "../components/telegram-form-actions"; +import { TelegramManualInput } from "../components/telegram-manual-input"; const schema = z.object({ name: z.string(), @@ -36,10 +35,11 @@ const schema = z.object({ data: z.object({ chatId: z.string(), }), + chatType: z.enum(["group", "private"]), monitors: z.array(z.number()), }); -type FormValues = z.infer; +export type FormValues = z.infer; export function FormTelegram({ monitors, @@ -60,21 +60,25 @@ export function FormTelegram({ data: { chatId: "", }, + chatType: "private", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); - const trpc = useTRPC(); - const sendTestMutation = useMutation( - trpc.notification.sendTest.mutationOptions(), - ); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); + // Check if we're editing an existing notification (has chatID) or creating a new one + const isEditMode = React.useMemo(() => { + return Boolean(defaultValues?.data?.chatId); + }, [defaultValues]); + + const [mode, setMode] = React.useState<"qr" | "manual" | null>(null); + function submitAction(values: FormValues) { if (isPending) return; @@ -92,39 +96,10 @@ export function FormTelegram({ }, }); await promise; - } catch (error) { - console.error(error); - } - }); - } - - function testAction() { - if (isPending) return; - startTransition(async () => { - try { - const provider = form.getValues("provider"); - const data = form.getValues("data"); - const promise = sendTestMutation.mutateAsync({ - provider, - data: { - telegram: { chatId: data.chatId }, - }, - }); - toast.promise(promise, { - loading: "Sending test...", - success: "Test sent", - error: (error) => { - if (isTRPCClientError(error)) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return "Failed to send test"; - }, - }); - await promise; + // Reset UI state after successful submission + setMode(null); + form.reset(); } catch (error) { console.error(error); } @@ -155,39 +130,20 @@ export function FormTelegram({ )} /> - ( - - Telegram Chat ID - - - - - - Enter the Telegram chat ID to send notifications to.{" "} - - Learn more - - - +
+ {isEditMode ? ( + // Edit mode: Show editable chatID input only + + ) : ( + // Create mode: Show QR/manual connection flow + )} - /> -
-
+ diff --git a/apps/dashboard/src/hooks/use-telegram-connection.ts b/apps/dashboard/src/hooks/use-telegram-connection.ts new file mode 100644 index 00000000..1c27e020 --- /dev/null +++ b/apps/dashboard/src/hooks/use-telegram-connection.ts @@ -0,0 +1,200 @@ +"use client"; + +import type { FormValues } from "@/components/forms/notifications/form-telegram"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import React, { useReducer, useTransition } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { toast } from "sonner"; + +interface UseTelegramConnectionProps { + form: UseFormReturn; + mode: "qr" | "manual" | null; +} + +interface TelegramConnectionState { + flowStep: "private" | "group"; + privateChatId: string | null; + userName: string | null; + groupTitle: string | null; + sessionStartTime: number | null; +} + +type TelegramConnectionAction = + | { type: "SET_SESSION_START_TIME"; payload: number | null } + | { type: "RESET_STATE" } + | { type: "RESET_GROUP_CONNECTION" } + | { + type: "SET_PRIVATE_CONNECTION_DATA"; + payload: { + privateChatId: string; + userName: string; + }; + } + | { + type: "SET_GROUP_CONNECTION_DATA"; + payload: { + groupTitle: string; + chatId: string; + }; + }; + +const initialState: TelegramConnectionState = { + flowStep: "private", + privateChatId: null, + userName: null, + groupTitle: null, + sessionStartTime: null, +}; + +function telegramConnectionReducer( + state: TelegramConnectionState, + action: TelegramConnectionAction, +): TelegramConnectionState { + switch (action.type) { + case "SET_SESSION_START_TIME": + return { ...state, sessionStartTime: action.payload }; + case "RESET_STATE": + return initialState; + case "RESET_GROUP_CONNECTION": + return { + ...state, + groupTitle: null, + sessionStartTime: Math.floor(Date.now() / 1000), + flowStep: state.privateChatId ? "group" : "private", + }; + case "SET_PRIVATE_CONNECTION_DATA": + return { + ...state, + privateChatId: action.payload.privateChatId, + userName: action.payload.userName, + flowStep: "group", + }; + case "SET_GROUP_CONNECTION_DATA": + return { + ...state, + groupTitle: action.payload.groupTitle, + }; + default: + return state; + } +} + +export function useTelegramConnection({ + form, + mode, +}: UseTelegramConnectionProps) { + const [isPending, startTransition] = useTransition(); + const trpc = useTRPC(); + const [state, dispatch] = useReducer(telegramConnectionReducer, initialState); + + // Create Telegram Token + const { data: tokenData, isLoading: isTokenLoading } = useQuery({ + ...trpc.notification.createTelegramToken.queryOptions(), + refetchOnWindowFocus: false, + }); + + // Set session start time when entering QR mode + React.useEffect(() => { + if (mode === "qr") { + dispatch({ + type: "SET_SESSION_START_TIME", + payload: Math.floor(Date.now() / 1000), + }); + } else if (mode === null) { + dispatch({ type: "SET_SESSION_START_TIME", payload: null }); + } + }, [mode]); + + // Cleanup: Reset UI state when component unmounts (e.g., on discard) + React.useEffect(() => { + return () => { + // This runs when component unmounts + dispatch({ type: "RESET_STATE" }); + }; + }, []); + + // Start polling for updates + const { data: updates } = useQuery({ + ...trpc.notification.getTelegramUpdates.queryOptions({ + privateChatId: + state.flowStep === "group" + ? state.privateChatId ?? undefined + : undefined, + since: state.sessionStartTime ?? undefined, + }), + enabled: + !!tokenData?.token && !form.getValues("data.chatId") && mode === "qr", + refetchInterval: 5000, + }); + + React.useEffect(() => { + if (updates && updates.length > 0) { + const lastUpdate = updates[updates.length - 1]; + + // Phase 1: Private chat ID received + if (lastUpdate.chatType === "private" && state.flowStep === "private") { + dispatch({ + type: "SET_PRIVATE_CONNECTION_DATA", + payload: { + privateChatId: lastUpdate.chatId, + userName: lastUpdate.user?.first_name || "Unknown", + }, + }); + toast.success( + `Connected to ${lastUpdate.user?.first_name || "Unknown"}'s account. Now add the bot to your group.`, + ); + } + // Phase 2: Group chat ID received + else if (lastUpdate.chatType === "group" && state.flowStep === "group") { + dispatch({ + type: "SET_GROUP_CONNECTION_DATA", + payload: { + groupTitle: lastUpdate.chatTitle || "Unknown", + chatId: lastUpdate.chatId, + }, + }); + startTransition(() => { + form.setValue("data.chatId", lastUpdate.chatId, { + shouldDirty: true, + }); + toast.success( + `Connected to group "${lastUpdate.chatTitle || "Unknown"}"`, + ); + }); + } + } + }, [updates, form, state.flowStep]); + + const resetConnection = React.useCallback(() => { + form.setValue("data.chatId", "", { shouldDirty: true }); + dispatch({ type: "RESET_GROUP_CONNECTION" }); + }, [form]); + + const confirmPrivateChat = React.useCallback(() => { + if (state.privateChatId) { + startTransition(() => { + form.setValue("data.chatId", state.privateChatId ?? "", { + shouldDirty: true, + }); + toast.success( + `Connected to ${state.userName || "Unknown"}'s private chat`, + ); + }); + } + }, [form, state.privateChatId, state.userName]); + + return { + tokenData, + isTokenLoading, + flowStep: state.flowStep, + privateChatId: state.privateChatId, + userName: state.userName, + groupTitle: state.groupTitle, + isPolling: + !!tokenData?.token && !form.watch("data.chatId") && mode === "qr", + resetConnection, + confirmPrivateChat, + isPending, + }; +} diff --git a/apps/web/public/assets/posts/telegram-group-qr-integration/telegram.png b/apps/web/public/assets/posts/telegram-group-qr-integration/telegram.png new file mode 100644 index 0000000000000000000000000000000000000000..239388e6f1907ac7b4ad13304d285ba5683e0070 GIT binary patch literal 29904 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 $TPoB!P0kw3t8H1DXhP6{ao;Xyczj*4Y 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. When they send it, the bot receives a private message containing the token. We verify the token matches what we stored in Redis for that workspace, then return the user's private `chatId` to the frontend. + +**Phase 2 — Group Detection** + +Now that we know *who* initiated the flow (via their private `chatId`), we wait for that same user to add our bot to a group. The bot receives a `new_chat_members` update. We verify: +- The bot was actually added to the group +- The bot was added *by the same user from Phase 1* +- The event happened *after* the session started + +Once both phases complete, we have the group's `chatId` — which is what gets stored and used for alert delivery. + +--- + +## Token Management: One Key per Workspace + +During Phase 1, we need to issue a short-lived, single-use token that links a QR scan back to a specific OpenStatus workspace. + +Our strategy is simple: **one Redis key per workspace**. + +``` +telegram:workspace_token:${workspaceId} → "a3kXp9bN1qRt" (30 min TTL) +``` + +The token is a 12-character random ID generated with `nanoid(12)`. This is short enough to fit comfortably in a Telegram deep-link URL parameter (Telegram limits `start` parameters to 64 characters, giving us plenty of headroom), yet random enough to be effectively unguessable. + +Key properties of this design: + +- **One active token per workspace** — generating a new QR code automatically invalidates the previous one. There's no accumulation of stale tokens floating around. +- **30-minute hard expiry** — set at the Redis level using TTL, so cleanup is free and automatic. +- **Single-use deletion** — once Phase 1 succeeds, we immediately delete the key. Even if a user somehow replays the same QR code, it won't match anymore. + +```ts +// Phase 1 server-side verification +const tokenKey = `telegram:workspace_token:${workspaceId}`; +const storedToken = await redis.get(tokenKey); + +if (storedToken && receivedToken === storedToken) { + await redis.del(tokenKey); // one-time use + return { chatId: String(message.chat.id), user: message.from }; +} +``` + +This approach scales horizontally: adding new workspaces requires no coordination between API instances, since each one can independently manage its own Redis keys. + +--- + +## Stale Update Prevention: Timestamp Filtering + +Here's the subtle problem with `getUpdates`: **Telegram's API returns updates from the last 24 hours by default**. If a user previously added the bot to a group (even accidentally), that event would still be in the update feed. + +Without filtering, Phase 2 would instantly succeed with stale data — connecting the user to the wrong group. + +Our fix: **session start time**. + +When the user clicks "Connect with QR", the frontend records a Unix timestamp (`sessionStartTime = Math.floor(Date.now() / 1000)`). This value is passed as `since` with every polling request. The backend skips any update older than this threshold. + +```ts +const recentUpdates = since + ? updates.filter((u) => u.message && u.message.date >= since) + : updates; +``` + +This also cleanly handles the **reset flow**. If a user accidentally adds the bot to the wrong group, they can click "Reset Group ID". We clear the stored `chatId`, generate a new `sessionStartTime`, and resume polling. The previous group-add event is now older than the new session start — so it gets filtered out automatically, with no server-side cleanup needed. + +The beauty of this approach: **the client drives the filtering logic**, and the server is just a passive validator. No state needs to be stored on the backend beyond the 30-minute token. + +--- + +## Ownership Verification in Phase 2 + +Detecting that the bot was added to a group is straightforward. But we need to make sure *the right user* did it. Otherwise, any user who scans the QR could add the bot to an arbitrary group that doesn't belong to them. + +The verification chain in Phase 2: + +1. The update must be a `group` or `supergroup` event (not a private chat) +2. The `new_chat_members` array must include our bot's username +3. The `message.from.id` must match the `privateChatId` returned from Phase 1 +4. The `message.date` must be `>= since` (the session start time) + + +```ts +function extractGroupBotAddition(update, privateChatId, botUsername) { + const { message } = update; + if (!message || !["group", "supergroup"].includes(message.chat.type)) return null; + if (String(message.from.id) !== privateChatId) return null; + + // Telegram uses all three field names inconsistently across versions + const isBotAdded = + message.new_chat_participant?.username === botUsername || + message.new_chat_member?.username === botUsername || + message.new_chat_members?.some((m) => m.username === botUsername); + + if (!isBotAdded) return null; + + return { chatId: String(message.chat.id), chatTitle: message.chat.title }; +} +``` + +All three Telegram field variants (`new_chat_participant`, `new_chat_member`, `new_chat_members`) are checked because the field name varies across bot API versions and group types. It's defensive, but necessary — we've learned that Telegram's API isn't always consistent across versions and group configurations. + +--- + +## Frontend State: A Reducer-Driven Flow + +Managing the two-phase flow on the client required careful state handling. We used `useReducer` to model the flow explicitly, treating the entire QR connection process as a state machine. + +The state machine looks like this: + +``` +flowStep: "private" → (Phase 1 success) → flowStep: "group" → (Phase 2 success) + ↓ + (Phase 2 success) + ↓ + chatId written to form +``` + +The reducer handles five actions: + +| Action | Effect | +|--------|--------| +| `SET_SESSION_START_TIME` | Captures Unix timestamp when QR mode is entered | +| `SET_PRIVATE_CONNECTION_DATA` | Stores `privateChatId`, advances flow to `"group"` step | +| `SET_GROUP_CONNECTION_DATA` | Stores `groupTitle`, triggers form update with final `chatId` | +| `RESET_GROUP_CONNECTION` | Clears group data, generates new `sessionStartTime`, keeps `privateChatId` | +| `RESET_STATE` | Full reset on unmount or user discard | + +The polling query is driven by this state: + +```ts +const { data: updates } = useQuery({ + ...trpc.notification.getTelegramUpdates.queryOptions({ + privateChatId: state.flowStep === "group" ? state.privateChatId : undefined, + since: state.sessionStartTime ?? undefined, + }), + enabled: !!tokenData?.token && !form.watch("data.chatId") && mode === "qr", + refetchInterval: 5000, +}); +``` + +Polling automatically stops once `chatId` is set in the form — there's no manual cleanup needed. The `enabled` flag handles it reactively. This means the frontend naturally stops polling once the connection succeeds, freeing up resources without explicit teardown logic. + +--- + +## Why This Architecture Scales + +The thing that makes this design hold up at scale is that **the server stays stateless between polls**. + +- No WebSocket connections to manage or monitor for leaks +- No long-lived processes that could accumulate state +- No event listeners that need memory cleanup +- No webhook endpoint that requires uptime SLAs + +Each poll is a short-lived tRPC call that: +1. Fetches updates from Telegram +2. Runs a few comparisons against a single Redis key +3. Returns the result + +Redis handles token TTL and single-use deletion atomically. The frontend manages all UX state locally via the reducer. There's no coordination needed between API instances. + +Adding a new workspace doesn't change anything server-side. The Redis key pattern `telegram:workspace_token:${workspaceId}` scales horizontally with zero coordination between instances. If you double your API servers, the system just works. + +--- + +## The User Experience + +From a user's perspective, the flow is straightforward: + +1. Open the "Connect Telegram" panel in OpenStatus +2. Scan the QR code with your phone +3. Telegram opens a DM with the bot — you hit send on the pre-filled `/start` message +4. The dashboard immediately detects the connection and shows your Telegram username +5. You add the bot to your Telegram group +6. The dashboard detects the group and auto-fills the notification channel +7. Alerts now flow directly to that group + +No webhooks. No manual token copying. No server-side secrets exposed in URLs. No stale data from leftover `getUpdates` history. The setup takes about 10 seconds. + +--- + +## Key Takeaways + +This pattern works well when: + +- **Setup is one-time**: You're not streaming real-time events; you're wiring up a configuration +- **Latency tolerance exists**: A 5-second poll interval is acceptable (vs. instant webhook delivery) +- **Operational simplicity matters**: You want to avoid running a public endpoint with its attendant security and availability requirements + +If you need true real-time bidirectional communication with Telegram (a chat application, for instance), webhooks are the right choice. But for one-shot integrations like "connect your group for alerts," this polling approach is simpler, more reliable, and far easier to deploy. + +--- + +_Set up Telegram alerting on [Openstatus](/app/login) and get notified the moment a monitor fails._ \ No newline at end of file diff --git a/apps/web/src/data/author.ts b/apps/web/src/data/author.ts index ff49b4bf..f859a6f9 100644 --- a/apps/web/src/data/author.ts +++ b/apps/web/src/data/author.ts @@ -2,12 +2,14 @@ export const author = { "Maximilian Kaske": { name: "Maximilian Kaske", url: "https://x.com/mxkaske", - image: "/assets/authors/max.png", }, "Thibault Le Ouay Ducasse": { name: "Thibault Le Ouay Ducasse", url: "https://bsky.app/profile/thibaultleouay.dev", - image: "/assets/authors/thibault.jpeg", + }, + "Moulik Aggarwal": { + name: "Moulik Aggarwal", + url: "https://x.com/aggmoulik", }, } as const; diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 84a1c656..863628a8 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -22,7 +22,13 @@ import { sendTest as sendGoogleChatTest } from "@openstatus/notification-google- import { sendTest as sendGrafanaTest } from "@openstatus/notification-grafana-oncall"; import { sendTest as sendTelegramTest } from "@openstatus/notification-telegram"; import { sendTest as sendWhatsAppTest } from "@openstatus/notification-twillio-whatsapp"; +import { redis } from "@openstatus/upstash"; +import { nanoid } from "nanoid"; +import { + type TelegramGetUpdatesResponse, + processTelegramUpdates, +} from "../service/telegram-updates"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const notificationRouter = createTRPCRouter({ @@ -310,4 +316,50 @@ export const notificationRouter = createTRPCRouter({ message: "Invalid provider", }); }), + + createTelegramToken: protectedProcedure.query(async (opts) => { + const workspaceId = opts.ctx.workspace.id; + const randomId = nanoid(12); + const EXPIRY = 1800; // 30 minutes + + await redis.set(`telegram:workspace_token:${workspaceId}`, randomId, { + ex: EXPIRY, + }); + + return { token: randomId }; + }), + + getTelegramUpdates: protectedProcedure + .input( + z + .object({ + privateChatId: z.string().optional(), + since: z.number().optional(), + }) + .optional(), + ) + .query(async (opts) => { + const res = await fetch( + `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/getUpdates`, + ); + const data = (await res.json()) as TelegramGetUpdatesResponse; + if (!data.ok || !data.result) return []; + + const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME; + if (!botUsername) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Telegram bot username not configured", + }); + } + + return processTelegramUpdates({ + updates: data.result, + workspaceId: opts.ctx.workspace.id, + privateChatId: opts.input?.privateChatId, + since: opts.input?.since, + botUsername, + redisClient: redis, + }); + }), }); diff --git a/packages/api/src/service/telegram-updates.ts b/packages/api/src/service/telegram-updates.ts new file mode 100644 index 00000000..d48e402d --- /dev/null +++ b/packages/api/src/service/telegram-updates.ts @@ -0,0 +1,256 @@ +import type { redis } from "@openstatus/upstash"; + +// ---- Telegram API Types ----------------------------------------------------- + +interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + username?: string; + language_code?: string; +} + +interface TelegramChat { + id: number; + type: "private" | "group" | "supergroup" | "channel"; + title?: string; + first_name?: string; + all_members_are_administrators?: boolean; +} + +interface TelegramMessage { + message_id: number; + from: TelegramUser; + chat: TelegramChat; + date: number; + text?: string; + entities?: Array<{ + offset: number; + length: number; + type: string; + }>; + new_chat_members?: TelegramUser[]; + new_chat_member?: TelegramUser; + new_chat_participant?: TelegramUser; +} + +interface TelegramChatMember { + user: TelegramUser; + status: + | "member" + | "administrator" + | "left" + | "creator" + | "restricted" + | "kicked"; + can_be_edited?: boolean; + can_manage_chat?: boolean; + can_change_info?: boolean; + can_delete_messages?: boolean; + can_invite_users?: boolean; + can_restrict_members?: boolean; + can_pin_messages?: boolean; + can_manage_topics?: boolean; + can_promote_members?: boolean; + can_manage_video_chats?: boolean; + can_post_stories?: boolean; + can_edit_stories?: boolean; + can_delete_stories?: boolean; + is_anonymous?: boolean; +} + +interface TelegramMyChatMemberUpdate { + chat: TelegramChat; + from: TelegramUser; + date: number; + old_chat_member: TelegramChatMember; + new_chat_member: TelegramChatMember; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + my_chat_member?: TelegramMyChatMemberUpdate; +} + +export interface TelegramGetUpdatesResponse { + ok: boolean; + result: TelegramUpdate[]; +} + +export type ValidUpdate = + | { + chatId: string; + chatType: "private"; + user: { id: number; first_name: string; username?: string }; + } + | { + chatId: string; + chatType: "group"; + chatTitle?: string; + user: { id: number; first_name: string; username?: string }; + }; + +// ---- Helpers ---------------------------------------------------------------- + +function extractPrivateChatStart( + update: TelegramUpdate, + token: string, +): { + chatId: string; + user: { id: number; first_name: string; username?: string }; +} | null { + const { message } = update; + + if ( + !message || + message.chat.type !== "private" || + !message.text?.startsWith("/start ") || + !message.from + ) { + return null; + } + + const receivedToken = message.text.split(" ")[1]; + if (!receivedToken || receivedToken !== token) return null; + + return { + chatId: String(message.chat.id), + user: { + id: message.from.id, + first_name: message.from.first_name, + username: message.from.username, + }, + }; +} + +function extractGroupBotAddition( + update: TelegramUpdate, + privateChatId: string, + botUsername: string, +): { + chatId: string; + chatTitle?: string; + user: { id: number; first_name: string; username?: string }; +} | null { + // Modern Telegram Bot API (v5.0+): bot additions come as my_chat_member updates + if (update.my_chat_member) { + const { my_chat_member } = update; + + const isInvalidGroupAddition = + (my_chat_member.chat.type !== "group" && + my_chat_member.chat.type !== "supergroup") || + String(my_chat_member.from.id) !== privateChatId || + my_chat_member.new_chat_member.user.username !== botUsername || + (my_chat_member.new_chat_member.status !== "member" && + my_chat_member.new_chat_member.status !== "administrator"); + + if (isInvalidGroupAddition) { + return null; + } + + return { + chatId: String(my_chat_member.chat.id), + chatTitle: my_chat_member.chat.title, + user: { + id: my_chat_member.from.id, + first_name: my_chat_member.from.first_name, + username: my_chat_member.from.username, + }, + }; + } + + // Legacy fallback: service message with new_chat_member fields + const { message } = update; + + const isInvalidGroupAddition = + !message || + (message.chat.type !== "group" && message.chat.type !== "supergroup") || + !message.from || + String(message.from.id) !== privateChatId; + + if (isInvalidGroupAddition) { + return null; + } + + const isBotAdded = + message.new_chat_participant?.username === botUsername || + message.new_chat_member?.username === botUsername || + message.new_chat_members?.some((m) => m.username === botUsername); + + if (!isBotAdded) return null; + + return { + chatId: String(message.chat.id), + chatTitle: message.chat.title, + user: { + id: message.from.id, + first_name: message.from.first_name, + username: message.from.username, + }, + }; +} + +// ---- Main logic ------------------------------------------------------------- + +export async function processTelegramUpdates(args: { + updates: TelegramUpdate[]; + workspaceId: number; + privateChatId?: string; + since?: number; + botUsername: string; + redisClient: typeof redis; +}): Promise { + const { + updates, + workspaceId, + privateChatId, + since, + botUsername, + redisClient, + } = args; + + const validUpdates: ValidUpdate[] = []; + + // 1. Pre-filter by timestamp + const recentUpdates = since + ? updates.filter((u) => { + if (u.message) return u.message.date >= since; + if (u.my_chat_member) return u.my_chat_member.date >= since; + return false; + }) + : updates; + + // 2. Phase 1: private /start (no privateChatId filter) + if (!privateChatId) { + const tokenKey = `telegram:workspace_token:${workspaceId}`; + const storedToken = await redisClient.get(tokenKey); + + if (storedToken) { + for (const update of recentUpdates) { + const result = extractPrivateChatStart(update, storedToken); + if (result) { + // Single-use token: delete and stop + await redisClient.del(tokenKey); + validUpdates.push({ chatType: "private", ...result }); + break; + } + } + } + } + // 3. Phase 2: group/supergroup additions + else { + for (const update of recentUpdates) { + const result = extractGroupBotAddition( + update, + privateChatId, + botUsername, + ); + if (result) { + validUpdates.push({ chatType: "group", ...result }); + } + } + } + + return validUpdates; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 03d8b529..6245018f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -83,6 +83,7 @@ "lucide-react": "0.525.0", "luxon": "3.5.0", "next": "16.1.6", + "qr-code-styling": "1.9.2", "radix-ui": "1.4.3", "react": "19.2.3", "react-day-picker": "8.10.1", diff --git a/packages/ui/src/components/ui/qr-code.tsx b/packages/ui/src/components/ui/qr-code.tsx new file mode 100644 index 00000000..0ad8d0e1 --- /dev/null +++ b/packages/ui/src/components/ui/qr-code.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import QRCodeStyling, { + type Options as QRCodeOptions, +} from "qr-code-styling"; +import { cn } from "@openstatus/ui/lib/utils"; + +export interface QRCodeProps { + data: string; + image?: string; + size?: number; + className?: string; + options?: Partial; +} + +export const QRCode = ({ + data, + image, + size = 200, + className, + options, +}: QRCodeProps) => { + const [qrCode] = useState( + new QRCodeStyling({ + width: size, + height: size, + type: "svg", + data, + image, + dotsOptions: { + color: "#000000", + type: "rounded", + }, + backgroundOptions: { + color: "#ffffff", + }, + imageOptions: { + crossOrigin: "anonymous", + margin: 20, + }, + ...options, + }), + ); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + qrCode.append(ref.current); + } + }, [qrCode, ref]); + + useEffect(() => { + if (!qrCode) return; + qrCode.update({ + data, + image, + width: size, + height: size, + ...options, + }); + }, [qrCode, data, image, size, options]); + + return ( +
+ ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b503e9..e4bfa954 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2166,6 +2166,9 @@ importers: next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + qr-code-styling: + specifier: 1.9.2 + version: 1.9.2 radix-ui: specifier: 1.4.3 version: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -10513,6 +10516,13 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qr-code-styling@1.9.2: + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} + engines: {node: '>=18.18.0'} + + qrcode-generator@1.5.2: + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -21674,6 +21684,12 @@ snapshots: pure-rand@6.1.0: {} + qr-code-styling@1.9.2: + dependencies: + qrcode-generator: 1.5.2 + + qrcode-generator@1.5.2: {} + qs@6.14.0: dependencies: side-channel: 1.1.0