Add Telegram bot integration and related features
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:
@@ -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[] }[]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 })
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'Операции необратимы — удаляют данные и файлы навсегда.',
|
||||
|
||||
Reference in New Issue
Block a user