@easyoref/agent 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/__tests__/clarify.test.ts +827 -0
  2. package/__tests__/config.test.ts +304 -0
  3. package/__tests__/enrichment.integration.test.ts +871 -0
  4. package/__tests__/graph.test.ts +661 -0
  5. package/dist/auth.d.ts +11 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +54 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/dry-run.d.ts +12 -0
  10. package/dist/dry-run.d.ts.map +1 -0
  11. package/dist/dry-run.js +236 -0
  12. package/dist/dry-run.js.map +1 -0
  13. package/dist/extract.d.ts +180 -0
  14. package/dist/extract.d.ts.map +1 -0
  15. package/dist/extract.js +210 -0
  16. package/dist/extract.js.map +1 -0
  17. package/dist/graph.d.ts +4083 -0
  18. package/dist/graph.d.ts.map +1 -0
  19. package/dist/graph.js +162 -0
  20. package/dist/graph.js.map +1 -0
  21. package/dist/index.d.ts +23 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +23 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/models.d.ts +7 -0
  26. package/dist/models.d.ts.map +1 -0
  27. package/dist/models.js +18 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/nodes/clarify-node.d.ts +132 -0
  30. package/dist/nodes/clarify-node.d.ts.map +1 -0
  31. package/dist/nodes/clarify-node.js +118 -0
  32. package/dist/nodes/clarify-node.js.map +1 -0
  33. package/dist/nodes/clarify.d.ts +6 -0
  34. package/dist/nodes/clarify.d.ts.map +1 -0
  35. package/dist/nodes/clarify.js +124 -0
  36. package/dist/nodes/clarify.js.map +1 -0
  37. package/dist/nodes/edit-node.d.ts +71 -0
  38. package/dist/nodes/edit-node.d.ts.map +1 -0
  39. package/dist/nodes/edit-node.js +496 -0
  40. package/dist/nodes/edit-node.js.map +1 -0
  41. package/dist/nodes/edit.d.ts +6 -0
  42. package/dist/nodes/edit.d.ts.map +1 -0
  43. package/dist/nodes/edit.js +22 -0
  44. package/dist/nodes/edit.js.map +1 -0
  45. package/dist/nodes/extract-node.d.ts +174 -0
  46. package/dist/nodes/extract-node.d.ts.map +1 -0
  47. package/dist/nodes/extract-node.js +233 -0
  48. package/dist/nodes/extract-node.js.map +1 -0
  49. package/dist/nodes/extract.d.ts +6 -0
  50. package/dist/nodes/extract.d.ts.map +1 -0
  51. package/dist/nodes/extract.js +49 -0
  52. package/dist/nodes/extract.js.map +1 -0
  53. package/dist/nodes/filter-agent.d.ts +11 -0
  54. package/dist/nodes/filter-agent.d.ts.map +1 -0
  55. package/dist/nodes/filter-agent.js +60 -0
  56. package/dist/nodes/filter-agent.js.map +1 -0
  57. package/dist/nodes/filter-node.d.ts +9 -0
  58. package/dist/nodes/filter-node.d.ts.map +1 -0
  59. package/dist/nodes/filter-node.js +111 -0
  60. package/dist/nodes/filter-node.js.map +1 -0
  61. package/dist/nodes/filters.d.ts +13 -0
  62. package/dist/nodes/filters.d.ts.map +1 -0
  63. package/dist/nodes/filters.js +111 -0
  64. package/dist/nodes/filters.js.map +1 -0
  65. package/dist/nodes/message-node.d.ts +71 -0
  66. package/dist/nodes/message-node.d.ts.map +1 -0
  67. package/dist/nodes/message-node.js +491 -0
  68. package/dist/nodes/message-node.js.map +1 -0
  69. package/dist/nodes/message.d.ts +71 -0
  70. package/dist/nodes/message.d.ts.map +1 -0
  71. package/dist/nodes/message.js +496 -0
  72. package/dist/nodes/message.js.map +1 -0
  73. package/dist/nodes/vote-node.d.ts +13 -0
  74. package/dist/nodes/vote-node.d.ts.map +1 -0
  75. package/dist/nodes/vote-node.js +232 -0
  76. package/dist/nodes/vote-node.js.map +1 -0
  77. package/dist/nodes/vote.d.ts +13 -0
  78. package/dist/nodes/vote.d.ts.map +1 -0
  79. package/dist/nodes/vote.js +232 -0
  80. package/dist/nodes/vote.js.map +1 -0
  81. package/dist/queue.d.ts +15 -0
  82. package/dist/queue.d.ts.map +1 -0
  83. package/dist/queue.js +41 -0
  84. package/dist/queue.js.map +1 -0
  85. package/dist/redis.d.ts +8 -0
  86. package/dist/redis.d.ts.map +1 -0
  87. package/dist/redis.js +33 -0
  88. package/dist/redis.js.map +1 -0
  89. package/dist/runtime/auth.d.ts +11 -0
  90. package/dist/runtime/auth.d.ts.map +1 -0
  91. package/dist/runtime/auth.js +54 -0
  92. package/dist/runtime/auth.js.map +1 -0
  93. package/dist/runtime/dry-run.d.ts +12 -0
  94. package/dist/runtime/dry-run.d.ts.map +1 -0
  95. package/dist/runtime/dry-run.js +236 -0
  96. package/dist/runtime/dry-run.js.map +1 -0
  97. package/dist/runtime/queue.d.ts +15 -0
  98. package/dist/runtime/queue.d.ts.map +1 -0
  99. package/dist/runtime/queue.js +41 -0
  100. package/dist/runtime/queue.js.map +1 -0
  101. package/dist/runtime/redis.d.ts +8 -0
  102. package/dist/runtime/redis.d.ts.map +1 -0
  103. package/dist/runtime/redis.js +33 -0
  104. package/dist/runtime/redis.js.map +1 -0
  105. package/dist/runtime/worker.d.ts +14 -0
  106. package/dist/runtime/worker.d.ts.map +1 -0
  107. package/dist/runtime/worker.js +135 -0
  108. package/dist/runtime/worker.js.map +1 -0
  109. package/dist/tools/alert-history.d.ts +18 -0
  110. package/dist/tools/alert-history.d.ts.map +1 -0
  111. package/dist/tools/alert-history.js +98 -0
  112. package/dist/tools/alert-history.js.map +1 -0
  113. package/dist/tools/betterstack-log.d.ts +15 -0
  114. package/dist/tools/betterstack-log.d.ts.map +1 -0
  115. package/dist/tools/betterstack-log.js +80 -0
  116. package/dist/tools/betterstack-log.js.map +1 -0
  117. package/dist/tools/index.d.ts +44 -0
  118. package/dist/tools/index.d.ts.map +1 -0
  119. package/dist/tools/index.js +20 -0
  120. package/dist/tools/index.js.map +1 -0
  121. package/dist/tools/read-sources.d.ts +15 -0
  122. package/dist/tools/read-sources.d.ts.map +1 -0
  123. package/dist/tools/read-sources.js +67 -0
  124. package/dist/tools/read-sources.js.map +1 -0
  125. package/dist/tools/resolve-area.d.ts +19 -0
  126. package/dist/tools/resolve-area.d.ts.map +1 -0
  127. package/dist/tools/resolve-area.js +147 -0
  128. package/dist/tools/resolve-area.js.map +1 -0
  129. package/dist/tools.d.ts +115 -0
  130. package/dist/tools.d.ts.map +1 -0
  131. package/dist/tools.js +439 -0
  132. package/dist/tools.js.map +1 -0
  133. package/dist/worker.d.ts +14 -0
  134. package/dist/worker.d.ts.map +1 -0
  135. package/dist/worker.js +135 -0
  136. package/dist/worker.js.map +1 -0
  137. package/package.json +26 -0
  138. package/src/graph.ts +200 -0
  139. package/src/index.ts +27 -0
  140. package/src/models.ts +20 -0
  141. package/src/nodes/clarify-node.ts +172 -0
  142. package/src/nodes/edit-node.ts +695 -0
  143. package/src/nodes/extract-node.ts +299 -0
  144. package/src/nodes/filter-node.ts +139 -0
  145. package/src/nodes/message.ts +695 -0
  146. package/src/nodes/vote-node.ts +354 -0
  147. package/src/nodes/vote.ts +354 -0
  148. package/src/runtime/auth.ts +63 -0
  149. package/src/runtime/dry-run.ts +303 -0
  150. package/src/runtime/queue.ts +53 -0
  151. package/src/runtime/redis.ts +38 -0
  152. package/src/runtime/worker.ts +167 -0
  153. package/src/tools/alert-history.ts +120 -0
  154. package/src/tools/betterstack-log.ts +102 -0
  155. package/src/tools/index.ts +23 -0
  156. package/src/tools/read-sources.ts +86 -0
  157. package/src/tools/resolve-area.ts +202 -0
  158. package/tsconfig.json +14 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Message building and Telegram editing.
