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": {
|
||||
"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",
|
||||
|
||||
@@ -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")}
|
||||
})
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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 с динамической историей.
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user