diff --git a/apps/api/db/migrations.js b/apps/api/db/migrations.js index 2ebbcf5..64469c2 100644 --- a/apps/api/db/migrations.js +++ b/apps/api/db/migrations.js @@ -256,6 +256,11 @@ export const MIGRATIONS = [ } catch (e) { if (!e.message?.includes('duplicate column')) throw e } + try { + db.exec('ALTER TABLE settings ADD COLUMN telegramApiUrl TEXT') + } catch (e) { + if (!e.message?.includes('duplicate column')) throw e + } try { db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER') } catch (e) { diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts index 1542652..ef9707b 100644 --- a/apps/api/src/routes/settings.test.ts +++ b/apps/api/src/routes/settings.test.ts @@ -80,6 +80,33 @@ describe('settings telegram test', () => { const url = String(fetchMock.mock.calls[0]![0]) expect(url).toContain('botoverride-token/') }) + + it('sends to a custom Bot API URL from the test body', async () => { + const fetchMock = vi.fn(async () => Response.json({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await app.inject({ + method: 'POST', + url: '/api/settings/telegram/test', + payload: { + telegramApiUrl: 'http://127.0.0.1:8081', + }, + }) + + expect(String(fetchMock.mock.calls[0]![0])).toBe( + 'http://127.0.0.1:8081/botdb-token/sendMessage', + ) + }) + + it('persists telegramApiUrl via PUT settings', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/settings/settings-main', + payload: { telegramApiUrl: 'https://bots.example.com' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ telegramApiUrl: 'https://bots.example.com' }) + }) }) describe('settings cfdm sync', () => { diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index df2c026..2a8b3cb 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db' import { settingsRepository } from '@cfdm/db/repositories/settings' -import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings' +import { settingsSchema, telegramTestBodySchema, normalizeTelegramApiUrl } from '@cfdm/shared/contracts/settings' import { restartScheduler } from '../services/scheduler.js' import { sendTelegramMessage } from '../services/telegram.js' @@ -51,6 +51,10 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => { const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || '' const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || '' + const apiUrl = + body.telegramApiUrl !== undefined + ? normalizeTelegramApiUrl(body.telegramApiUrl) + : settings?.telegramApiUrl const messageThreadId = body.telegramMessageThreadId !== undefined ? body.telegramMessageThreadId @@ -64,6 +68,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => { chatId, '✅ VPS Tracker: тестовое сообщение', messageThreadId, + apiUrl, ) return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' } }) diff --git a/apps/api/src/services/backup-import.ts b/apps/api/src/services/backup-import.ts index 542b58e..8799af0 100644 --- a/apps/api/src/services/backup-import.ts +++ b/apps/api/src/services/backup-import.ts @@ -117,6 +117,7 @@ export function importJsonSnapshot(data: BackupPayload): void { syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440, telegramBotToken: String(s.telegramBotToken ?? ''), telegramChatId: String(s.telegramChatId ?? ''), + telegramApiUrl: String(s.telegramApiUrl ?? ''), telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''), notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0, notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0, diff --git a/apps/api/src/services/notifications/channels.ts b/apps/api/src/services/notifications/channels.ts index 59651dc..7bc100b 100644 --- a/apps/api/src/services/notifications/channels.ts +++ b/apps/api/src/services/notifications/channels.ts @@ -9,7 +9,13 @@ export async function deliverTelegram( const token = settings.telegramBotToken?.trim() const chatId = settings.telegramChatId?.trim() if (!token || !chatId) return { ok: false, error: 'Telegram не настроен' } - return sendTelegramMessage(token, chatId, messageHtml, settings.telegramMessageThreadId) + return sendTelegramMessage( + token, + chatId, + messageHtml, + settings.telegramMessageThreadId, + settings.telegramApiUrl, + ) } export async function deliverWebhook( diff --git a/apps/api/src/services/notifications/types.ts b/apps/api/src/services/notifications/types.ts index e3b2756..e4ef0b7 100644 --- a/apps/api/src/services/notifications/types.ts +++ b/apps/api/src/services/notifications/types.ts @@ -27,6 +27,7 @@ export interface NotificationPayload { export interface SettingsNotifyRow { telegramBotToken?: string | null telegramChatId?: string | null + telegramApiUrl?: string | null telegramMessageThreadId?: string | null webhookUrl?: string | null webhookEnabled?: number | boolean | null diff --git a/apps/api/src/services/telegram.test.ts b/apps/api/src/services/telegram.test.ts index 1a1587f..0c0f91d 100644 --- a/apps/api/src/services/telegram.test.ts +++ b/apps/api/src/services/telegram.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { formatTelegramApiError, telegramErrorHint } from './telegram.js' +import { + formatTelegramApiError, + telegramErrorHint, + telegramSendMessageUrl, +} from './telegram.js' +import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings' describe('telegramErrorHint', () => { it('maps thread not found', () => { @@ -36,3 +41,32 @@ describe('formatTelegramApiError', () => { expect(msg).toBe('-1001: invalid payload') }) }) + +describe('telegramSendMessageUrl', () => { + it('uses cloud origin by default', () => { + expect(telegramSendMessageUrl('TOKEN')).toBe( + `${DEFAULT_TELEGRAM_API_URL}/botTOKEN/sendMessage`, + ) + }) + + it('builds local HTTP origin', () => { + expect(telegramSendMessageUrl('TOKEN', 'http://127.0.0.1:8081')).toBe( + 'http://127.0.0.1:8081/botTOKEN/sendMessage', + ) + }) + + it('builds HTTPS reverse-proxy origin', () => { + expect(telegramSendMessageUrl('TOKEN', 'https://bots.example.com')).toBe( + 'https://bots.example.com/botTOKEN/sendMessage', + ) + }) + + it('strips trailing slash and /bot suffix', () => { + expect(telegramSendMessageUrl('TOKEN', 'http://127.0.0.1:8081/')).toBe( + 'http://127.0.0.1:8081/botTOKEN/sendMessage', + ) + expect(telegramSendMessageUrl('TOKEN', 'http://127.0.0.1:8081/bot')).toBe( + 'http://127.0.0.1:8081/botTOKEN/sendMessage', + ) + }) +}) diff --git a/apps/api/src/services/telegram.ts b/apps/api/src/services/telegram.ts index 7230f15..218626f 100644 --- a/apps/api/src/services/telegram.ts +++ b/apps/api/src/services/telegram.ts @@ -2,6 +2,17 @@ * Telegram Bot API — отправка уведомлений */ +import { + DEFAULT_TELEGRAM_API_URL, + normalizeTelegramApiUrl, +} from '@cfdm/shared/contracts/settings' + +export { DEFAULT_TELEGRAM_API_URL, normalizeTelegramApiUrl } + +export function telegramSendMessageUrl(token: string, apiUrl?: string | null): string { + return `${normalizeTelegramApiUrl(apiUrl)}/bot${token.trim()}/sendMessage` +} + export interface TelegramSendResult { ok: boolean error?: string @@ -55,6 +66,7 @@ export async function sendTelegramMessage( chatIds: string | string[], text: string, messageThreadId?: string | number | null, + apiUrl?: string | null, ): Promise { if (!token?.trim() || !text?.trim()) { return { ok: false, error: 'Пустой токен или текст' } @@ -75,7 +87,7 @@ export async function sendTelegramMessage( } if (Number.isFinite(threadId)) payload.message_thread_id = threadId - const url = `https://api.telegram.org/bot${token.trim()}/sendMessage` + const url = telegramSendMessageUrl(token, apiUrl) const errors: string[] = [] let anyOk = false diff --git a/apps/web/src/components/layout/system-monitor-popover.test.ts b/apps/web/src/components/layout/system-monitor-popover.test.ts new file mode 100644 index 0000000..346bca8 --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { countRecentFailedNotifications } from './system-monitor-popover' + +const HOUR = 60 * 60 * 1000 +const now = Date.parse('2026-08-22T00:00:00.000Z') + +describe('countRecentFailedNotifications', () => { + it('counts only failed rows within the window', () => { + const rows = [ + { status: 'failed', createdAt: new Date(now - 2 * HOUR).toISOString() }, + { status: 'sent', createdAt: new Date(now - 1 * HOUR).toISOString() }, + { status: 'failed', createdAt: new Date(now - 30 * HOUR).toISOString() }, + ] + expect(countRecentFailedNotifications(rows, 24 * HOUR, now)).toBe(1) + }) + + it('ignores rows without a valid createdAt', () => { + expect( + countRecentFailedNotifications( + [{ status: 'failed', createdAt: null }, { status: 'failed' }], + 24 * HOUR, + now, + ), + ).toBe(0) + }) +}) diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index bc19612..e5d2aa4 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -2,7 +2,7 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { Activity, - ListChecks, + Bell, RefreshCw, Server, Wallet, @@ -103,6 +103,26 @@ function countCurrentSyncFailures(rows: SyncStatusRow[]): number { return failed } +type NotifyLogRow = { + status?: string | null + createdAt?: string | null +} + +const NOTIFY_FAIL_WINDOW_MS = 24 * 60 * 60 * 1000 + +export function countRecentFailedNotifications( + rows: NotifyLogRow[], + maxAgeMs = NOTIFY_FAIL_WINDOW_MS, + now = Date.now(), +): number { + return rows.filter((n) => { + if (String(n.status ?? '').toLowerCase() !== 'failed') return false + const ts = new Date(n.createdAt ?? '').getTime() + if (Number.isNaN(ts)) return false + return now - ts <= maxAgeMs + }).length +} + /** Live system monitor popover (app-shell pattern, VPS Tracker API data). */ export function SystemMonitorPopover() { const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 }) @@ -133,9 +153,7 @@ export function SystemMonitorPopover() { const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? []) const recentSyncFailed = failedSyncCount > 0 const syncAlert = staleSync || recentSyncFailed - const failedNotifications = (notifyQ.data ?? []).filter( - (n) => String(n.status ?? '').toLowerCase() === 'failed', - ).length + const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? []) const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data) const metrics = useMemo( @@ -151,14 +169,14 @@ export function SystemMonitorPopover() { alert: syncAlert, }, { - id: 'inventory', - label: 'Инвентарь', - value: String(issuesCount), + id: 'notifications', + label: 'Уведомления', + value: String(failedNotifications), unit: 'шт.', - percent: Math.min(100, issuesCount * 15), - icon: , - tone: issuesCount > 0 ? 'warning' : 'success', - alert: issuesCount > 0, + percent: Math.min(100, failedNotifications * 15), + icon: , + tone: failedNotifications > 0 ? 'warning' : 'success', + alert: failedNotifications > 0, }, { id: 'runway', @@ -184,10 +202,19 @@ export function SystemMonitorPopover() { alert: downCount > 0, }, ], - [downCount, failedSyncCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert], + [ + downCount, + failedNotifications, + failedSyncCount, + lowBalance, + recentSyncFailed, + runwayDays, + runwayLow, + syncAlert, + ], ) - const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0 + const spiking = metrics.some((m) => m.alert) || !apiOk return ( @@ -247,10 +274,12 @@ export function SystemMonitorPopover() {
API:{' '} - {apiOk ? 'OK' : '—'} + + {apiOk ? 'OK' : '—'} + {' · '} - Уведомлений с ошибкой:{' '} - {failedNotifications} + Инвентарь:{' '} + {issuesCount} {stats?.lastGlobalSyncAt ? ( <> {' · '} diff --git a/apps/web/src/components/settings/use-settings-section.ts b/apps/web/src/components/settings/use-settings-section.ts index 28f4bc4..4fdea2c 100644 --- a/apps/web/src/components/settings/use-settings-section.ts +++ b/apps/web/src/components/settings/use-settings-section.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields' +import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings' import type { SettingsFormValues } from '@/lib/schemas' import type { Settings } from '@/types/entities' @@ -18,6 +19,7 @@ export function settingsToFormValues(s: Settings): SettingsFormValues { syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440, telegramChatId: s.telegramChatId ?? '', telegramBotToken: '', + telegramApiUrl: s.telegramApiUrl || DEFAULT_TELEGRAM_API_URL, notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false, notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false, notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false, diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 77a84c0..277f45e 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -162,6 +162,7 @@ export const api = { sendTelegramTest: (body?: { telegramBotToken?: string telegramChatId?: string + telegramApiUrl?: string telegramMessageThreadId?: string }) => fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index a9ed1ad..7403d76 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider' import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account' import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields' +import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings' export const vpsStatusSchema = z.enum(['active', 'paused', 'archived']) export const tariffTypeSchema = z.enum(['daily', 'monthly']) @@ -98,6 +99,11 @@ export const settingsSchema = z.object({ uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5), telegramChatId: z.string().optional().default(''), telegramBotToken: z.string().optional().default(''), + telegramApiUrl: z + .string() + .url('Невалидный URL') + .optional() + .default(DEFAULT_TELEGRAM_API_URL), telegramMessageThreadId: z.string().optional().default(''), notifyPaymentExpiryEnabled: z.boolean().optional().default(true), notifyNewTariffsEnabled: z.boolean().optional().default(true), diff --git a/apps/web/src/routes/_auth/settings/notifications.tsx b/apps/web/src/routes/_auth/settings/notifications.tsx index 49789e3..13c0351 100644 --- a/apps/web/src/routes/_auth/settings/notifications.tsx +++ b/apps/web/src/routes/_auth/settings/notifications.tsx @@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' import { useMemo } from 'react' import { z } from 'zod' +import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' @@ -41,6 +42,7 @@ export const Route = createFileRoute('/_auth/settings/notifications')({ const notifySchema = z.object({ telegramChatId: z.string().optional().default(''), telegramBotToken: z.string().optional().default(''), + telegramApiUrl: z.string().url('Невалидный URL').default(DEFAULT_TELEGRAM_API_URL), telegramMessageThreadId: z.string().optional().default(''), notifyPaymentExpiryEnabled: z.boolean().default(true), notifyNewTariffsEnabled: z.boolean().default(true), @@ -78,6 +80,7 @@ function SettingsNotificationsPage() { ? { telegramChatId: formValues.telegramChatId ?? '', telegramBotToken: '', + telegramApiUrl: formValues.telegramApiUrl || DEFAULT_TELEGRAM_API_URL, telegramMessageThreadId: formValues.telegramMessageThreadId ?? '', notifyPaymentExpiryEnabled: formValues.notifyPaymentExpiryEnabled ?? true, notifyNewTariffsEnabled: formValues.notifyNewTariffsEnabled ?? true, @@ -102,9 +105,11 @@ function SettingsNotificationsPage() { telegramChatId?: string telegramMessageThreadId?: string telegramBotToken?: string + telegramApiUrl?: string } = { telegramChatId: values.telegramChatId?.trim() || undefined, telegramMessageThreadId: values.telegramMessageThreadId ?? '', + telegramApiUrl: values.telegramApiUrl?.trim() || DEFAULT_TELEGRAM_API_URL, } if (token) payload.telegramBotToken = token return api.sendTelegramTest(payload) @@ -206,6 +211,19 @@ function SettingsNotificationsPage() { {...form.register('telegramBotToken')} /> + + + { }) expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('new-token') }) + + it('defaults telegramApiUrl to cloud origin and persists a custom URL', () => { + const created = settingsRepository.upsert('settings-main', { + telegramChatId: '-100', + }) + expect(created.telegramApiUrl).toBe('https://api.telegram.org') + + const updated = settingsRepository.upsert('settings-main', { + telegramApiUrl: 'http://127.0.0.1:8081/', + }) + expect(updated.telegramApiUrl).toBe('http://127.0.0.1:8081') + }) }) diff --git a/packages/db/src/repositories/settings.ts b/packages/db/src/repositories/settings.ts index a680919..d32de59 100644 --- a/packages/db/src/repositories/settings.ts +++ b/packages/db/src/repositories/settings.ts @@ -3,6 +3,7 @@ import { appSwitcherConfigSchema, type AppSwitcherConfig, } from '@cfdm/shared/contracts/app-switcher' +import { normalizeTelegramApiUrl } from '@cfdm/shared/contracts/settings' import { getDb, schema } from '../index.js' import { getCurrentSpaceId, @@ -111,6 +112,7 @@ function toDto(row: Row | undefined): SettingsDto | undefined { webhookEnabled: Boolean(row.webhookEnabled), integrationEnabled: Boolean(row.integrationEnabled), showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions), + telegramApiUrl: normalizeTelegramApiUrl(row.telegramApiUrl), notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60, uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5, customFields: Array.isArray(customFields) ? customFields : [], @@ -135,6 +137,7 @@ interface SettingsInput { syncTariffsIntervalMinutes?: number telegramBotToken?: string telegramChatId?: string + telegramApiUrl?: string telegramMessageThreadId?: string notifyPaymentExpiryEnabled?: boolean notifyNewTariffsEnabled?: boolean @@ -179,6 +182,10 @@ function buildValues(id: string, spaceId: string, existing: Row | undefined, r: : existing?.telegramBotToken ?? '', telegramChatId: r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '', + telegramApiUrl: + r.telegramApiUrl !== undefined + ? normalizeTelegramApiUrl(r.telegramApiUrl) + : normalizeTelegramApiUrl(existing?.telegramApiUrl), telegramMessageThreadId: r.telegramMessageThreadId !== undefined ? r.telegramMessageThreadId || '' diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index d270852..a7f912c 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -146,6 +146,7 @@ const CORE_TABLE_MIGRATIONS: string[] = [ customFields TEXT, telegramBotToken TEXT, telegramChatId TEXT, + telegramApiUrl TEXT, notifyPaymentExpiryEnabled INTEGER, notifyNewTariffsEnabled INTEGER, telegramMessageThreadId TEXT, @@ -302,6 +303,7 @@ const COLUMN_MIGRATIONS: string[] = [ `ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`, `ALTER TABLE settings ADD COLUMN telegramBotToken TEXT`, `ALTER TABLE settings ADD COLUMN telegramChatId TEXT`, + `ALTER TABLE settings ADD COLUMN telegramApiUrl TEXT`, `ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER`, `ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER`, `ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT`, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 68617aa..7064ab3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -194,6 +194,7 @@ export const settings = sqliteTable('settings', { customFields: text('customFields'), telegramBotToken: text('telegramBotToken'), telegramChatId: text('telegramChatId'), + telegramApiUrl: text('telegramApiUrl'), notifyPaymentExpiryEnabled: integer('notifyPaymentExpiryEnabled'), notifyNewTariffsEnabled: integer('notifyNewTariffsEnabled'), telegramMessageThreadId: text('telegramMessageThreadId'), diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index d6f83fb..5fae67c 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -196,6 +196,7 @@ CREATE TABLE IF NOT EXISTS settings ( customFields TEXT, telegramBotToken TEXT, telegramChatId TEXT, + telegramApiUrl TEXT, notifyPaymentExpiryEnabled INTEGER, notifyNewTariffsEnabled INTEGER, telegramMessageThreadId TEXT, diff --git a/packages/shared/src/contracts/settings.ts b/packages/shared/src/contracts/settings.ts index 30cd822..6145108 100644 --- a/packages/shared/src/contracts/settings.ts +++ b/packages/shared/src/contracts/settings.ts @@ -2,6 +2,21 @@ import { z } from 'zod' import { customFieldsSchema } from './custom-fields.js' import { appSwitcherConfigSchema } from './app-switcher.js' +/** Cloud Bot API origin. Local telegram-bot-api: http://127.0.0.1:8081 or https via TLS proxy. */ +export const DEFAULT_TELEGRAM_API_URL = 'https://api.telegram.org' + +export function normalizeTelegramApiUrl(raw?: string | null): string { + const trimmed = String(raw ?? '').trim() + if (!trimmed) return DEFAULT_TELEGRAM_API_URL + return trimmed.replace(/\/+$/, '').replace(/\/bot$/i, '') +} + +export const telegramApiUrlSchema = z + .string() + .optional() + .transform((value) => normalizeTelegramApiUrl(value)) + .pipe(z.string().url('Невалидный URL')) + export const settingsSchema = z.object({ id: z.string().optional(), baseCurrency: z.string().optional(), @@ -15,6 +30,7 @@ export const settingsSchema = z.object({ uptimeCheckIntervalMinutes: z.coerce.number().optional(), telegramBotToken: z.string().optional(), telegramChatId: z.string().optional(), + telegramApiUrl: telegramApiUrlSchema, telegramMessageThreadId: z.string().optional(), notifyPaymentExpiryEnabled: z.boolean().optional(), notifyNewTariffsEnabled: z.boolean().optional(), @@ -36,6 +52,7 @@ export type Settings = z.infer export const telegramTestBodySchema = z.object({ telegramBotToken: z.string().optional(), telegramChatId: z.string().optional(), + telegramApiUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(), telegramMessageThreadId: z.string().optional(), })