feat(settings): добавить Bot API URL и ячейку ошибок уведомлений
Docker / build (push) Failing after 26s
Docker / build (push) Failing after 26s
Монитор больше не ставит «Внимание» по скрытому счётчику. Telegram можно слать через локальный telegram-bot-api. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -256,6 +256,11 @@ export const MIGRATIONS = [
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!e.message?.includes('duplicate column')) throw 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 {
|
try {
|
||||||
db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER')
|
db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -80,6 +80,33 @@ describe('settings telegram test', () => {
|
|||||||
const url = String(fetchMock.mock.calls[0]![0])
|
const url = String(fetchMock.mock.calls[0]![0])
|
||||||
expect(url).toContain('botoverride-token/')
|
expect(url).toContain('botoverride-token/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends to a custom Bot API URL from the test body', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(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', () => {
|
describe('settings cfdm sync', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db'
|
import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db'
|
||||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
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 { restartScheduler } from '../services/scheduler.js'
|
||||||
import { sendTelegramMessage } from '../services/telegram.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 token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || ''
|
||||||
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
|
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
|
||||||
|
const apiUrl =
|
||||||
|
body.telegramApiUrl !== undefined
|
||||||
|
? normalizeTelegramApiUrl(body.telegramApiUrl)
|
||||||
|
: settings?.telegramApiUrl
|
||||||
const messageThreadId =
|
const messageThreadId =
|
||||||
body.telegramMessageThreadId !== undefined
|
body.telegramMessageThreadId !== undefined
|
||||||
? body.telegramMessageThreadId
|
? body.telegramMessageThreadId
|
||||||
@@ -64,6 +68,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
chatId,
|
chatId,
|
||||||
'✅ VPS Tracker: тестовое сообщение',
|
'✅ VPS Tracker: тестовое сообщение',
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
|
apiUrl,
|
||||||
)
|
)
|
||||||
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
|
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export function importJsonSnapshot(data: BackupPayload): void {
|
|||||||
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
|
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
|
||||||
telegramBotToken: String(s.telegramBotToken ?? ''),
|
telegramBotToken: String(s.telegramBotToken ?? ''),
|
||||||
telegramChatId: String(s.telegramChatId ?? ''),
|
telegramChatId: String(s.telegramChatId ?? ''),
|
||||||
|
telegramApiUrl: String(s.telegramApiUrl ?? ''),
|
||||||
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
|
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
|
||||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
|
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ export async function deliverTelegram(
|
|||||||
const token = settings.telegramBotToken?.trim()
|
const token = settings.telegramBotToken?.trim()
|
||||||
const chatId = settings.telegramChatId?.trim()
|
const chatId = settings.telegramChatId?.trim()
|
||||||
if (!token || !chatId) return { ok: false, error: 'Telegram не настроен' }
|
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(
|
export async function deliverWebhook(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface NotificationPayload {
|
|||||||
export interface SettingsNotifyRow {
|
export interface SettingsNotifyRow {
|
||||||
telegramBotToken?: string | null
|
telegramBotToken?: string | null
|
||||||
telegramChatId?: string | null
|
telegramChatId?: string | null
|
||||||
|
telegramApiUrl?: string | null
|
||||||
telegramMessageThreadId?: string | null
|
telegramMessageThreadId?: string | null
|
||||||
webhookUrl?: string | null
|
webhookUrl?: string | null
|
||||||
webhookEnabled?: number | boolean | null
|
webhookEnabled?: number | boolean | null
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('telegramErrorHint', () => {
|
||||||
it('maps thread not found', () => {
|
it('maps thread not found', () => {
|
||||||
@@ -36,3 +41,32 @@ describe('formatTelegramApiError', () => {
|
|||||||
expect(msg).toBe('-1001: invalid payload')
|
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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
* Telegram Bot API — отправка уведомлений
|
* 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 {
|
export interface TelegramSendResult {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
error?: string
|
error?: string
|
||||||
@@ -55,6 +66,7 @@ export async function sendTelegramMessage(
|
|||||||
chatIds: string | string[],
|
chatIds: string | string[],
|
||||||
text: string,
|
text: string,
|
||||||
messageThreadId?: string | number | null,
|
messageThreadId?: string | number | null,
|
||||||
|
apiUrl?: string | null,
|
||||||
): Promise<TelegramSendResult> {
|
): Promise<TelegramSendResult> {
|
||||||
if (!token?.trim() || !text?.trim()) {
|
if (!token?.trim() || !text?.trim()) {
|
||||||
return { ok: false, error: 'Пустой токен или текст' }
|
return { ok: false, error: 'Пустой токен или текст' }
|
||||||
@@ -75,7 +87,7 @@ export async function sendTelegramMessage(
|
|||||||
}
|
}
|
||||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
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[] = []
|
const errors: string[] = []
|
||||||
let anyOk = false
|
let anyOk = false
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ListChecks,
|
Bell,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -103,6 +103,26 @@ function countCurrentSyncFailures(rows: SyncStatusRow[]): number {
|
|||||||
return failed
|
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). */
|
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||||
export function SystemMonitorPopover() {
|
export function SystemMonitorPopover() {
|
||||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||||
@@ -133,9 +153,7 @@ export function SystemMonitorPopover() {
|
|||||||
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
||||||
const recentSyncFailed = failedSyncCount > 0
|
const recentSyncFailed = failedSyncCount > 0
|
||||||
const syncAlert = staleSync || recentSyncFailed
|
const syncAlert = staleSync || recentSyncFailed
|
||||||
const failedNotifications = (notifyQ.data ?? []).filter(
|
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
||||||
(n) => String(n.status ?? '').toLowerCase() === 'failed',
|
|
||||||
).length
|
|
||||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||||
|
|
||||||
const metrics = useMemo<MonitorMetric[]>(
|
const metrics = useMemo<MonitorMetric[]>(
|
||||||
@@ -151,14 +169,14 @@ export function SystemMonitorPopover() {
|
|||||||
alert: syncAlert,
|
alert: syncAlert,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'inventory',
|
id: 'notifications',
|
||||||
label: 'Инвентарь',
|
label: 'Уведомления',
|
||||||
value: String(issuesCount),
|
value: String(failedNotifications),
|
||||||
unit: 'шт.',
|
unit: 'шт.',
|
||||||
percent: Math.min(100, issuesCount * 15),
|
percent: Math.min(100, failedNotifications * 15),
|
||||||
icon: <ListChecks aria-hidden />,
|
icon: <Bell aria-hidden />,
|
||||||
tone: issuesCount > 0 ? 'warning' : 'success',
|
tone: failedNotifications > 0 ? 'warning' : 'success',
|
||||||
alert: issuesCount > 0,
|
alert: failedNotifications > 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runway',
|
id: 'runway',
|
||||||
@@ -184,10 +202,19 @@ export function SystemMonitorPopover() {
|
|||||||
alert: downCount > 0,
|
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 (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -247,10 +274,12 @@ export function SystemMonitorPopover() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||||
API:{' '}
|
API:{' '}
|
||||||
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
|
<span className={cn('font-medium', apiOk ? 'text-foreground' : 'text-destructive')}>
|
||||||
|
{apiOk ? 'OK' : '—'}
|
||||||
|
</span>
|
||||||
{' · '}
|
{' · '}
|
||||||
Уведомлений с ошибкой:{' '}
|
Инвентарь:{' '}
|
||||||
<span className="text-foreground font-medium tabular-nums">{failedNotifications}</span>
|
<span className="text-foreground font-medium tabular-nums">{issuesCount}</span>
|
||||||
{stats?.lastGlobalSyncAt ? (
|
{stats?.lastGlobalSyncAt ? (
|
||||||
<>
|
<>
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
|
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 { SettingsFormValues } from '@/lib/schemas'
|
||||||
import type { Settings } from '@/types/entities'
|
import type { Settings } from '@/types/entities'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
||||||
telegramChatId: s.telegramChatId ?? '',
|
telegramChatId: s.telegramChatId ?? '',
|
||||||
telegramBotToken: '',
|
telegramBotToken: '',
|
||||||
|
telegramApiUrl: s.telegramApiUrl || DEFAULT_TELEGRAM_API_URL,
|
||||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
||||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export const api = {
|
|||||||
sendTelegramTest: (body?: {
|
sendTelegramTest: (body?: {
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
|
telegramApiUrl?: string
|
||||||
telegramMessageThreadId?: string
|
telegramMessageThreadId?: string
|
||||||
}) =>
|
}) =>
|
||||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
|
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
|||||||
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
|
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
|
||||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||||
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
|
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 vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
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),
|
uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5),
|
||||||
telegramChatId: z.string().optional().default(''),
|
telegramChatId: z.string().optional().default(''),
|
||||||
telegramBotToken: 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(''),
|
telegramMessageThreadId: z.string().optional().default(''),
|
||||||
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
||||||
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -41,6 +42,7 @@ export const Route = createFileRoute('/_auth/settings/notifications')({
|
|||||||
const notifySchema = z.object({
|
const notifySchema = z.object({
|
||||||
telegramChatId: z.string().optional().default(''),
|
telegramChatId: z.string().optional().default(''),
|
||||||
telegramBotToken: z.string().optional().default(''),
|
telegramBotToken: z.string().optional().default(''),
|
||||||
|
telegramApiUrl: z.string().url('Невалидный URL').default(DEFAULT_TELEGRAM_API_URL),
|
||||||
telegramMessageThreadId: z.string().optional().default(''),
|
telegramMessageThreadId: z.string().optional().default(''),
|
||||||
notifyPaymentExpiryEnabled: z.boolean().default(true),
|
notifyPaymentExpiryEnabled: z.boolean().default(true),
|
||||||
notifyNewTariffsEnabled: z.boolean().default(true),
|
notifyNewTariffsEnabled: z.boolean().default(true),
|
||||||
@@ -78,6 +80,7 @@ function SettingsNotificationsPage() {
|
|||||||
? {
|
? {
|
||||||
telegramChatId: formValues.telegramChatId ?? '',
|
telegramChatId: formValues.telegramChatId ?? '',
|
||||||
telegramBotToken: '',
|
telegramBotToken: '',
|
||||||
|
telegramApiUrl: formValues.telegramApiUrl || DEFAULT_TELEGRAM_API_URL,
|
||||||
telegramMessageThreadId: formValues.telegramMessageThreadId ?? '',
|
telegramMessageThreadId: formValues.telegramMessageThreadId ?? '',
|
||||||
notifyPaymentExpiryEnabled: formValues.notifyPaymentExpiryEnabled ?? true,
|
notifyPaymentExpiryEnabled: formValues.notifyPaymentExpiryEnabled ?? true,
|
||||||
notifyNewTariffsEnabled: formValues.notifyNewTariffsEnabled ?? true,
|
notifyNewTariffsEnabled: formValues.notifyNewTariffsEnabled ?? true,
|
||||||
@@ -102,9 +105,11 @@ function SettingsNotificationsPage() {
|
|||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
telegramMessageThreadId?: string
|
telegramMessageThreadId?: string
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
|
telegramApiUrl?: string
|
||||||
} = {
|
} = {
|
||||||
telegramChatId: values.telegramChatId?.trim() || undefined,
|
telegramChatId: values.telegramChatId?.trim() || undefined,
|
||||||
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
|
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
|
||||||
|
telegramApiUrl: values.telegramApiUrl?.trim() || DEFAULT_TELEGRAM_API_URL,
|
||||||
}
|
}
|
||||||
if (token) payload.telegramBotToken = token
|
if (token) payload.telegramBotToken = token
|
||||||
return api.sendTelegramTest(payload)
|
return api.sendTelegramTest(payload)
|
||||||
@@ -206,6 +211,19 @@ function SettingsNotificationsPage() {
|
|||||||
{...form.register('telegramBotToken')}
|
{...form.register('telegramBotToken')}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Bot API URL"
|
||||||
|
description="Свой telegram-bot-api: http://127.0.0.1:8081 или https://bots.example.com (Let's Encrypt на reverse proxy)"
|
||||||
|
labelFor="set-tg-api-url"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="set-tg-api-url"
|
||||||
|
className="w-full"
|
||||||
|
placeholder={DEFAULT_TELEGRAM_API_URL}
|
||||||
|
{...form.register('telegramApiUrl')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Thread ID"
|
title="Thread ID"
|
||||||
description="Топик форума (необязательно)"
|
description="Топик форума (необязательно)"
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export interface Settings {
|
|||||||
webhookEnabled?: boolean
|
webhookEnabled?: boolean
|
||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
|
telegramApiUrl?: string
|
||||||
telegramMessageThreadId?: string
|
telegramMessageThreadId?: string
|
||||||
telegramBotTokenSet?: boolean
|
telegramBotTokenSet?: boolean
|
||||||
customFields?: CustomFieldDef[]
|
customFields?: CustomFieldDef[]
|
||||||
|
|||||||
@@ -30,4 +30,16 @@ describe('settingsRepository', () => {
|
|||||||
})
|
})
|
||||||
expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('new-token')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
appSwitcherConfigSchema,
|
appSwitcherConfigSchema,
|
||||||
type AppSwitcherConfig,
|
type AppSwitcherConfig,
|
||||||
} from '@cfdm/shared/contracts/app-switcher'
|
} from '@cfdm/shared/contracts/app-switcher'
|
||||||
|
import { normalizeTelegramApiUrl } from '@cfdm/shared/contracts/settings'
|
||||||
import { getDb, schema } from '../index.js'
|
import { getDb, schema } from '../index.js'
|
||||||
import {
|
import {
|
||||||
getCurrentSpaceId,
|
getCurrentSpaceId,
|
||||||
@@ -111,6 +112,7 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
webhookEnabled: Boolean(row.webhookEnabled),
|
webhookEnabled: Boolean(row.webhookEnabled),
|
||||||
integrationEnabled: Boolean(row.integrationEnabled),
|
integrationEnabled: Boolean(row.integrationEnabled),
|
||||||
showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions),
|
showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions),
|
||||||
|
telegramApiUrl: normalizeTelegramApiUrl(row.telegramApiUrl),
|
||||||
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||||
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
@@ -135,6 +137,7 @@ interface SettingsInput {
|
|||||||
syncTariffsIntervalMinutes?: number
|
syncTariffsIntervalMinutes?: number
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
|
telegramApiUrl?: string
|
||||||
telegramMessageThreadId?: string
|
telegramMessageThreadId?: string
|
||||||
notifyPaymentExpiryEnabled?: boolean
|
notifyPaymentExpiryEnabled?: boolean
|
||||||
notifyNewTariffsEnabled?: boolean
|
notifyNewTariffsEnabled?: boolean
|
||||||
@@ -179,6 +182,10 @@ function buildValues(id: string, spaceId: string, existing: Row | undefined, r:
|
|||||||
: existing?.telegramBotToken ?? '',
|
: existing?.telegramBotToken ?? '',
|
||||||
telegramChatId:
|
telegramChatId:
|
||||||
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
|
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
|
||||||
|
telegramApiUrl:
|
||||||
|
r.telegramApiUrl !== undefined
|
||||||
|
? normalizeTelegramApiUrl(r.telegramApiUrl)
|
||||||
|
: normalizeTelegramApiUrl(existing?.telegramApiUrl),
|
||||||
telegramMessageThreadId:
|
telegramMessageThreadId:
|
||||||
r.telegramMessageThreadId !== undefined
|
r.telegramMessageThreadId !== undefined
|
||||||
? r.telegramMessageThreadId || ''
|
? r.telegramMessageThreadId || ''
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ const CORE_TABLE_MIGRATIONS: string[] = [
|
|||||||
customFields TEXT,
|
customFields TEXT,
|
||||||
telegramBotToken TEXT,
|
telegramBotToken TEXT,
|
||||||
telegramChatId TEXT,
|
telegramChatId TEXT,
|
||||||
|
telegramApiUrl TEXT,
|
||||||
notifyPaymentExpiryEnabled INTEGER,
|
notifyPaymentExpiryEnabled INTEGER,
|
||||||
notifyNewTariffsEnabled INTEGER,
|
notifyNewTariffsEnabled INTEGER,
|
||||||
telegramMessageThreadId TEXT,
|
telegramMessageThreadId TEXT,
|
||||||
@@ -302,6 +303,7 @@ const COLUMN_MIGRATIONS: string[] = [
|
|||||||
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
|
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN telegramBotToken TEXT`,
|
`ALTER TABLE settings ADD COLUMN telegramBotToken TEXT`,
|
||||||
`ALTER TABLE settings ADD COLUMN telegramChatId 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 notifyPaymentExpiryEnabled INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER`,
|
`ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT`,
|
`ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT`,
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const settings = sqliteTable('settings', {
|
|||||||
customFields: text('customFields'),
|
customFields: text('customFields'),
|
||||||
telegramBotToken: text('telegramBotToken'),
|
telegramBotToken: text('telegramBotToken'),
|
||||||
telegramChatId: text('telegramChatId'),
|
telegramChatId: text('telegramChatId'),
|
||||||
|
telegramApiUrl: text('telegramApiUrl'),
|
||||||
notifyPaymentExpiryEnabled: integer('notifyPaymentExpiryEnabled'),
|
notifyPaymentExpiryEnabled: integer('notifyPaymentExpiryEnabled'),
|
||||||
notifyNewTariffsEnabled: integer('notifyNewTariffsEnabled'),
|
notifyNewTariffsEnabled: integer('notifyNewTariffsEnabled'),
|
||||||
telegramMessageThreadId: text('telegramMessageThreadId'),
|
telegramMessageThreadId: text('telegramMessageThreadId'),
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
customFields TEXT,
|
customFields TEXT,
|
||||||
telegramBotToken TEXT,
|
telegramBotToken TEXT,
|
||||||
telegramChatId TEXT,
|
telegramChatId TEXT,
|
||||||
|
telegramApiUrl TEXT,
|
||||||
notifyPaymentExpiryEnabled INTEGER,
|
notifyPaymentExpiryEnabled INTEGER,
|
||||||
notifyNewTariffsEnabled INTEGER,
|
notifyNewTariffsEnabled INTEGER,
|
||||||
telegramMessageThreadId TEXT,
|
telegramMessageThreadId TEXT,
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { z } from 'zod'
|
|||||||
import { customFieldsSchema } from './custom-fields.js'
|
import { customFieldsSchema } from './custom-fields.js'
|
||||||
import { appSwitcherConfigSchema } from './app-switcher.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({
|
export const settingsSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
baseCurrency: z.string().optional(),
|
baseCurrency: z.string().optional(),
|
||||||
@@ -15,6 +30,7 @@ export const settingsSchema = z.object({
|
|||||||
uptimeCheckIntervalMinutes: z.coerce.number().optional(),
|
uptimeCheckIntervalMinutes: z.coerce.number().optional(),
|
||||||
telegramBotToken: z.string().optional(),
|
telegramBotToken: z.string().optional(),
|
||||||
telegramChatId: z.string().optional(),
|
telegramChatId: z.string().optional(),
|
||||||
|
telegramApiUrl: telegramApiUrlSchema,
|
||||||
telegramMessageThreadId: z.string().optional(),
|
telegramMessageThreadId: z.string().optional(),
|
||||||
notifyPaymentExpiryEnabled: z.boolean().optional(),
|
notifyPaymentExpiryEnabled: z.boolean().optional(),
|
||||||
notifyNewTariffsEnabled: z.boolean().optional(),
|
notifyNewTariffsEnabled: z.boolean().optional(),
|
||||||
@@ -36,6 +52,7 @@ export type Settings = z.infer<typeof settingsSchema>
|
|||||||
export const telegramTestBodySchema = z.object({
|
export const telegramTestBodySchema = z.object({
|
||||||
telegramBotToken: z.string().optional(),
|
telegramBotToken: z.string().optional(),
|
||||||
telegramChatId: z.string().optional(),
|
telegramChatId: z.string().optional(),
|
||||||
|
telegramApiUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||||
telegramMessageThreadId: z.string().optional(),
|
telegramMessageThreadId: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user