fix(alerts): различать отказ и неизвестный результат Telegram
This commit is contained in:
1 parent
382e318b97
commit
46be201d10
9 files changed
+63
-33
No files matched your search
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "npm run build -w @mmapp/contracts && tsc",
|
"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",
|
"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",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {deliverTelegramAlert,type TelegramAlertInput} from "./alerts-telegram-delivery.js"
|
||||||
import {alertsConfigRevision} from "./alerts-revision.js"
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js"
|
import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js"
|
||||||
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
||||||
@@ -358,32 +359,8 @@ export function formatAlertRuleTestTelegramText(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Отправка текста в Telegram (движок алертов и тест). */
|
/** Отправка текста в Telegram (движок алертов и тест). */
|
||||||
export async function sendTelegramAlertMessage(opts: {
|
export async function sendTelegramAlertMessage(opts:TelegramAlertInput){
|
||||||
text: string
|
return deliverTelegramAlert(opts,{readStored:getTelegramSettingsRow,send:sendTelegramRequest})
|
||||||
/** Если задан — вместо токена из БД (ручной тест) */
|
|
||||||
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 appendAlertHistory(entry: {
|
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)
|
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()=>{
|
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]")
|
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")}
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
type Payload={chat_id:string;text:string;message_thread_id?:number}
|
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]")
|
const redact=(message:string)=>message.replaceAll(token,"[TOKEN]").replaceAll(encodeURIComponent(token),"[TOKEN]")
|
||||||
try{
|
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 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)
|
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}
|
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 не подтвердил приём сообщения"
|
const description=typeof raw==="object"&&raw!==null&&"description" in raw&&typeof raw.description==="string"?raw.description:"Telegram не подтвердил приём сообщения"
|
||||||
return {ok:false,error:redact(description)}
|
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"
|
||||||
}catch{return {ok:false,error:"Результат отправки в Telegram неизвестен: ответ не получен"}}
|
return {ok:false,outcome:rejected?"rejected":"unknown",error:redact(description)}
|
||||||
|
}catch{return {ok:false,outcome:"unknown",error:"Результат отправки в Telegram неизвестен: ответ не получен"}}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 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).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 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 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 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 с динамической историей.
|
- GET/strict receipt содержит обязательную SHA-256 revision правил/групп. Canonical hash сохраняет порядок targets/conditions и policy, сортирует строки по ID, исключает history/lastFired и Telegram. Это версия конфигурации в body, не ETag полного overview с динамической историей.
|
||||||
|
|||||||
@@ -278,3 +278,8 @@ Read-only repeatable read/единый context без pool escape, strict snapsh
|
|||||||
### 11Б.4.4.2 — revision/If-Match (2026-10-03)
|
### 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.
|
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.
|
||||||
Reference in new issue
Block a user