Add Telegram bot integration and related features
ci / build-backend (push) Successful in 2m38s
ci / build-frontend (push) Successful in 1m16s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 3m48s

Implemented Telegram bot functionality, including settings management, subscriber tracking, and link generation for user interaction. Updated the backend to support new Telegram-related services and database entities. Enhanced the frontend to display Telegram options and allow users to open a chat with the bot. Localization strings were added for both English and Russian to support the new features. This integration aims to improve user engagement through Telegram notifications and interactions.
This commit is contained in:
Leonid Pershin
2026-07-31 07:58:18 +03:00
parent bd3ced3637
commit 2b03e43a83
53 changed files with 6566 additions and 2 deletions
@@ -0,0 +1,287 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { getTelegramSettings, listTelegramSubscribers, updateTelegramSettings } from './api'
import type { TelegramProxyKind, TelegramSettingsBody, TelegramTransport } from './types'
const EMPTY: TelegramSettingsBody = {
isEnabled: false,
transport: 'Polling',
botToken: null,
webhookUrl: null,
proxyKind: 'None',
proxyHost: null,
proxyPort: null,
proxyUsername: null,
proxyPassword: null,
}
/** Пустая строка формы — это «не задано», а для секретов ещё и «не менять». */
function trimmed(value: string): string | null {
const next = value.trim()
return next.length === 0 ? null : next
}
/**
* Бот расписания: токен, способ доставки обновлений, прокси и список подписчиков.
*
* Сохранение сразу проверяет связь — Telegram отвечает на getMe, и неверный токен или мёртвый
* прокси видно тут же, а не через сутки молчания бота.
*/
export function TelegramPanel() {
const { t } = useTranslation()
const onError = useApiError()
const queryClient = useQueryClient()
const [form, setForm] = useState<TelegramSettingsBody>(EMPTY)
const { data: settings, isLoading } = useQuery({
queryKey: ['telegram', 'settings'],
queryFn: getTelegramSettings,
})
const { data: subscribers } = useQuery({
queryKey: ['telegram', 'subscribers'],
queryFn: listTelegramSubscribers,
})
// Форма наполняется тем, что вернул сервер; секреты остаются пустыми — их он не отдаёт.
useEffect(() => {
if (!settings) return
setForm({
isEnabled: settings.isEnabled,
transport: settings.transport,
botToken: null,
webhookUrl: settings.webhookUrl,
proxyKind: settings.proxyKind,
proxyHost: settings.proxyHost,
proxyPort: settings.proxyPort,
proxyUsername: settings.proxyUsername,
proxyPassword: null,
})
}, [settings])
const save = useMutation({
mutationFn: () => updateTelegramSettings(form),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['telegram'] })
toast.success(t('admin.telegram.saved'))
},
onError,
})
const patch = (part: Partial<TelegramSettingsBody>) => setForm((prev) => ({ ...prev, ...part }))
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.telegram.title')}</h2>
{settings?.botUsername && <Badge variant="muted">@{settings.botUsername}</Badge>}
{settings?.isEnabled ? (
<Badge>{t('admin.telegram.enabled')}</Badge>
) : (
<Badge variant="muted">{t('admin.telegram.disabled')}</Badge>
)}
<Badge variant="muted">
{t('admin.telegram.subscribers')}: {settings?.subscribers ?? 0}
</Badge>
</div>
{settings?.lastError && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
{settings.lastError}
</p>
)}
<div className="crt-panel flex flex-col gap-4 rounded-md p-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.isEnabled}
onChange={(e) => patch({ isEnabled: e.target.checked })}
/>
{t('admin.telegram.enable')}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.token')}</Label>
<Input
type="password"
autoComplete="off"
placeholder={settings?.hasToken ? t('admin.telegram.tokenKept') : '123456:ABC...'}
value={form.botToken ?? ''}
onChange={(e) => patch({ botToken: trimmed(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.telegram.tokenHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.transport')}</Label>
<Select
value={form.transport}
onValueChange={(v) => patch({ transport: v as TelegramTransport })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Polling">{t('admin.telegram.transports.Polling')}</SelectItem>
<SelectItem value="Webhook">{t('admin.telegram.transports.Webhook')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`admin.telegram.transportHint.${form.transport}`)}
</p>
</div>
</div>
{form.transport === 'Webhook' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.webhookUrl')}</Label>
<Input
placeholder="https://tv.example.com/api/telegram/webhook"
value={form.webhookUrl ?? ''}
onChange={(e) => patch({ webhookUrl: trimmed(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.telegram.webhookHint')}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.proxy')}</Label>
<Select
value={form.proxyKind}
onValueChange={(v) => patch({ proxyKind: v as TelegramProxyKind })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None">{t('admin.telegram.proxies.None')}</SelectItem>
<SelectItem value="Http">HTTP</SelectItem>
<SelectItem value="Socks5">SOCKS5</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('admin.telegram.proxyHint')}</p>
</div>
{form.proxyKind !== 'None' && (
<div className="grid grid-cols-[1fr_7rem] gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.proxyHost')}</Label>
<Input
value={form.proxyHost ?? ''}
onChange={(e) => patch({ proxyHost: trimmed(e.target.value) })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.proxyPort')}</Label>
<Input
type="number"
min={1}
max={65535}
value={form.proxyPort ?? ''}
onChange={(e) => patch({ proxyPort: Number(e.target.value) || null })}
/>
</div>
</div>
)}
</div>
{form.proxyKind !== 'None' && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.proxyUser')}</Label>
<Input
autoComplete="off"
value={form.proxyUsername ?? ''}
onChange={(e) => patch({ proxyUsername: trimmed(e.target.value) })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.telegram.proxyPassword')}</Label>
<Input
type="password"
autoComplete="off"
placeholder={settings?.hasProxyPassword ? t('admin.telegram.tokenKept') : undefined}
value={form.proxyPassword ?? ''}
onChange={(e) => patch({ proxyPassword: trimmed(e.target.value) })}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
{settings?.lastContactAt && (
<span className="text-xs text-muted-foreground">
{t('admin.telegram.lastContact')}: {new Date(settings.lastContactAt).toLocaleString()}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.telegram.subscribers')}
</h3>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.telegram.user')}</th>
<th className="px-4 py-2 font-medium">{t('admin.telegram.chat')}</th>
<th className="px-4 py-2 font-medium">{t('admin.telegram.channels')}</th>
<th className="px-4 py-2 font-medium">{t('admin.telegram.lastSeen')}</th>
</tr>
</thead>
<tbody>
{(subscribers ?? []).map((s) => (
<tr key={s.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
{s.userName ?? '—'}
{s.isStopped && (
<Badge variant="muted" className="ml-2">
{t('admin.telegram.stopped')}
</Badge>
)}
</td>
<td className="px-4 py-2 text-muted-foreground">
{s.displayName ? `@${s.displayName}` : s.chatId}
</td>
<td className="px-4 py-2 text-muted-foreground">
{s.channels.length === 0
? '—'
: s.channels.map((c) => `${c.channelName} (${c.kinds.length})`).join(', ')}
</td>
<td className="px-4 py-2 text-muted-foreground">
{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : '—'}
</td>
</tr>
))}
{(subscribers ?? []).length === 0 && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
{t('admin.telegram.noSubscribers')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { apiRequest } from '@/shared/api/client'
import type { TelegramSettingsBody, TelegramSettingsDto, TelegramSubscriberDto } from './types'
export function getTelegramSettings() {
return apiRequest<TelegramSettingsDto>('/admin/telegram/settings')
}
export function updateTelegramSettings(body: TelegramSettingsBody) {
return apiRequest<TelegramSettingsDto>('/admin/telegram/settings', { method: 'PUT', body })
}
export function listTelegramSubscribers() {
return apiRequest<TelegramSubscriberDto[]>('/admin/telegram/subscribers')
}
/** Ссылка на чат с одноразовым кодом — её открывает кнопка на странице эфира. */
export function issueTelegramLink() {
return apiRequest<{ url: string; expiresAt: string }>('/telegram/link', { method: 'POST' })
}
@@ -0,0 +1,44 @@
export type TelegramTransport = 'Polling' | 'Webhook'
export type TelegramProxyKind = 'None' | 'Http' | 'Socks5'
export type TelegramSettingsDto = {
isEnabled: boolean
/** Токен наружу не отдаётся — только признак, что он задан. */
hasToken: boolean
botUsername: string | null
botLink: string | null
transport: TelegramTransport
webhookUrl: string | null
proxyKind: TelegramProxyKind
proxyHost: string | null
proxyPort: number | null
proxyUsername: string | null
hasProxyPassword: boolean
lastContactAt: string | null
lastError: string | null
subscribers: number
}
/** Пустые токен и пароль означают «оставить как есть»: форма их не показывает. */
export type TelegramSettingsBody = {
isEnabled: boolean
transport: TelegramTransport
botToken: string | null
webhookUrl: string | null
proxyKind: TelegramProxyKind
proxyHost: string | null
proxyPort: number | null
proxyUsername: string | null
proxyPassword: string | null
}
export type TelegramSubscriberDto = {
id: string
chatId: number
displayName: string | null
userName: string | null
isStopped: boolean
createdAt: string
lastSeenAt: string | null
channels: { channelId: string; channelName: string; kinds: string[] }[]
}
+28 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Download, Radio, RotateCw } from 'lucide-react'
import { Download, Send, Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
@@ -11,6 +11,8 @@ import { toast } from '@/shared/ui/toast-store'
import { ChannelPlayer } from './ChannelPlayer'
import {
downloadIptvPlaylist,
getTelegramStatus,
issueTelegramLink,
getEpg,
getViewerFeatures,
imageUrl,
@@ -80,6 +82,25 @@ export function AirPage() {
})
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
// Настройки бота читаем публичной частью: кнопка нужна только когда бот действительно настроен.
const { data: telegram } = useQuery({
queryKey: qk.air.telegram,
queryFn: getTelegramStatus,
})
/**
* Ссылка на чат выдаётся по нажатию, а не заранее: в ней одноразовый код привязки, и держать
* его в разметке страницы всё время незачем.
*/
const openBot = async () => {
try {
const { url } = await issueTelegramLink()
window.open(url, '_blank', 'noopener')
} catch {
toast.error(t('admin.telegram.linkFailed'))
}
}
const numbersEnabled = features?.channelNumbersEnabled ?? false
const currentChannel = channels?.find((c) => c.slug === selected)
@@ -193,6 +214,12 @@ export function AirPage() {
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
{telegram?.isConfigured && (
<Button size="sm" variant="outline" onClick={() => void openBot()}>
<Send className="h-4 w-4" />
{t('admin.telegram.openChat')}
</Button>
)}
<Button
size="sm"
variant="outline"
+10
View File
@@ -45,3 +45,13 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
const suffix = qs ? `?${qs}` : ''
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${suffix}`)
}
/** Настроен ли бот расписания — по этому флагу показывается кнопка «Открыть чат». */
export function getTelegramStatus() {
return apiRequest<{ isConfigured: boolean; botUsername: string | null }>('/telegram/status')
}
/** Ссылка на чат с одноразовым кодом привязки: выдаётся по нажатию, живёт минуты. */
export function issueTelegramLink() {
return apiRequest<{ url: string; expiresAt: string }>('/telegram/link', { method: 'POST' })
}
+21
View File
@@ -30,6 +30,7 @@ import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
import { Route as AdminStorageRouteImport } from './routes/admin/storage'
import { Route as AdminTelegramRouteImport } from './routes/admin/telegram'
import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
@@ -145,6 +146,11 @@ const AdminStorageRoute = AdminStorageRouteImport.update({
path: '/storage',
getParentRoute: () => AdminRoute,
} as any)
const AdminTelegramRoute = AdminTelegramRouteImport.update({
id: '/telegram',
path: '/telegram',
getParentRoute: () => AdminRoute,
} as any)
const AdminUsersRoute = AdminUsersRouteImport.update({
id: '/users',
path: '/users',
@@ -213,6 +219,7 @@ export interface FileRoutesByFullPath {
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/storage': typeof AdminStorageRoute
'/admin/telegram': typeof AdminTelegramRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -240,6 +247,7 @@ export interface FileRoutesByTo {
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/storage': typeof AdminStorageRoute
'/admin/telegram': typeof AdminTelegramRoute
'/admin/users': typeof AdminUsersRoute
'/admin': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -273,6 +281,7 @@ export interface FileRoutesById {
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/storage': typeof AdminStorageRoute
'/admin/telegram': typeof AdminTelegramRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -307,6 +316,7 @@ export interface FileRouteTypes {
| '/admin/settings'
| '/admin/shows'
| '/admin/storage'
| '/admin/telegram'
| '/admin/users'
| '/admin/'
| '/admin/channels/$channelId'
@@ -334,6 +344,7 @@ export interface FileRouteTypes {
| '/admin/roles'
| '/admin/settings'
| '/admin/storage'
| '/admin/telegram'
| '/admin/users'
| '/admin'
| '/admin/channels/$channelId'
@@ -366,6 +377,7 @@ export interface FileRouteTypes {
| '/admin/settings'
| '/admin/shows'
| '/admin/storage'
| '/admin/telegram'
| '/admin/users'
| '/admin/'
| '/admin/channels/$channelId'
@@ -536,6 +548,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminStorageRouteImport
parentRoute: typeof AdminRoute
}
'/admin/telegram': {
id: '/admin/telegram'
path: '/telegram'
fullPath: '/admin/telegram'
preLoaderRoute: typeof AdminTelegramRouteImport
parentRoute: typeof AdminRoute
}
'/admin/users': {
id: '/admin/users'
path: '/users'
@@ -672,6 +691,7 @@ interface AdminRouteChildren {
AdminSettingsRoute: typeof AdminSettingsRoute
AdminShowsRoute: typeof AdminShowsRouteWithChildren
AdminStorageRoute: typeof AdminStorageRoute
AdminTelegramRoute: typeof AdminTelegramRoute
AdminUsersRoute: typeof AdminUsersRoute
AdminIndexRoute: typeof AdminIndexRoute
}
@@ -691,6 +711,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminSettingsRoute: AdminSettingsRoute,
AdminShowsRoute: AdminShowsRouteWithChildren,
AdminStorageRoute: AdminStorageRoute,
AdminTelegramRoute: AdminTelegramRoute,
AdminUsersRoute: AdminUsersRoute,
AdminIndexRoute: AdminIndexRoute,
}
+7
View File
@@ -113,6 +113,13 @@ function AdminLayout() {
>
{t('admin.maintenance.title')}
</Link>
<Link
to="/admin/telegram"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.telegram.title')}
</Link>
<Link
to="/admin/settings"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { TelegramPanel } from '@/features/admin/telegram/TelegramPanel'
export const Route = createFileRoute('/admin/telegram')({ component: TelegramPanel })
+1
View File
@@ -10,6 +10,7 @@ export const qk = {
},
air: {
telegram: ['air', 'telegram'] as const,
channels: ['air', 'channels'] as const,
features: ['air', 'features'] as const,
epg: (slug: string | null) => ['air', 'epg', slug] as const,
+36
View File
@@ -989,6 +989,42 @@ export const en = {
Other: 'files outside the known directories — usually zero',
},
},
telegram: {
title: 'Telegram',
enable: 'Bot enabled',
enabled: 'Enabled',
disabled: 'Disabled',
saved: 'Saved, connection verified',
token: 'Bot token',
tokenKept: 'set — leave as is',
tokenHint: 'Issued by @BotFather. Never returned: only the fact that it is set.',
transport: 'Delivery',
transports: { Polling: 'Polling (getUpdates)', Webhook: 'Webhook' },
transportHint: {
Polling: 'The server pulls updates itself. Works without a public address and via a proxy.',
Webhook:
'Telegram calls your HTTPS endpoint. Needs a public domain; a proxy will not help.',
},
webhookUrl: 'Webhook URL',
webhookHint: 'Full https URL down to /api/telegram/webhook. The secret is generated for you.',
proxy: 'Proxy',
proxies: { None: 'No proxy' },
proxyHint: 'How to reach api.telegram.org.',
proxyHost: 'Host',
proxyPort: 'Port',
proxyUser: 'User',
proxyPassword: 'Password',
lastContact: 'Last contact',
subscribers: 'Subscribers',
noSubscribers: 'Nobody subscribed yet.',
user: 'User',
chat: 'Chat',
channels: 'Channels',
lastSeen: 'Last seen',
stopped: 'stopped',
openChat: 'Open bot chat',
linkFailed: 'Bot is not configured',
},
maintenance: {
title: 'Maintenance',
warning: 'These actions are irreversible — data and files are deleted permanently.',
+37
View File
@@ -984,6 +984,43 @@ export const ru = {
Other: 'файлы мимо известных каталогов — обычно ноль',
},
},
telegram: {
title: 'Телеграм',
enable: 'Бот включён',
enabled: 'Включён',
disabled: 'Выключен',
saved: 'Настройки сохранены, связь проверена',
token: 'Токен бота',
tokenKept: 'задан — оставить как есть',
tokenHint: 'Выдаётся @BotFather. Наружу не отдаётся: видно только, что он задан.',
transport: 'Способ доставки',
transports: { Polling: 'Опрос (getUpdates)', Webhook: 'Вебхук' },
transportHint: {
Polling: 'Сервер сам ходит за обновлениями. Работает без публичного адреса и через прокси.',
Webhook:
'Telegram стучится на ваш HTTPS-адрес. Нужен доступный извне домен; прокси тут не поможет.',
},
webhookUrl: 'Адрес вебхука',
webhookHint:
'Полный https-адрес до /api/telegram/webhook. Секрет генерируется автоматически.',
proxy: 'Прокси',
proxies: { None: 'Без прокси' },
proxyHint: 'Через что ходить к api.telegram.org.',
proxyHost: 'Адрес',
proxyPort: 'Порт',
proxyUser: 'Пользователь',
proxyPassword: 'Пароль',
lastContact: 'Последняя связь',
subscribers: 'Подписчики',
noSubscribers: 'Пока никто не подписан.',
user: 'Пользователь',
chat: 'Чат',
channels: 'Каналы',
lastSeen: 'Активность',
stopped: 'остановлен',
openChat: 'Открыть чат с ботом',
linkFailed: 'Бот не настроен',
},
maintenance: {
title: 'Обслуживание',
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',