diff --git a/backend/package.json b/backend/package.json index ad11e487..521933da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "npm run build -w @mmapp/contracts && tsc", - "test:alerts-telegram": "tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts", + "test:alerts-telegram": "tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts", "test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts", "start": "node dist/index.js", "db:generate": "drizzle-kit generate", diff --git a/backend/src/services/alerts-service.ts b/backend/src/services/alerts-service.ts index 98932d66..c223b69d 100644 --- a/backend/src/services/alerts-service.ts +++ b/backend/src/services/alerts-service.ts @@ -1,3 +1,4 @@ +import {deliverTelegramAlert,type TelegramAlertInput} from "./alerts-telegram-delivery.js" import {alertsConfigRevision} from "./alerts-revision.js" import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js" import {executeAlertsConfigReplacement} from "./alerts-config-execution.js" @@ -358,32 +359,8 @@ export function formatAlertRuleTestTelegramText(opts: { } /** Отправка текста в Telegram (движок алертов и тест). */ -export async function sendTelegramAlertMessage(opts: { - text: string - /** Если задан — вместо токена из БД (ручной тест) */ - token?: string - /** Пусто — глобальный chat из БД */ - chatId?: string - /** Переопределение темы; undefined — как в настройках */ - messageThreadId?: number | null -}): Promise<{ ok: true } | { ok: false; error: string }> { - const token = (opts.token?.trim() || await getTelegramBotToken()).trim() - if (!token) return { ok: false, error: "Не задан Bot Token" } - const pub = await getTelegramPublic() - const chatId = (opts.chatId?.trim() || pub.chatId || "").trim() - if (!chatId) return { ok: false, error: "Не задан Chat ID" } - const threadId = - opts.messageThreadId !== undefined && opts.messageThreadId !== null - ? opts.messageThreadId >= 1 - ? Math.floor(opts.messageThreadId) - : undefined - : await getTelegramMessageThreadIdForApi() - const payload: { chat_id: string; text: string; message_thread_id?: number } = { - chat_id: chatId, - text: opts.text, - } - if (threadId != null) payload.message_thread_id = threadId - return sendTelegramRequest(token,payload) +export async function sendTelegramAlertMessage(opts:TelegramAlertInput){ + return deliverTelegramAlert(opts,{readStored:getTelegramSettingsRow,send:sendTelegramRequest}) } export async function appendAlertHistory(entry: { diff --git a/backend/src/services/alerts-telegram-delivery.test.ts b/backend/src/services/alerts-telegram-delivery.test.ts new file mode 100644 index 00000000..68c36b1b --- /dev/null +++ b/backend/src/services/alerts-telegram-delivery.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {deliverTelegramAlert} from "./alerts-telegram-delivery.js" +test("engine Telegram delivery freezes one stored snapshot for token/chat/thread",async()=>{ + let reads=0,sends=0;const result=await deliverTelegramAlert({text:"fixture"},{readStored:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:9}},send:async(token,payload)=>{sends++;assert.equal(token,"fixture-only");assert.deepEqual(payload,{chat_id:"123",text:"fixture",message_thread_id:9});return {ok:true}}});assert.deepEqual(result,{ok:true});assert.equal(reads,1);assert.equal(sends,1) +}) +test("missing/invalid stored data and read failure reject before POST without exposing a secret",async()=>{ + let sends=0;for(const row of [null,{}, {botToken:"fixture-only",chatId:"123",messageThreadId:1.5},{botToken:"",chatId:"123",messageThreadId:null}]){const result=await deliverTelegramAlert({text:"fixture"},{readStored:async()=>row,send:async()=>{sends++;return {ok:true}}});assert.equal(result.ok,false);if(!result.ok)assert.equal(result.outcome,"rejected");assert.equal(JSON.stringify(result).includes("fixture-only"),false)} + const failed=await deliverTelegramAlert({text:"fixture"},{readStored:async()=>{throw Error("fixture-only DB failed")},send:async()=>{sends++;return {ok:true}}});assert.equal(failed.ok,false);if(!failed.ok)assert.equal(failed.outcome,"rejected");assert.equal(sends,0) +}) +test("POST exception is unknown with no automatic retry and malformed override is not sent",async()=>{ + let sends=0;const deps={readStored:async()=>({botToken:"fixture-only",chatId:"123",messageThreadId:null}),send:async()=>{sends++;throw Error("fixture-only upstream failed")}} + const invalid=await deliverTelegramAlert({text:"fixture",messageThreadId:1.5},deps);assert.equal(invalid.ok,false);assert.equal(sends,0) + const unknown=await deliverTelegramAlert({text:"fixture"},deps);assert.equal(unknown.ok,false);if(!unknown.ok)assert.equal(unknown.outcome,"unknown");assert.equal(sends,1);assert.equal(JSON.stringify(unknown).includes("fixture-only"),false) +}) diff --git a/backend/src/services/alerts-telegram-delivery.ts b/backend/src/services/alerts-telegram-delivery.ts new file mode 100644 index 00000000..dc5e81e5 --- /dev/null +++ b/backend/src/services/alerts-telegram-delivery.ts @@ -0,0 +1,18 @@ +import {z} from "zod" +import {sendTelegramRequest,type TelegramSendResult} from "./alerts-telegram-send.js" +const storedSchema=z.object({botToken:z.string(),chatId:z.string(),messageThreadId:z.number().int().positive().max(2147483647).nullable()}) +export type TelegramAlertInput={text:string;token?:string;chatId?:string;messageThreadId?:number|null} +export async function deliverTelegramAlert(input:TelegramAlertInput,deps:{readStored:()=>Promise;send:typeof sendTelegramRequest}):Promise{ + let dispatched=false + try{ + const parsed=storedSchema.safeParse(await deps.readStored()) + if(!parsed.success)return {ok:false,outcome:"rejected",error:"Сохранённые параметры Telegram не подтверждены"} + const stored=parsed.data,token=(input.token?.trim()||stored.botToken).trim(),chatId=(input.chatId?.trim()||stored.chatId).trim() + if(!token)return {ok:false,outcome:"rejected",error:"Не задан Bot Token"} + if(!chatId)return {ok:false,outcome:"rejected",error:"Не задан Chat ID"} + const thread=input.messageThreadId??stored.messageThreadId + if(thread!==null&&(!Number.isInteger(thread)||thread<1||thread>2147483647))return {ok:false,outcome:"rejected",error:"ID темы Telegram не подтверждён"} + dispatched=true + return await deps.send(token,{chat_id:chatId,text:input.text,...(thread!==null?{message_thread_id:thread}:{})}) + }catch{return {ok:false,outcome:dispatched?"unknown":"rejected",error:dispatched?"Результат отправки в Telegram неизвестен":"Не удалось прочитать параметры Telegram до отправки"}} +} diff --git a/backend/src/services/alerts-telegram-send.test.ts b/backend/src/services/alerts-telegram-send.test.ts index 88f0734a..a5db5ca2 100644 --- a/backend/src/services/alerts-telegram-send.test.ts +++ b/backend/src/services/alerts-telegram-send.test.ts @@ -10,6 +10,11 @@ test("Telegram send uses bounded single POST and preserves confirmed result",asy let calls=0;const result=await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture",message_thread_id:1},async(url,init)=>{calls++;assert.equal(url,"https://api.telegram.org/botfixture%3Asecret/sendMessage");assert.equal(init?.method,"POST");assert.ok(init?.signal);assert.deepEqual(JSON.parse(init?.body as string),{chat_id:"123",text:"fixture",message_thread_id:1});return Response.json({ok:true})});assert.deepEqual(result,{ok:true});assert.equal(calls,1) }) test("Telegram network unknown has no retry or secret; upstream error is redacted",async()=>{ - let calls=0;assert.deepEqual(await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture"},async()=>{calls++;throw Error("fixture:secret network URL")}),{ok:false,error:"Результат отправки в Telegram неизвестен: ответ не получен"});assert.equal(calls,1) + let calls=0;assert.deepEqual(await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture"},async()=>{calls++;throw Error("fixture:secret network URL")}),{ok:false,outcome:"unknown",error:"Результат отправки в Telegram неизвестен: ответ не получен"});assert.equal(calls,1) const result=await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture"},async()=>Response.json({ok:false,description:"fixture:secret fixture%3Asecret"}));assert.equal(result.ok,false);if(!result.ok)assert.equal(result.error,"[TOKEN] [TOKEN]") }) + +test("Telegram rejects explicitly only with a valid negative API receipt; ambiguous responses stay unknown",async()=>{ + const rejected=await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>Response.json({ok:false,error_code:429,description:"retry later"},{status:429}));assert.equal(rejected.ok,false);if(!rejected.ok)assert.equal(rejected.outcome,"rejected") + for(const body of [{ok:false,description:"proxy failure"},{ok:false,error_code:"429",description:"bad type"},{ok:true}]){const result=await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>Response.json(body,{status:503}));assert.equal(result.ok,false);if(!result.ok)assert.equal(result.outcome,"unknown")} +}) diff --git a/backend/src/services/alerts-telegram-send.ts b/backend/src/services/alerts-telegram-send.ts index e49fb55e..a89bf92f 100644 --- a/backend/src/services/alerts-telegram-send.ts +++ b/backend/src/services/alerts-telegram-send.ts @@ -1,11 +1,13 @@ type Payload={chat_id:string;text:string;message_thread_id?:number} -export async function sendTelegramRequest(token:string,payload:Payload,fetcher:typeof fetch=fetch):Promise<{ok:true}|{ok:false;error:string}>{ +export type TelegramSendResult={ok:true}|{ok:false;outcome:"rejected"|"unknown";error:string} +export async function sendTelegramRequest(token:string,payload:Payload,fetcher:typeof fetch=fetch):Promise{ const redact=(message:string)=>message.replaceAll(token,"[TOKEN]").replaceAll(encodeURIComponent(token),"[TOKEN]") try{ const response=await fetcher("https://api.telegram.org/bot"+encodeURIComponent(token)+"/sendMessage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload),signal:AbortSignal.timeout(30_000)}) const raw:unknown=await response.json().catch(()=>undefined) if(response.ok&&typeof raw==="object"&&raw!==null&&"ok" in raw&&raw.ok===true)return {ok:true} const description=typeof raw==="object"&&raw!==null&&"description" in raw&&typeof raw.description==="string"?raw.description:"Telegram не подтвердил приём сообщения" - return {ok:false,error:redact(description)} - }catch{return {ok:false,error:"Результат отправки в Telegram неизвестен: ответ не получен"}} + const rejected=typeof raw==="object"&&raw!==null&&"ok" in raw&&raw.ok===false&&"error_code" in raw&&typeof raw.error_code==="number"&&Number.isInteger(raw.error_code)&&raw.error_code>=400&&raw.error_code<600&&"description" in raw&&typeof raw.description==="string" + return {ok:false,outcome:rejected?"rejected":"unknown",error:redact(description)} + }catch{return {ok:false,outcome:"unknown",error:"Результат отправки в Telegram неизвестен: ответ не получен"}} } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 412432e7..bf1ed693 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.4.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3: outbox audit — неизвестный результат отправки/повторы/зависшие sending и согласованность журнала; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. +Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.2: миграция статусов outbox, atomic claim перед POST и unknown hold без автоматического resend; затем согласованный receipt/history и UI очереди, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bc8ca754..a4102b34 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.4.3.1 — классификация Telegram delivery (2026-10-03) + +- Telegram send helper отличает rejected (валидный negative Bot API receipt с ok:false/error_code/description) от unknown (network/timeout/malformed/HTTP failure без согласованного negative receipt). HTTP success+literal ok:true остаётся API acceptance; не доказательство доставки. Один bounded POST, без retry; секреты redacted. +- Engine delivery теперь разрешает token/chat/thread из одного stored snapshot вместо трёх независимых reads. Missing/invalid stored data и read failure отклоняются до POST; POST exception — unknown. Invalid thread не округляется. Legacy null/undefined thread fallback сохранён; cooldown/recovery/evaluation policy не менялась. +- 3 delivery DI + 4 send DI, общий Alerts backend/HTTP/JWT профиль, backend build/lint прошли. Реальные PG/Telegram не использовались. Outbox dispatcher пока старый: он ещё повторяет unknown и не делает atomic claim; очередью этот блок не считается принятой. +- Аудит SQL нашёл status CHECK (pending/sent/failed), несмотря на TypeScript text enum. Для processing/unknown нужна новая migration; не изменять исторические SQL. +- Следующий точный шаг 11Б.4.4.3.2: миграция статусов outbox, atomic claim перед POST и unknown hold без автоматического resend; затем согласованный receipt/history и UI очереди, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.4.4.2 — ревизия конфигурации правил (2026-10-03) - GET/strict receipt содержит обязательную SHA-256 revision правил/групп. Canonical hash сохраняет порядок targets/conditions и policy, сортирует строки по ID, исключает history/lastFired и Telegram. Это версия конфигурации в body, не ETag полного overview с динамической историей. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index ac29582e..1a612615 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -278,3 +278,8 @@ Read-only repeatable read/единый context без pool escape, strict snapsh ### 11Б.4.4.2 — revision/If-Match (2026-10-03) Canonical configuration hash, обязательный If-Match HTTP, locked stale check до write, 412/428 без optimistic overwrite; draft refresh сохраняет concurrent rules. Hash/stale DI, HTTP/JWT, API/browser/source, builds/typecheck/lint прошли; real PG конкуренция не доказана. Обновлять frontend/contracts/backend вместе; no push. Далее 11Б.4.4.3: outbox audit — неизвестный результат отправки/повторы/зависшие sending и согласованность журнала; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + + +### 11Б.4.4.3.1 — delivery outcome (2026-10-03) + +Bot API rejected/unknown, единый stored snapshot/strict thread, no secret/no retry в helper. 3 delivery + 4 send DI, Alerts profile/backend build/lint прошли; dispatcher ещё не мигрирован, PG/Telegram не проверены. Далее 11Б.4.4.3.2: миграция статусов outbox, atomic claim перед POST и unknown hold без автоматического resend; затем согласованный receipt/history и UI очереди, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.