fix(alerts): сохранять параметры выборочно и проверять результат отправки
This commit is contained in:
1 parent
0bcac655ea
commit
98319c9d50
11 files changed
+90
-58
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user