import type { ApiAlertGroup, ApiAlertRule } from "../alerts-service.js" import { computeStabilityReadyMap } from "./confirm-stability.js" import { isCooldownElapsed } from "./cooldown.js" import { groupShouldFire } from "./groups.js" import { isPositiveRecoveryTelegramText } from "./telegram-emoji.js" import { shouldAllowRecoveryByPolicy } from "./state-machine.js" import type { AlertEngineRuleDiag, RuleEvalHit } from "./types.js" export type EngineStateMap = Map export type StandaloneDecision = { kind: "rule" rule: ApiAlertRule hit: RuleEvalHit } export type GroupDecision = { kind: "group" group: ApiAlertGroup members: ApiAlertRule[] hits: RuleEvalHit[] } function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string { const suffix = transition ?? "neutral" return `rule:${ruleId}:${suffix}` } function textAfterGroupAlertHeader(fullBody: string): string { const segs = fullBody.split(/\n\n/) if (segs.length >= 2 && /^Группа\s*«/i.test(segs[0] ?? "")) return segs.slice(1).join("\n\n").trim() return fullBody } export function buildRuleDiagnostics( rules: ApiAlertRule[], hitByRule: Map, stabilityReady: Map, state: EngineStateMap, canSend: boolean, ): AlertEngineRuleDiag[] { const out: AlertEngineRuleDiag[] = [] for (const rule of rules) { if (!rule.enabled) continue const hit = hitByRule.get(rule.id) ?? null const inGroup = Boolean(rule.groupId) const evalHit = Boolean(hit) const stab = stabilityReady.get(rule.id) ?? false const key = ruleScopeKey(rule.id, hit?.transition) const prev = state.get(key) const cooldownOk = isCooldownElapsed(prev?.lastFiredAt, rule.cooldown) let blocked: AlertEngineRuleDiag["blocked"] if (!evalHit) blocked = "no_hit" else if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) blocked = "stability" else if (inGroup) blocked = "in_group" else if (!stab) blocked = "stability" else if (!cooldownOk) blocked = "cooldown" else if (!canSend) blocked = "no_telegram" else if (hit && isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) blocked = "dedupe_positive" else blocked = undefined out.push({ ruleId: rule.id, inGroup, evalHit, hitTransition: hit?.transition, hitMessage: hit?.message, stabilityOk: stab, cooldownOk, telegramOk: canSend, blocked, }) } return out } export async function computeDecisions(args: { rules: ApiAlertRule[] groups: ApiAlertGroup[] hitByRule: Map state: EngineStateMap canSend: boolean }): Promise<{ stabilityReady: Map standalone: StandaloneDecision[] grouped: GroupDecision[] ruleDiag: AlertEngineRuleDiag[] }> { const { rules, groups, hitByRule, state, canSend } = args const stabilityReady = await computeStabilityReadyMap(rules, hitByRule) const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend) const standalone: StandaloneDecision[] = [] const grouped: GroupDecision[] = [] for (const rule of rules) { if (!rule.enabled || rule.groupId) continue const hit = hitByRule.get(rule.id) if (!hit) continue if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) continue if (!stabilityReady.get(rule.id)) continue const key = ruleScopeKey(rule.id, hit.transition) const prev = state.get(key) if (!isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)) continue if (isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) continue standalone.push({ kind: "rule", rule, hit }) } for (const g of groups) { if (!g.enabled) continue const members = rules.filter((r) => r.groupId === g.id && r.enabled) if (members.length === 0) continue const firing = members.map((r) => { const h = hitByRule.get(r.id) if (!shouldAllowRecoveryByPolicy(h ?? null, r.recoveryMode)) return false return Boolean(h) && Boolean(stabilityReady.get(r.id)) }) if (!groupShouldFire(g.combineMode, firing)) continue const hits: RuleEvalHit[] = [] for (const member of members) { const hit = hitByRule.get(member.id) if (!hit) continue if (!shouldAllowRecoveryByPolicy(hit, member.recoveryMode)) continue hits.push(hit) } if (hits.length === 0) continue const key = `group:${g.id}` const prev = state.get(key) const cd = g.cooldownOverride ?? "5м" if (!isCooldownElapsed(prev?.lastFiredAt, cd)) continue const body = `Группа «${g.name}» (${g.combineMode === "any" ? "ANY" : "ALL"})\n\n${hits.map((h) => h.message).join("\n")}` const hash = hits.map((h) => h.payloadHash).sort().join("|") if (isPositiveRecoveryTelegramText(textAfterGroupAlertHeader(body)) && prev?.lastPayloadHash === hash) continue grouped.push({ kind: "group", group: g, members, hits }) } return { stabilityReady, standalone, grouped, ruleDiag } }