fix(alerts): сохранять параметры выборочно и проверять результат отправки

This commit is contained in:
Denozordec committed 2026-10-03 17:06:44 +07:00
1 parent 0bcac655ea
commit 98319c9d50
11 files changed
+90 -58

No files matched your search

+15 -51
View File
@@ -1,3 +1,5 @@
import {sendTelegramRequest} from "./alerts-telegram-send.js"
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
import { asc, desc, eq, sql } from "drizzle-orm"
import { db, dbAll } from "../db/index.js"
import {
@@ -58,7 +60,7 @@ export type ApiAlertHistoryEntry = {
async function ensureTelegramRow() {
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
if (!row) {
await db.insert(alertTelegramSettings).values({ id: 1 })
await db.insert(alertTelegramSettings).values({ id: 1 }).onConflictDoNothing({target:alertTelegramSettings.id})
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
}
return row!
@@ -309,37 +311,13 @@ export async function updateTelegramSettings(patch: {
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = await ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
if (patch.token !== undefined) {
if (patch.token === null || patch.token === "") nextToken = ""
else nextToken = patch.token.trim()
}
if (patch.chatId !== undefined) nextChat = patch.chatId.trim()
if (patch.messageThreadId !== undefined) {
if (patch.messageThreadId === null) nextThread = null
else {
const n = Math.floor(patch.messageThreadId)
nextThread = Number.isFinite(n) && n >= 1 ? n : null
}
}
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`now()`,
}
if (nextThread !== undefined) {
await db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
} else {
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
}
return await getTelegramPublic()
return executeTelegramSettingsUpdate(patch, {
ensure: ensureTelegramRow,
update: async values => {
const [row] = await db.update(alertTelegramSettings).set({...values, updatedAt:sql`now()`}).where(eq(alertTelegramSettings.id,1)).returning()
return row
},
})
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
@@ -395,26 +373,12 @@ export async function sendTelegramAlertMessage(opts: {
? Math.floor(opts.messageThreadId)
: undefined
: await getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
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
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!res.ok || data.ok === false) {
return { ok: false, error: data.description ?? res.statusText ?? "Telegram API error" }
}
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Ошибка сети" }
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: {
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {sendTelegramRequest} from "./alerts-telegram-send.js"
test("Telegram send requires HTTP success and literal true receipt; malformed body is never success",async()=>{
for(const body of [{},{ok:false},{ok:"true"},null]){let calls=0;const result=await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>{calls++;return Response.json(body)});assert.equal(result.ok,false);assert.equal(calls,1)}
assert.equal((await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>new Response("html"))).ok,false)
assert.equal((await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>Response.json({ok:true},{status:503}))).ok,false)
})
test("Telegram send uses bounded single POST and preserves confirmed result",async()=>{
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)
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]")
})
@@ -0,0 +1,11 @@
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}>{
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 неизвестен: ответ не получен"}}
}
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeTelegramSettingsUpdate,type TelegramStoredPatch} from "./alerts-telegram-settings.js"
function fixture(){let row={botToken:"fixture-secret",chatId:"old",messageThreadId:10 as number|null};const writes:TelegramStoredPatch[]=[];return {writes,get:()=>row,set:(next:typeof row)=>row=next,deps:{ensure:async()=>{},update:async(patch:TelegramStoredPatch)=>{writes.push(patch);row={...row,...patch};return {...row}}}}
}
test("Telegram selective update changes only submitted fields and does not expose secret",async()=>{const f=fixture();const publicFields=await executeTelegramSettingsUpdate({chatId:" new "},f.deps);assert.deepEqual(f.writes,[{chatId:"new"}]);assert.deepEqual(f.get(),{botToken:"fixture-secret",chatId:"new",messageThreadId:10});assert.deepEqual(publicFields,{chatId:"new",tokenConfigured:true,messageThreadId:10});assert.equal(JSON.stringify(publicFields).includes("fixture-secret"),false)})
test("Telegram disjoint concurrent patches preserve token/chat/thread without stale full-row writes",async()=>{const f=fixture();await Promise.all([executeTelegramSettingsUpdate({token:null},f.deps),executeTelegramSettingsUpdate({messageThreadId:null},f.deps),executeTelegramSettingsUpdate({chatId:"new"},f.deps)]);assert.deepEqual(f.get(),{botToken:"",chatId:"new",messageThreadId:null});assert.equal(f.writes.length,3);assert.ok(f.writes.every(row=>Object.keys(row).length===1))})
test("Telegram invalid/empty patch does not ensure or update",async()=>{let calls=0;const deps={ensure:async()=>{calls++},update:async()=>{calls++;return undefined}};for(const patch of [{},{messageThreadId:0},{messageThreadId:1.5},{messageThreadId:2147483648},{token:4}])await assert.rejects(executeTelegramSettingsUpdate(patch as never,deps),/Проверьте/);assert.equal(calls,0)})
test("Telegram missing/mismatched returning row does not confirm save or retry",async()=>{for(const row of [undefined,{botToken:"secret",chatId:"old",messageThreadId:null}]){let calls=0;await assert.rejects(executeTelegramSettingsUpdate({chatId:"new"},{ensure:async()=>{},update:async()=>{calls++;return row}}),/не подтверждено/);assert.equal(calls,1)}})
@@ -0,0 +1,20 @@
import {putTelegramSchema,telegramPublicSchema,type PutTelegramBody} from "@mmapp/contracts/alerts"
export type TelegramStoredRow={botToken:string;chatId:string;messageThreadId:number|null}
export type TelegramStoredPatch=Partial<TelegramStoredRow>
interface Dependencies {ensure:()=>Promise<unknown>;update:(patch:TelegramStoredPatch)=>Promise<TelegramStoredRow|undefined>}
export async function executeTelegramSettingsUpdate(input:PutTelegramBody,deps:Dependencies){
const parsed=putTelegramSchema.strict().safeParse(input)
if(!parsed.success||Object.keys(parsed.data).length===0)throw Error("Проверьте изменённые параметры Telegram")
const patch:TelegramStoredPatch={}
if(parsed.data.token!==undefined)patch.botToken=parsed.data.token?.trim()??""
if(parsed.data.chatId!==undefined)patch.chatId=parsed.data.chatId.trim()
if(parsed.data.messageThreadId!==undefined)patch.messageThreadId=parsed.data.messageThreadId
if(Object.keys(patch).length===0)throw Error("Нет изменённых параметров Telegram")
await deps.ensure()
const row=await deps.update(patch)
if(!row||Object.entries(patch).some(([key,value])=>row[key as keyof TelegramStoredRow]!==value))throw Error("Сохранение параметров Telegram не подтверждено")
const result=telegramPublicSchema.safeParse({chatId:row.chatId,tokenConfigured:Boolean(row.botToken.trim()),messageThreadId:row.messageThreadId,connected:Boolean(row.botToken.trim()&&row.chatId.trim())})
if(!result.success)throw Error("Сохранённые параметры Telegram не подтверждены")
const {connected:_connected,...publicFields}=result.data
return publicFields
}