3
+ *
4
+ * Builds enriched message text from voted consensus + carry-forward data.
5
+ * Uses inline [[1]](url) citations. No superscripts. No footer sources.
6
+ */
7
+
8
+ import * as logger from "@easyoref/monitoring";
9
+ import type {
10
+ AlertType,
11
+ CitedSource,
12
+ EnrichmentData,
13
+ InlineCite,
14
+ QualitativeCount,
15
+ VotedResult,
16
+ } from "@easyoref/shared";
17
+ import {
18
+ config,
19
+ createEmptyEnrichmentData,
20
+ EnrichmentDataSchema,
21
+ getActiveSession,
22
+ saveEnrichmentData,
23
+ setActiveSession,
24
+ textHash,
25
+ toIsraelTime,
26
+ } from "@easyoref/shared";
27
+ import { Bot } from "grammy";
28
+
29
+ // ── Country translations ───────────────────────────────
30
+
31
+ /** EN country name → Russian */
32
+ export const COUNTRY_RU: Record<string, string> = {
33
+ Iran: "Иран",
34
+ Yemen: "Йемен",
35
+ Lebanon: "Ливан",
36
+ Gaza: "Газа",
37
+ Iraq: "Ирак",
38
+ Syria: "Сирия",
39
+ Hezbollah: "Хезболла",
40
+ };
41
+
42
+ // ── Citation helpers ───────────────────────────────────
43
+
44
+ /** Format inline citations: [[1]](url), [[2]](url) */
45
+ export function inlineCites(
46
+ indices: number[],
47
+ citedSources: CitedSource[],
48
+ ): string {
49
+ const parts: string[] = [];
50
+ for (const idx of indices) {
51
+ const src = citedSources.find((s) => s.index === idx);
52
+ if (src?.messageUrl) {
53
+ parts.push(`<a href="${src.messageUrl}">[${idx}]</a>`);
54
+ }
55
+ }
56
+ return parts.length > 0 ? " " + parts.join(", ") : "";
57
+ }
58
+
59
+ /** Get InlineCite[] from citation indices */
60
+ export function extractCites(
61
+ indices: number[],
62
+ citedSources: CitedSource[],
63
+ ): InlineCite[] {
64
+ const cites: InlineCite[] = [];
65
+ for (const idx of indices) {
66
+ const src = citedSources.find((s) => s.index === idx);
67
+ if (src?.messageUrl) {
68
+ cites.push({ url: src.messageUrl, channel: src.channel });
69
+ }
70
+ }
71
+ return cites;
72
+ }
73
+
74
+ /** Format inline citations from InlineCite[] (carry-forward data) */
75
+ export function inlineCitesFromData(cites: InlineCite[]): string {
76
+ if (cites.length === 0) return "";
77
+ return (
78
+ " " + cites.map((c, i) => `<a href="${c.url}">[${i + 1}]</a>`).join(", ")
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Build a global URL→index map across ALL enrichment fields.
84
+ * Deduplicates by URL so the same source gets the same [N] everywhere.
85
+ */
86
+ export function buildGlobalCiteMap(
87
+ enrichment: EnrichmentData,
88
+ ): Map<string, number> {
89
+ // Order matches visual render order in buildEnrichedMessage:
90
+ // ETA (inline replacement first), then appended fields top-to-bottom.
91
+ const allCites: InlineCite[] = [
92
+ ...enrichment.etaCites,
93
+ ...enrichment.originCites,
94
+ ...enrichment.rocketCites,
95
+ ...enrichment.interceptedCites,
96
+ ...enrichment.hitsCites,
97
+ ...enrichment.noImpactsCites,
98
+ ...enrichment.casualtiesCites,
99
+ ...enrichment.injuriesCites,
100
+ ];
101
+ const map = new Map<string, number>();
102
+ let idx = 1;
103
+ for (const cite of allCites) {
104
+ if (!map.has(cite.url)) {
105
+ map.set(cite.url, idx++);
106
+ }
107
+ }
108
+ return map;
109
+ }
110
+
111
+ /** Format inline citations using global numbering */
112
+ export function renderCitesGlobal(
113
+ cites: InlineCite[],
114
+ globalMap: Map<string, number>,
115
+ ): string {
116
+ if (cites.length === 0) return "";
117
+ const parts: string[] = [];
118
+ for (const c of cites) {
119
+ const i = globalMap.get(c.url);
120
+ if (i) {
121
+ parts.push(`<a href="${c.url}">[${i}]</a>`);
122
+ }
123
+ }
124
+ return parts.length > 0 ? " " + parts.join(", ") : "";
125
+ }
126
+
127
+ // ── Confidence thresholds ──────────────────────────────
128
+
129
+ export const SKIP = 0.6;
130
+ export const UNCERTAIN = 0.75;
131
+ export const CERTAIN = 0.95;
132
+
133
+ // ── Monitoring indicator ───────────────────────────────
134
+
135
+ /** Strip custom-emoji monitoring line from message text */
136
+ export const MONITORING_RE =
137
+ /\n?<tg-emoji emoji-id="\d+">⏳<\/tg-emoji>\s*[^\n]+$/;
138
+
139
+ export function stripMonitoring(text: string): string {
140
+ return text.replace(MONITORING_RE, "");
141
+ }
142
+
143
+ export function appendMonitoring(text: string, label: string): string {
144
+ return text + "\n" + label;
145
+ }
146
+
147
+ // ── Display helpers ────────────────────────────────────
148
+
149
+ function qualDisplay(
150
+ qual: QualitativeCount | undefined,
151
+ conf: number,
152
+ ): string | undefined {
153
+ if (qual === undefined) return undefined;
154
+ if (qual.type === "none") return conf >= CERTAIN ? "нет" : undefined;
155
+ const map: Record<string, string> = {
156
+ all: "все",
157
+ most: "большинство",
158
+ many: "много",
159
+ few: "несколько",
160
+ exists: "есть",
161
+ none: "нет",
162
+ };
163
+ if (qual.type === "more_than") return `>${qual.value}`;
164
+ if (qual.type === "less_than") return `<${qual.value}`;
165
+ return map[qual.type];
166
+ }
167
+
168
+ // ── Build enrichment data from vote ────────────────────
169
+
170
+ /**
171
+ * Build enrichment data from current vote + previous enrichment (carry-forward).
172
+ * Returns updated EnrichmentData for Redis persistence.
173
+ */
174
+ export function buildEnrichmentFromVote(
175
+ result: VotedResult,
176
+ prev: EnrichmentData,
177
+ alertType: AlertType,
178
+ alertTs: number,
179
+ ): EnrichmentData {
180
+ const data: EnrichmentData = { ...prev };
181
+
182
+ // Origin
183
+ if (result.countryOrigins && result.countryOrigins.length > 0) {
184
+ data.origin = result.countryOrigins
185
+ .map((c: { name: string }) => COUNTRY_RU[c.name] ?? c.name)
186
+ .join(" + ");
187
+ data.originCites = result.countryOrigins.flatMap(
188
+ (c: { citations: number[] }) =>
189
+ extractCites(c.citations, result.citedSources),
190
+ );
191
+ }
192
+
193
+ // ETA — only for early_warning/red_alert
194
+ if (
195
+ result.etaRefinedMinutes &&
196
+ (alertType === "early_warning" || alertType === "red_alert")
197
+ ) {
198
+ const absTime = new Date(
199
+ alertTs + result.etaRefinedMinutes * 60_000,
200
+ ).toLocaleTimeString("he-IL", {
201
+ hour: "2-digit",
202
+ minute: "2-digit",
203
+ timeZone: "Asia/Jerusalem",
204
+ });
205
+ data.etaAbsolute = `~${absTime}`;
206
+ data.etaCites = extractCites(result.etaCitations, result.citedSources);
207
+ }
208
+
209
+ // Rocket count — show even at lower confidence (high-value intel)
210
+ // >= 0.55: show with (?); >= UNCERTAIN (0.75): show without marker
211
+ if (
212
+ result.rocketCountMin !== undefined &&
213
+ result.rocketCountMax !== undefined &&
214
+ result.rocketConfidence >= 0.55
215
+ ) {
216
+ const u = result.rocketConfidence < UNCERTAIN ? " (?)" : "";
217
+ data.rocketCount =
218
+ result.rocketCountMin === result.rocketCountMax
219
+ ? `${result.rocketCountMin}${u}`
220
+ : `~${result.rocketCountMin}–${result.rocketCountMax}${u}`;
221
+ data.rocketCites = extractCites(
222
+ result.rocketCitations,
223
+ result.citedSources,
224
+ );
225
+ }
226
+
227
+ // Cassette
228
+ if (result.isCassette !== undefined && result.isCassetteConfidence >= SKIP) {
229
+ data.isCassette = result.isCassette;
230
+ }
231
+
232
+ // Intercepted
233
+ if (
234
+ result.intercepted !== undefined &&
235
+ result.interceptedConfidence >= SKIP
236
+ ) {
237
+ const u = result.interceptedConfidence < UNCERTAIN ? " (?)" : "";
238
+ data.intercepted = `${result.intercepted}${u}`;
239
+ data.interceptedCites = extractCites(
240
+ result.interceptedCitations,
241
+ result.citedSources,
242
+ );
243
+ } else if (result.interceptedQual && result.interceptedConfidence >= SKIP) {
244
+ const qs = qualDisplay(
245
+ result.interceptedQual,
246
+ result.interceptedConfidence,
247
+ );
248
+ if (qs) data.intercepted = qs;
249
+ }
250
+
251
+ // Hits
252
+ if (
253
+ result.hitsConfirmed &&
254
+ result.hitsConfirmed > 0 &&
255
+ result.hitsConfidence >= SKIP
256
+ ) {
257
+ const u = result.hitsConfidence < UNCERTAIN ? " (?)" : "";
258
+ data.hitsConfirmed = `${result.hitsConfirmed}${u}`;
259
+ data.hitsCites = extractCites(result.hitsCitations, result.citedSources);
260
+ }
261
+
262
+ // No impacts: sources explicitly confirm zero hits
263
+ if (result.noImpacts && result.hitsConfidence >= SKIP) {
264
+ data.noImpacts = true;
265
+ data.noImpactsCites = extractCites(
266
+ result.noImpactsCitations,
267
+ result.citedSources,
268
+ );
269
+ }
270
+
271
+ // Rocket detail (per-region breakdown)
272
+ if (result.rocketDetail) {
273
+ data.rocketDetail = result.rocketDetail;
274
+ }
275
+
276
+ // Hit location & type
277
+ if (result.hitLocation && result.hitsConfirmed && result.hitsConfirmed > 0) {
278
+ data.hitLocation = result.hitLocation;
279
+ }
280
+ if (result.hitType && result.hitsConfirmed && result.hitsConfirmed > 0) {
281
+ data.hitType = result.hitType;
282
+ }
283
+ if (result.hitDetail && result.hitsConfirmed && result.hitsConfirmed > 0) {
284
+ data.hitDetail = result.hitDetail;
285
+ }
286
+
287
+ // Casualties — CRITICAL: only report at near-certain confidence
288
+ // Requires explicit mention of killed/dead in source (נהרג/מת/killed/dead/убит/погиб)
289
+ if (
290
+ result.casualties &&
291
+ result.casualties > 0 &&
292
+ result.casualtiesConfidence >= CERTAIN // 0.95 — never show unconfirmed deaths
293
+ ) {
294
+ // No uncertainty marker for deaths — either confirmed or not shown
295
+ data.casualties = `${result.casualties}`;
296
+ data.casualtiesCites = extractCites(
297
+ result.casualtiesCitations,
298
+ result.citedSources,
299
+ );
300
+ }
301
+
302
+ // Injuries — show only if confidence >= UNCERTAIN (not SKIP)
303
+ // Retractions: if new vote has injuries=0 and confidence >= UNCERTAIN, clear previous data
304
+ if (
305
+ result.injuries &&
306
+ result.injuries > 0 &&
307
+ result.injuriesConfidence >= UNCERTAIN
308
+ ) {
309
+ const u = result.injuriesConfidence < CERTAIN ? " (?)" : "";
310
+ const causeSuffix =
311
+ result.injuriesCause === "rushing_to_shelter"
312
+ ? " (по дороге в укрытие)"
313
+ : result.injuriesCause === "rocket"
314
+ ? " (от ракеты)"
315
+ : "";
316
+ data.injuries = `${result.injuries}${u}${causeSuffix}`;
317
+ data.injuriesCause = result.injuriesCause;
318
+ data.injuriesCites = extractCites(
319
+ result.injuriesCitations,
320
+ result.citedSources,
321
+ );
322
+ } else if (
323
+ result.injuries &&
324
+ result.injuries === 0 &&
325
+ result.injuriesConfidence >= UNCERTAIN &&
326
+ prev.injuries
327
+ ) {
328
+ // Explicit retraction: source says "no injured" — clear previous report
329
+ data.injuries = undefined;
330
+ data.injuriesCause = undefined;
331
+ data.injuriesCites = [];
332
+ }
333
+
334
+ // Early warning time
335
+ if (alertType === "early_warning" && !data.earlyWarningTime) {
336
+ data.earlyWarningTime = toIsraelTime(alertTs);
337
+ }
338
+
339
+ return data;
340
+ }
341
+
342
+ // ── Build enriched message text ────────────────────────
343
+
344
+ /**
345
+ * Build the enriched message text from current message + enrichment data.
346
+ * Uses inline [[1]](url) citations.
347
+ */
348
+ export function buildEnrichedMessage(
349
+ currentText: string,
350
+ alertType: AlertType,
351
+ alertTs: number,
352
+ enrichment: EnrichmentData,
353
+ monitoringLabel?: string,
354
+ ): string {
355
+ // Strip monitoring indicator before building — will re-add at the end
356
+ let text = stripMonitoring(currentText);
357
+
358
+ // ── Global citation map ──
359
+ const citeMap = buildGlobalCiteMap(enrichment);
360
+
361
+ // ── Refine ETA in-place ──
362
+ if (
363
+ enrichment.etaAbsolute &&
364
+ (alertType === "early_warning" || alertType === "red_alert")
365
+ ) {
366
+ const etaCiteStr = renderCitesGlobal(enrichment.etaCites, citeMap);
367
+ const refined = `${enrichment.etaAbsolute}${etaCiteStr}`;
368
+
369
+ const etaPatterns = [
370
+ /~\d+[–-]\d+\s*мин/,
371
+ /~\d+[–-]\d+\s*min/,
372
+ /~\d+[–-]\d+\s*דקות/,
373
+ /~\d+[–-]\d+\s*دقيقة/,
374
+ /1\.5\s*мин/,
375
+ /1\.5\s*min/,
376
+ /1\.5\s*דקות/,
377
+ /1\.5\s*دقيقة/,
378
+ ];
379
+
380
+ for (const pattern of etaPatterns) {
381
+ if (pattern.test(text)) {
382
+ text = text.replace(pattern, refined);
383
+ break;
384
+ }
385
+ }
386
+ }
387
+
388
+ // ── Origin ──
389
+ if (enrichment.origin) {
390
+ const citeStr = renderCitesGlobal(enrichment.originCites, citeMap);
391
+ text = insertBeforeBlockEnd(
392
+ text,
393
+ `<b>Откуда:</b> ${enrichment.origin}${citeStr}`,
394
+ );
395
+ }
396
+
397
+ // ── Rocket count (separate line, no compound breakdown) ──
398
+ if (enrichment.rocketCount) {
399
+ const citeStr = renderCitesGlobal(enrichment.rocketCites, citeMap);
400
+ const cassette = enrichment.isCassette ? ", кассетные" : "";
401
+ const detail = enrichment.rocketDetail
402
+ ? ` (${enrichment.rocketDetail})`
403
+ : "";
404
+ text = insertBeforeBlockEnd(
405
+ text,
406
+ `<b>Ракет:</b> ${enrichment.rocketCount}${detail}${cassette}${citeStr}`,
407
+ );
408
+ }
409
+
410
+ // ── Intercepted (own line) ──
411
+ if (enrichment.intercepted && alertType !== "early_warning") {
412
+ const citeStr = renderCitesGlobal(enrichment.interceptedCites, citeMap);
413
+ text = insertBeforeBlockEnd(
414
+ text,
415
+ `<b>Перехваты:</b> ${enrichment.intercepted}${citeStr}`,
416
+ );
417
+ }
418
+
419
+ // ── Hits / No impacts (own line) ──
420
+ if (enrichment.hitsConfirmed && alertType !== "early_warning") {
421
+ const citeStr = renderCitesGlobal(enrichment.hitsCites, citeMap);
422
+ const HIT_TYPE_DISPLAY: Record<string, string> = {
423
+ direct: "прямое",
424
+ shrapnel: "обломки",
425
+ };
426
+ const qualifiers: string[] = [];
427
+ if (enrichment.hitLocation) qualifiers.push(enrichment.hitLocation);
428
+ if (enrichment.hitDetail) qualifiers.push(enrichment.hitDetail);
429
+ if (enrichment.hitType)
430
+ qualifiers.push(
431
+ HIT_TYPE_DISPLAY[enrichment.hitType] ?? enrichment.hitType,
432
+ );
433
+ const suffix = qualifiers.length > 0 ? ` (${qualifiers.join(", ")})` : "";
434
+ text = insertBeforeBlockEnd(
435
+ text,
436
+ `<b>Попадания:</b> ${enrichment.hitsConfirmed}${suffix}${citeStr}`,
437
+ );
438
+ } else if (enrichment.noImpacts && alertType !== "early_warning") {
439
+ const citeStr = renderCitesGlobal(enrichment.noImpactsCites, citeMap);
440
+ text = insertBeforeBlockEnd(text, `<b>Прилетов:</b> нет${citeStr}`);
441
+ }
442
+
443
+ // ── Casualties / Injuries (resolved only) ──
444
+ if (enrichment.casualties && alertType === "resolved") {
445
+ const citeStr = renderCitesGlobal(enrichment.casualtiesCites, citeMap);
446
+ text = insertBeforeBlockEnd(
447
+ text,
448
+ `<b>Погибшие:</b> ${enrichment.casualties}${citeStr}`,
449
+ );
450
+ }
451
+ if (enrichment.injuries && alertType === "resolved") {
452
+ const citeStr = renderCitesGlobal(enrichment.injuriesCites, citeMap);
453
+ const causeLabel =
454
+ enrichment.injuriesCause === "rushing_to_shelter"
455
+ ? " (на пути в укрытие)"
456
+ : enrichment.injuriesCause === "rocket"
457
+ ? " (от ракеты)"
458
+ : "";
459
+ text = insertBeforeBlockEnd(
460
+ text,
461
+ `<b>Пострадавшие:</b> ${enrichment.injuries}${causeLabel}${citeStr}`,
462
+ );
463
+ }
464
+
465
+ // Re-add monitoring indicator if still in active phase
466
+ if (monitoringLabel && alertType !== "resolved") {
467
+ text = appendMonitoring(text, monitoringLabel);
468
+ }
469
+
470
+ return text;
471
+ }
472
+
473
+ /**
474
+ * Insert a line before </blockquote> (or legacy time line as fallback).
475
+ */
476
+ export function insertBeforeBlockEnd(text: string, line: string): string {
477
+ const bqIdx = text.lastIndexOf("</blockquote>");
478
+ if (bqIdx !== -1) {
479
+ return text.slice(0, bqIdx) + line + "\n" + text.slice(bqIdx);
480
+ }
481
+ // Legacy fallback: insert before time line
482
+ const timePattern =
483
+ /(<b>(?:Время оповещения|Alert time|שעת ההתרעה|وقت الإنذار):<\/b>)/;
484
+ const match = text.match(timePattern);
485
+ if (match?.index) {
486
+ return text.slice(0, match.index) + line + "\n" + text.slice(match.index);
487
+ }
488
+ const lines = text.split("\n");
489
+ lines.splice(Math.max(lines.length - 1, 0), 0, line);
490
+ return lines.join("\n");
491
+ }
492
+
493
+ /** @deprecated Use insertBeforeBlockEnd */
494
+ export const insertBeforeTimeLine = insertBeforeBlockEnd;
495
+
496
+ // ── Edit message ───────────────────────────────────────
497
+
498
+ export interface TelegramTargetMessage {
499
+ chatId: string;
500
+ messageId: number;
501
+ isCaption: boolean;
502
+ }
503
+
504
+ export interface EditMessageInput {
505
+ alertId: string;
506
+ alertTs: number;
507
+ alertType: AlertType;
508
+ chatId: string;
509
+ messageId: number;
510
+ isCaption: boolean;
511
+ telegramMessages?: TelegramTargetMessage[];
512
+ currentText: string;
513
+ votedResult: VotedResult | undefined;
514
+ previousEnrichment: EnrichmentData;
515
+ monitoringLabel?: string;
516
+ }
517
+
518
+ /**
519
+ * Edit the Telegram message with enriched data.
520
+ */
521
+ export const editTelegramMessage = async (
522
+ input: EditMessageInput,
523
+ ): Promise<void> => {
524
+ if (!config.botToken) return;
525
+
526
+ const tgBot = new Bot(config.botToken);
527
+ const prev = input.previousEnrichment ?? createEmptyEnrichmentData();
528
+
529
+ // Resolve targets: multi-chat or single fallback
530
+ const targets: TelegramTargetMessage[] = input.telegramMessages ?? [
531
+ {
532
+ chatId: input.chatId,
533
+ messageId: input.messageId,
534
+ isCaption: input.isCaption,
535
+ },
536
+ ];
537
+
538
+ if (!input.votedResult) {
539
+ // No new data — try carry-forward only
540
+ if (prev.origin || prev.intercepted) {
541
+ const newText = buildEnrichedMessage(
542
+ input.currentText,
543
+ input.alertType,
544
+ input.alertTs,
545
+ prev,
546
+ input.monitoringLabel,
547
+ );
548
+
549
+ const hash = textHash(newText);
550
+ if (hash === prev.lastEditHash) {
551
+ logger.info("Agent: no change (dedup)", { alertId: input.alertId });
552
+ return;
553
+ }
554
+
555
+ for (const t of targets) {
556
+ try {
557
+ if (t.isCaption) {
558
+ await tgBot.api.editMessageCaption(t.chatId, t.messageId, {
559
+ caption: newText,
560
+ parse_mode: "HTML",
561
+ });
562
+ } else {
563
+ await tgBot.api.editMessageText(t.chatId, t.messageId, newText, {
564
+ parse_mode: "HTML",
565
+ });
566
+ }
567
+ } catch (err) {
568
+ const errStr = String(err);
569
+ if (!errStr.includes("message is not modified")) {
570
+ logger.error("Agent: edit failed", {
571
+ alertId: input.alertId,
572
+ chatId: t.chatId,
573
+ error: errStr,
574
+ });
575
+ }
576
+ }
577
+ }
578
+
579
+ prev.lastEditHash = hash;
580
+ await saveEnrichmentData(prev);
581
+
582
+ // Keep session.currentText in sync for monitoring removal
583
+ const sess = await getActiveSession();
584
+ if (sess) {
585
+ sess.currentText = newText;
586
+ await setActiveSession(sess);
587
+ }
588
+
589
+ logger.info("Agent: enriched (carry-forward)", {
590
+ alertId: input.alertId,
591
+ });
592
+ } else {
593
+ logger.info("Agent: no voted result — skipping", {
594
+ alertId: input.alertId,
595
+ });
596
+ }
597
+ return;
598
+ }
599
+
600
+ // Build enrichment data: merge vote + previous
601
+ const enrichment = buildEnrichmentFromVote(
602
+ input.votedResult,
603
+ prev,
604
+ input.alertType,
605
+ input.alertTs,
606
+ );
607
+
608
+ const newText = buildEnrichedMessage(
609
+ input.currentText,
610
+ input.alertType,
611
+ input.alertTs,
612
+ enrichment,
613
+ input.monitoringLabel,
614
+ );
615
+
616
+ // Dedup: skip if text hasn't changed
617
+ const hash = textHash(newText);
618
+ if (hash === enrichment.lastEditHash) {
619
+ logger.info("Agent: no change (dedup)", { alertId: input.alertId });
620
+ return;
621
+ }
622
+
623
+ if (input.votedResult.confidence < config.agent.confidenceThreshold) {
624
+ logger.info("Agent: low confidence — editing with (?) markers", {
625
+ alertId: input.alertId,
626
+ confidence: input.votedResult.confidence,
627
+ });
628
+ }
629
+
630
+ for (const t of targets) {
631
+ try {
632
+ if (t.isCaption) {
633
+ await tgBot.api.editMessageCaption(t.chatId, t.messageId, {
634
+ caption: newText,
635
+ parse_mode: "HTML",
636
+ });
637
+ } else {
638
+ await tgBot.api.editMessageText(t.chatId, t.messageId, newText, {
639
+ parse_mode: "HTML",
640
+ });
641
+ }
642
+ } catch (err) {
643
+ const errStr = String(err);
644
+ if (!errStr.includes("message is not modified")) {
645
+ logger.error("Agent: edit failed", {
646
+ alertId: input.alertId,
647
+ chatId: t.chatId,
648
+ error: errStr,
649
+ });
650
+ }
651
+ }
652
+ }
653
+
654
+ enrichment.lastEditHash = hash;
655
+ await saveEnrichmentData(enrichment);
656
+
657
+ // Keep session.currentText in sync for monitoring removal
658
+ const sess = await getActiveSession();
659
+ if (sess) {
660
+ sess.currentText = newText;
661
+ await setActiveSession(sess);
662
+ }
663
+
664
+ logger.info("Agent: enriched", {
665
+ alertId: input.alertId,
666
+ targets: targets.length,
667
+ confidence: input.votedResult.confidence,
668
+ sources: input.votedResult.sourcesCount,
669
+ phase: input.alertType,
670
+ });
671
+ };
672
+
673
+ export const editMessage = editTelegramMessage;
674
+
675
+ import type { AgentStateType } from "../graph.js";
676
+
677
+ export const editNode = async (
678
+ state: AgentStateType,
679
+ ): Promise<Partial<AgentStateType>> => {
680
+ await editTelegramMessage({
681
+ alertId: state.alertId,
682
+ alertTs: state.alertTs,
683
+ alertType: state.alertType,
684
+ chatId: state.chatId,
685
+ messageId: state.messageId,
686
+ isCaption: state.isCaption,
687
+ telegramMessages: state.telegramMessages,
688
+ currentText: state.currentText,
689
+ votedResult: state.votedResult,
690
+ previousEnrichment:
691
+ state.previousEnrichment ?? EnrichmentDataSchema.parse({}),
692
+ monitoringLabel: state.monitoringLabel,
693
+ });
694
+ return {};
695
+ };