Alerts: a Telegram channel, one chat or group each, connected by a one-time link

The channel sends through the Bot API with httpx only: HTML with escaped text,
cut to fit a message, silent for a warning, a short 429 waited out once, and
errors that say what to do about a blocked bot, a lost chat or a bad token and
never quote the request URL. The bot token is a stored secret: saving a
channel refuses a literal one, the card's "Store token" writes the secret and
a reference, and no endpoint returns it.

A chat id is never typed. Connect starts a ten minute code, and the chat that
sends `/start <code>` back through the bot is the one connected, so it is a
chat somebody opened a link for. The engine fetches updates only while a code
is waiting (no standing poll, no webhook, no inbound address) and ignores
everything else without a reply. Connect needs a saved form, and the form
takes the connected chat in without touching what was typed since.

The token is part of every Bot API URL, so the httpx logger gets the same
filter the request lines have, which now also masks an httpx.URL argument.
This commit is contained in:
2026-10-28 13:06:58 +01:00
parent bfaaa7cd03
commit 5d0ea480bf
14 changed files with 1462 additions and 11 deletions
+43 -1
View File
@@ -485,7 +485,7 @@ export const ChannelSchema = {
},
kind: {
type: 'string',
enum: ['ntfy', 'smtp', 'webhook', 'dashboard', 'webpush'],
enum: ['ntfy', 'smtp', 'webhook', 'dashboard', 'webpush', 'telegram'],
title: 'Kind'
},
enabled: {
@@ -4440,6 +4440,48 @@ export const TargetResourcesSchema = {
description: 'One machine: this engine, or a worker attached to it.'
} as const;
export const TelegramLinkSchema = {
properties: {
url: {
type: 'string',
title: 'Url'
},
group_url: {
type: 'string',
title: 'Group Url'
},
expires_in: {
type: 'integer',
title: 'Expires In'
}
},
type: 'object',
required: ['url', 'group_url', 'expires_in'],
title: 'TelegramLink'
} as const;
export const TelegramLinkStatusSchema = {
properties: {
waiting: {
type: 'boolean',
title: 'Waiting'
},
expires_in: {
type: 'integer',
title: 'Expires In',
default: 0
},
error: {
type: 'string',
title: 'Error',
default: ''
}
},
type: 'object',
required: ['waiting'],
title: 'TelegramLinkStatus'
} as const;
export const TokenSchema = {
properties: {
access_token: {
File diff suppressed because one or more lines are too long
+32 -2
View File
@@ -123,14 +123,14 @@ export type BrainNode = {
*/
export type Channel = {
name: string;
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush' | 'telegram';
enabled?: boolean;
config?: {
[key: string]: unknown;
};
};
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush' | 'telegram';
export type ChatItem = {
role: string;
@@ -1454,6 +1454,18 @@ export type TargetResources = {
in_flight?: number;
};
export type TelegramLink = {
url: string;
group_url: string;
expires_in: number;
};
export type TelegramLinkStatus = {
waiting: boolean;
expires_in?: number;
error?: string;
};
export type Token = {
access_token: string;
token_type?: string;
@@ -1740,6 +1752,24 @@ export type AlertsRemoveWebpushSubscriptionData = {
export type AlertsRemoveWebpushSubscriptionResponse = (Message);
export type AlertsStartTelegramLinkData = {
channelName: string;
};
export type AlertsStartTelegramLinkResponse = (TelegramLink);
export type AlertsReadTelegramLinkData = {
channelName: string;
};
export type AlertsReadTelegramLinkResponse = (TelegramLinkStatus);
export type AlertsCancelTelegramLinkData = {
channelName: string;
};
export type AlertsCancelTelegramLinkResponse = (Message);
export type AlertsTestChannelData = {
channelName: string;
};
@@ -0,0 +1,268 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Check, Copy, ExternalLink, Send, Unplug, Users } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import {
type AlertsConfig,
AlertsService,
type Channel,
SecretsService,
type TelegramLink,
} from "@/client"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
/** Where a token entered here is kept, unless the channel already names one. */
const DEFAULT_SECRET = "telegram-bot"
type Config = Record<string, unknown>
/**
* The two things a Telegram channel needs beyond a name: the bot's token, and
* the chat it writes to.
*
* The token goes to the secrets store and the channel keeps only a reference,
* so it is never shown again. The chat is never typed: the engine hands out a
* link, and the chat that opens it and presses Start is the one connected.
* That needs the stored channel, so Connect waits for a saved form.
*/
export function TelegramConnect({
channel,
dirty,
onPatch,
onConnected,
}: {
channel: Channel
/** Unsaved changes anywhere in the form. */
dirty: boolean
/** Merge into this channel's settings, in the draft. */
onPatch: (config: Config) => void
/** The engine connected a chat: the stored config, to adopt. */
onConnected: (stored: AlertsConfig) => void
}) {
const { showSuccessToast, showErrorToast } = useCustomToast()
const [copied, copy] = useCopyToClipboard()
const config = (channel.config ?? {}) as Config
const reference = config.token as { $secret?: string } | undefined
const secretName = reference?.$secret
const chatId = config.chat_id ? String(config.chat_id) : ""
const label = config.label ? String(config.label) : chatId
const [replacing, setReplacing] = useState(false)
const [token, setToken] = useState("")
const [link, setLink] = useState<TelegramLink | null>(null)
const storeToken = useMutation({
mutationFn: (value: string) =>
SecretsService.saveSecret({
name: secretName ?? DEFAULT_SECRET,
requestBody: { value },
}),
onSuccess: () => {
setToken("")
setReplacing(false)
onPatch({ token: { $secret: secretName ?? DEFAULT_SECRET } })
showSuccessToast("Bot token stored. Save to use it")
},
onError: handleError.bind(showErrorToast),
})
const connect = useMutation({
mutationFn: () =>
AlertsService.startTelegramLink({ channelName: channel.name }),
onSuccess: setLink,
onError: handleError.bind(showErrorToast),
})
const cancel = useMutation({
mutationFn: () =>
AlertsService.cancelTelegramLink({ channelName: channel.name }),
onSettled: () => setLink(null),
})
// The engine listens for the chat only while a code is out. Once it stops,
// the stored channel says whether a chat answered.
const status = useQuery({
queryKey: ["alerts", "telegram-link", channel.name],
queryFn: () =>
AlertsService.readTelegramLink({ channelName: channel.name }),
enabled: link !== null,
refetchInterval: 2000,
})
const finished = link !== null && status.data?.waiting === false
const failure = status.data?.error ?? ""
const handled = useRef<TelegramLink | null>(null)
useEffect(() => {
if (!finished || handled.current === link) return
handled.current = link
setLink(null)
AlertsService.readAlertsConfig().then((stored) => {
const now = stored.channels?.find((c) => c.name === channel.name)?.config
if (now?.chat_id && String(now.chat_id) !== chatId) {
onConnected(stored)
showSuccessToast(`Connected to ${String(now.label ?? now.chat_id)}`)
} else {
showErrorToast(failure || "The link ran out. Connect again")
}
})
}, [
finished,
link,
failure,
channel.name,
chatId,
onConnected,
showSuccessToast,
showErrorToast,
])
const needsSave = dirty
? "Save first. The engine connects the stored channel"
: undefined
const showTokenField = !secretName || replacing
return (
<div className="grid gap-3" data-testid="telegram-connect">
<div className="grid gap-1.5">
<div className="flex items-center gap-1">
<Label className="text-xs text-muted-foreground">Bot token</Label>
<InfoTip label="Bot token">
Create a bot with @BotFather in Telegram and paste its token. It is
stored as a secret and not shown again. Use one bot per instance.
</InfoTip>
</div>
{showTokenField ? (
<div className="flex flex-wrap items-center gap-2">
<Input
type="password"
autoComplete="off"
value={token}
placeholder="123456:ABC-DEF"
aria-label="Bot token"
className="min-w-0 max-w-sm flex-1"
onChange={(event) => setToken(event.target.value)}
/>
<Button
variant="outline"
size="sm-touch"
disabled={!token.trim() || storeToken.isPending}
data-testid="telegram-store-token"
onClick={() => storeToken.mutate(token.trim())}
>
Store token
</Button>
{replacing ? (
<Button
variant="ghost"
size="sm-touch"
onClick={() => {
setReplacing(false)
setToken("")
}}
>
Cancel
</Button>
) : null}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Stored as secret <code className="font-mono">{secretName}</code>
</span>
<Button
variant="ghost"
size="sm-touch"
onClick={() => setReplacing(true)}
>
Replace
</Button>
</div>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">Chat</Label>
{link ? (
<div className="grid gap-2" data-testid="telegram-link">
<span className="text-sm text-muted-foreground">
Open the link in Telegram and press Start.
</span>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm-touch">
<a href={link.url} target="_blank" rel="noreferrer">
<ExternalLink />
Open Telegram
</a>
</Button>
<Button asChild variant="outline" size="sm-touch">
<a href={link.group_url} target="_blank" rel="noreferrer">
<Users />
Add to a group
</a>
</Button>
<Button
variant="outline"
size="sm-touch"
onClick={() => copy(link.url)}
>
{copied === link.url ? <Check /> : <Copy />}
{copied === link.url ? "Copied" : "Copy link"}
</Button>
<Button
variant="ghost"
size="sm-touch"
disabled={cancel.isPending}
onClick={() => cancel.mutate()}
>
Cancel
</Button>
<InfoTip label="Connecting a chat">
Open the link on the device that should get the alerts. Add to a
group connects that group instead, and everyone in it reads the
alerts. The link works once and runs out after ten minutes.
</InfoTip>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 text-sm text-muted-foreground">
{chatId
? `Connected to ${label} (${chatId.startsWith("-") ? "group" : "private chat"})`
: "Not connected"}
</span>
<Button
variant="outline"
size="sm-touch"
disabled={!secretName || dirty || connect.isPending}
title={needsSave}
data-testid="telegram-connect-chat"
onClick={() => connect.mutate()}
>
<Send />
{chatId ? "Reconnect" : "Connect"}
</Button>
{chatId ? (
<Button
variant="ghost"
size="sm-touch"
// Cleared in the draft like any other setting. An undefined
// value is left out of the saved JSON.
onClick={() =>
onPatch({ chat_id: undefined, label: undefined })
}
>
<Unplug />
Disconnect
</Button>
) : null}
</div>
)}
</div>
</div>
)
}
+43 -1
View File
@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Bell, BellOff, Plus, Send, Trash2 } from "lucide-react"
import { useState } from "react"
import { useCallback, useState } from "react"
import { type AlertsConfig, AlertsService, type Channel } from "@/client"
import { TelegramConnect } from "@/components/Alerts/TelegramConnect"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -68,6 +69,8 @@ const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
// Nothing to configure: it goes to whichever browsers subscribed here, which
// is a button rather than a setting.
webpush: [],
// The token and the chat are connected by `TelegramConnect`, not typed.
telegram: [],
}
const KINDS: Channel["kind"][] = [
@@ -76,6 +79,7 @@ const KINDS: Channel["kind"][] = [
"webhook",
"dashboard",
"webpush",
"telegram",
]
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
@@ -218,6 +222,32 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
const rules = draft.rules ?? []
const dirty = JSON.stringify(draft) !== JSON.stringify(saved)
// A chat connected on the server: take it into the form without touching
// what has been typed since, and read the rest as stored.
const adopt = useCallback(
(stored: AlertsConfig) => {
setSaved(stored)
setDraft((current) => ({
...current,
channels: (current.channels ?? []).map((channel) => {
const fresh = stored.channels?.find((c) => c.name === channel.name)
return fresh?.config?.chat_id && channel.kind === "telegram"
? {
...channel,
config: {
...channel.config,
chat_id: fresh.config.chat_id,
label: fresh.config.label,
},
}
: channel
}),
}))
queryClient.setQueryData(alertsKey, stored)
},
[queryClient],
)
const patchChannel = (index: number, patch: Partial<Channel>) =>
setDraft({
...draft,
@@ -359,6 +389,18 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
</div>
{channel.kind === "webpush" ? <ThisBrowser /> : null}
{channel.kind === "telegram" ? (
<TelegramConnect
channel={channel}
dirty={dirty}
onConnected={adopt}
onPatch={(config) =>
patchChannel(index, {
config: { ...channel.config, ...config },
})
}
/>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
{FIELDS[channel.kind].map(([key, label, placeholder]) => (