fix(alerts): различать отказ и неизвестный результат Telegram

This commit is contained in:
Denozordec committed 2026-10-03 18:48:22 +07:00
1 parent 382e318b97
commit 46be201d10
9 files changed
+63 -33

No files matched your search

+1 -1
View File
@@ -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",
+3 -26
View File
@@ -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: {
@@ -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)
})
@@ -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<unknown>;send:typeof sendTelegramRequest}):Promise<TelegramSendResult>{
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 до отправки"}}
}
@@ -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")}
})
+5 -3
View File
@@ -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<TelegramSendResult>{
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 неизвестен: ответ не получен"}}
}
+1 -1
View File
@@ -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).
+9 -1
View File
@@ -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 с динамической историей.
+5
View File
@@ -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.