@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.
- package/__tests__/clarify.test.ts +827 -0
- package/__tests__/config.test.ts +304 -0
- package/__tests__/enrichment.integration.test.ts +871 -0
- package/__tests__/graph.test.ts +661 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +54 -0
- package/dist/auth.js.map +1 -0
- package/dist/dry-run.d.ts +12 -0
- package/dist/dry-run.d.ts.map +1 -0
- package/dist/dry-run.js +236 -0
- package/dist/dry-run.js.map +1 -0
- package/dist/extract.d.ts +180 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +210 -0
- package/dist/extract.js.map +1 -0
- package/dist/graph.d.ts +4083 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +162 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +7 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +18 -0
- package/dist/models.js.map +1 -0
- package/dist/nodes/clarify-node.d.ts +132 -0
- package/dist/nodes/clarify-node.d.ts.map +1 -0
- package/dist/nodes/clarify-node.js +118 -0
- package/dist/nodes/clarify-node.js.map +1 -0
- package/dist/nodes/clarify.d.ts +6 -0
- package/dist/nodes/clarify.d.ts.map +1 -0
- package/dist/nodes/clarify.js +124 -0
- package/dist/nodes/clarify.js.map +1 -0
- package/dist/nodes/edit-node.d.ts +71 -0
- package/dist/nodes/edit-node.d.ts.map +1 -0
- package/dist/nodes/edit-node.js +496 -0
- package/dist/nodes/edit-node.js.map +1 -0
- package/dist/nodes/edit.d.ts +6 -0
- package/dist/nodes/edit.d.ts.map +1 -0
- package/dist/nodes/edit.js +22 -0
- package/dist/nodes/edit.js.map +1 -0
- package/dist/nodes/extract-node.d.ts +174 -0
- package/dist/nodes/extract-node.d.ts.map +1 -0
- package/dist/nodes/extract-node.js +233 -0
- package/dist/nodes/extract-node.js.map +1 -0
- package/dist/nodes/extract.d.ts +6 -0
- package/dist/nodes/extract.d.ts.map +1 -0
- package/dist/nodes/extract.js +49 -0
- package/dist/nodes/extract.js.map +1 -0
- package/dist/nodes/filter-agent.d.ts +11 -0
- package/dist/nodes/filter-agent.d.ts.map +1 -0
- package/dist/nodes/filter-agent.js +60 -0
- package/dist/nodes/filter-agent.js.map +1 -0
- package/dist/nodes/filter-node.d.ts +9 -0
- package/dist/nodes/filter-node.d.ts.map +1 -0
- package/dist/nodes/filter-node.js +111 -0
- package/dist/nodes/filter-node.js.map +1 -0
- package/dist/nodes/filters.d.ts +13 -0
- package/dist/nodes/filters.d.ts.map +1 -0
- package/dist/nodes/filters.js +111 -0
- package/dist/nodes/filters.js.map +1 -0
- package/dist/nodes/message-node.d.ts +71 -0
- package/dist/nodes/message-node.d.ts.map +1 -0
- package/dist/nodes/message-node.js +491 -0
- package/dist/nodes/message-node.js.map +1 -0
- package/dist/nodes/message.d.ts +71 -0
- package/dist/nodes/message.d.ts.map +1 -0
- package/dist/nodes/message.js +496 -0
- package/dist/nodes/message.js.map +1 -0
- package/dist/nodes/vote-node.d.ts +13 -0
- package/dist/nodes/vote-node.d.ts.map +1 -0
- package/dist/nodes/vote-node.js +232 -0
- package/dist/nodes/vote-node.js.map +1 -0
- package/dist/nodes/vote.d.ts +13 -0
- package/dist/nodes/vote.d.ts.map +1 -0
- package/dist/nodes/vote.js +232 -0
- package/dist/nodes/vote.js.map +1 -0
- package/dist/queue.d.ts +15 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +41 -0
- package/dist/queue.js.map +1 -0
- package/dist/redis.d.ts +8 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +33 -0
- package/dist/redis.js.map +1 -0
- package/dist/runtime/auth.d.ts +11 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +54 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/dry-run.d.ts +12 -0
- package/dist/runtime/dry-run.d.ts.map +1 -0
- package/dist/runtime/dry-run.js +236 -0
- package/dist/runtime/dry-run.js.map +1 -0
- package/dist/runtime/queue.d.ts +15 -0
- package/dist/runtime/queue.d.ts.map +1 -0
- package/dist/runtime/queue.js +41 -0
- package/dist/runtime/queue.js.map +1 -0
- package/dist/runtime/redis.d.ts +8 -0
- package/dist/runtime/redis.d.ts.map +1 -0
- package/dist/runtime/redis.js +33 -0
- package/dist/runtime/redis.js.map +1 -0
- package/dist/runtime/worker.d.ts +14 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +135 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/tools/alert-history.d.ts +18 -0
- package/dist/tools/alert-history.d.ts.map +1 -0
- package/dist/tools/alert-history.js +98 -0
- package/dist/tools/alert-history.js.map +1 -0
- package/dist/tools/betterstack-log.d.ts +15 -0
- package/dist/tools/betterstack-log.d.ts.map +1 -0
- package/dist/tools/betterstack-log.js +80 -0
- package/dist/tools/betterstack-log.js.map +1 -0
- package/dist/tools/index.d.ts +44 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +20 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read-sources.d.ts +15 -0
- package/dist/tools/read-sources.d.ts.map +1 -0
- package/dist/tools/read-sources.js +67 -0
- package/dist/tools/read-sources.js.map +1 -0
- package/dist/tools/resolve-area.d.ts +19 -0
- package/dist/tools/resolve-area.d.ts.map +1 -0
- package/dist/tools/resolve-area.js +147 -0
- package/dist/tools/resolve-area.js.map +1 -0
- package/dist/tools.d.ts +115 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +439 -0
- package/dist/tools.js.map +1 -0
- package/dist/worker.d.ts +14 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +135 -0
- package/dist/worker.js.map +1 -0
- package/package.json +26 -0
- package/src/graph.ts +200 -0
- package/src/index.ts +27 -0
- package/src/models.ts +20 -0
- package/src/nodes/clarify-node.ts +172 -0
- package/src/nodes/edit-node.ts +695 -0
- package/src/nodes/extract-node.ts +299 -0
- package/src/nodes/filter-node.ts +139 -0
- package/src/nodes/message.ts +695 -0
- package/src/nodes/vote-node.ts +354 -0
- package/src/nodes/vote.ts +354 -0
- package/src/runtime/auth.ts +63 -0
- package/src/runtime/dry-run.ts +303 -0
- package/src/runtime/queue.ts +53 -0
- package/src/runtime/redis.ts +38 -0
- package/src/runtime/worker.ts +167 -0
- package/src/tools/alert-history.ts +120 -0
- package/src/tools/betterstack-log.ts +102 -0
- package/src/tools/index.ts +23 -0
- package/src/tools/read-sources.ts +86 -0
- package/src/tools/resolve-area.ts +202 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* GramJS QR Auth — run once to generate session_string.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx tsx packages/bot/src/agent/auth.ts
|
|
7
|
+
*
|
|
8
|
+
* Scan QR with burner phone → session_string printed → done.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import qrcode from "qrcode-terminal";
|
|
12
|
+
import { TelegramClient } from "telegram";
|
|
13
|
+
import { StringSession } from "telegram/sessions/index.js";
|
|
14
|
+
|
|
15
|
+
// Telegram Desktop public api_id/api_hash (from open-source TDesktop)
|
|
16
|
+
const apiId = 2040;
|
|
17
|
+
const apiHash = "b18441a1ff607e10a989891a5462e627";
|
|
18
|
+
|
|
19
|
+
const session = new StringSession("");
|
|
20
|
+
const client = new TelegramClient(session, apiId, apiHash, {
|
|
21
|
+
connectionRetries: 3,
|
|
22
|
+
deviceModel: "EasyOref Auth",
|
|
23
|
+
appVersion: "1.0.0",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
console.log("\n🔑 EasyOref — Telegram QR Auth\n");
|
|
27
|
+
console.log("Connecting to Telegram...\n");
|
|
28
|
+
|
|
29
|
+
// GramJS requires phoneNumber callback to enter QR flow.
|
|
30
|
+
// Throwing RESTART_AUTH_WITH_QR makes it switch to QR login.
|
|
31
|
+
await client.start({
|
|
32
|
+
phoneNumber: async () => {
|
|
33
|
+
throw { errorMessage: "RESTART_AUTH_WITH_QR" };
|
|
34
|
+
},
|
|
35
|
+
password: async () => "",
|
|
36
|
+
phoneCode: async () => "",
|
|
37
|
+
qrCode: async ({ token }) => {
|
|
38
|
+
const url = `tg://login?token=${token.toString("base64url")}`;
|
|
39
|
+
console.clear();
|
|
40
|
+
console.log(
|
|
41
|
+
"📱 Scan this QR in Telegram → Settings → Devices → Link Desktop\n",
|
|
42
|
+
);
|
|
43
|
+
qrcode.generate(url, { small: true });
|
|
44
|
+
console.log("");
|
|
45
|
+
},
|
|
46
|
+
onError: (err) => {
|
|
47
|
+
if (String(err).includes("RESTART_AUTH")) return Promise.resolve(true);
|
|
48
|
+
console.error("Auth error:", err);
|
|
49
|
+
return Promise.resolve(true);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
console.log("\n✅ Authenticated!\n");
|
|
54
|
+
|
|
55
|
+
const sessionString = client.session.save() as unknown as string;
|
|
56
|
+
|
|
57
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
58
|
+
console.log("SESSION STRING (copy this to config.yaml):\n");
|
|
59
|
+
console.log(sessionString);
|
|
60
|
+
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
61
|
+
|
|
62
|
+
await client.disconnect();
|
|
63
|
+
process.exit(0);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dry-run: test vote + buildEnrichedMessage without Redis / Telegram / LLM.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx packages/bot/src/agent/dry-run.ts
|
|
6
|
+
*
|
|
7
|
+
* Prints the enriched message HTML to stdout.
|
|
8
|
+
* Strip HTML tags to preview plain text:
|
|
9
|
+
* npx tsx packages/bot/src/agent/dry-run.ts | sed 's/<[^>]*>//g'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Mock base message (as formatMessage() would produce) ──────────────────────
|
|
13
|
+
|
|
14
|
+
const BASE_MESSAGE = [
|
|
15
|
+
"<b>🚀 Ракетная атака</b>",
|
|
16
|
+
"Ожидаются прилёты. Пройдите в укрытие.",
|
|
17
|
+
"",
|
|
18
|
+
"<b>Район:</b> Тель-Авив — Яффо",
|
|
19
|
+
"<b>Подлётное время:</b> ~5–12 мин",
|
|
20
|
+
"<b>Время оповещения:</b> 03:47",
|
|
21
|
+
].join("\n");
|
|
22
|
+
|
|
23
|
+
// ── Mock validated extractions (normally come from LLM) ───────────────────────
|
|
24
|
+
|
|
25
|
+
const NOW = Date.now();
|
|
26
|
+
const ALERT_TS = NOW - 90_000; // alert was 90s ago
|
|
27
|
+
|
|
28
|
+
const MOCK_EXTRACTIONS = [
|
|
29
|
+
{
|
|
30
|
+
channel: "@newsflashhhj",
|
|
31
|
+
messageUrl: "https://t.me/newsflashhhj/12340",
|
|
32
|
+
regionRelevance: 0.9,
|
|
33
|
+
sourceTrust: 0.85,
|
|
34
|
+
tone: "calm" as const,
|
|
35
|
+
countryOrigin: "Iran",
|
|
36
|
+
rocketCount: 6,
|
|
37
|
+
isCassette: false,
|
|
38
|
+
hitsConfirmed: undefined,
|
|
39
|
+
hit_detail: undefined,
|
|
40
|
+
etaRefinedMinutes: 8,
|
|
41
|
+
confidence: 0.88,
|
|
42
|
+
valid: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
channel: "@israelsecurity",
|
|
46
|
+
messageUrl: "https://t.me/israelsecurity/5521",
|
|
47
|
+
regionRelevance: 0.85,
|
|
48
|
+
sourceTrust: 0.78,
|
|
49
|
+
tone: "neutral" as const,
|
|
50
|
+
countryOrigin: "Lebanon",
|
|
51
|
+
rocketCount: 7,
|
|
52
|
+
isCassette: true,
|
|
53
|
+
hitsConfirmed: undefined,
|
|
54
|
+
hit_detail: undefined,
|
|
55
|
+
etaRefinedMinutes: 9,
|
|
56
|
+
confidence: 0.75,
|
|
57
|
+
valid: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
channel: "@N12LIVE",
|
|
61
|
+
messageUrl: "https://t.me/N12LIVE/8802",
|
|
62
|
+
regionRelevance: 0.7,
|
|
63
|
+
sourceTrust: 0.9,
|
|
64
|
+
tone: "calm" as const,
|
|
65
|
+
countryOrigin: "Iran",
|
|
66
|
+
rocketCount: 5,
|
|
67
|
+
isCassette: undefined,
|
|
68
|
+
hitsConfirmed: 2,
|
|
69
|
+
hit_detail: "на открытой местности",
|
|
70
|
+
etaRefinedMinutes: undefined,
|
|
71
|
+
confidence: 0.82,
|
|
72
|
+
valid: true,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// ── Inline copy of vote() + buildEnrichedMessage() ────────────────────────────
|
|
77
|
+
// (avoids importing config / redis which require a real config.yaml)
|
|
78
|
+
|
|
79
|
+
const SUPERSCRIPTS = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"];
|
|
80
|
+
function sup(indices: number[]): string {
|
|
81
|
+
return indices
|
|
82
|
+
.map((n) =>
|
|
83
|
+
String(n)
|
|
84
|
+
.split("")
|
|
85
|
+
.map((d) => SUPERSCRIPTS[Number(d)])
|
|
86
|
+
.join(""),
|
|
87
|
+
)
|
|
88
|
+
.join("");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const COUNTRY_RU: Record<string, string> = {
|
|
92
|
+
Iran: "Иран",
|
|
93
|
+
Yemen: "Йемен",
|
|
94
|
+
Lebanon: "Ливан",
|
|
95
|
+
Gaza: "Газа",
|
|
96
|
+
Iraq: "Ирак",
|
|
97
|
+
Syria: "Сирия",
|
|
98
|
+
Hezbollah: "Хезболла",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function vote(extractions: typeof MOCK_EXTRACTIONS) {
|
|
102
|
+
const indexed = extractions.map((e, i) => ({ ...e, idx: i + 1 }));
|
|
103
|
+
|
|
104
|
+
const citedSources = indexed.map((e) => ({
|
|
105
|
+
index: e.idx,
|
|
106
|
+
channel: e.channel,
|
|
107
|
+
messageUrl: e.messageUrl ?? undefined,
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
// ETA: highest-confidence source
|
|
111
|
+
const withEta = indexed
|
|
112
|
+
.filter((e) => e.etaRefinedMinutes !== undefined)
|
|
113
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
114
|
+
const bestEta = withEta[0] ?? undefined;
|
|
115
|
+
|
|
116
|
+
// Countries: group, collect citations
|
|
117
|
+
const countryMap = new Map<string, number[]>();
|
|
118
|
+
for (const e of indexed) {
|
|
119
|
+
if (e.countryOrigin) {
|
|
120
|
+
const list = countryMap.get(e.countryOrigin) ?? [];
|
|
121
|
+
list.push(e.idx);
|
|
122
|
+
countryMap.set(e.countryOrigin, list);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const countryOrigins =
|
|
126
|
+
countryMap.size > 0
|
|
127
|
+
? Array.from(countryMap.entries()).map(([name, citations]) => ({
|
|
128
|
+
name,
|
|
129
|
+
citations,
|
|
130
|
+
}))
|
|
131
|
+
: undefined;
|
|
132
|
+
|
|
133
|
+
// Rocket range
|
|
134
|
+
const rocketSrcs = indexed.filter((e) => e.rocketCount !== undefined);
|
|
135
|
+
const rocketVals = rocketSrcs.map((e) => e.rocketCount as number);
|
|
136
|
+
const rocketCountMin =
|
|
137
|
+
rocketVals.length > 0 ? Math.min(...rocketVals) : undefined;
|
|
138
|
+
const rocketCountMax =
|
|
139
|
+
rocketVals.length > 0 ? Math.max(...rocketVals) : undefined;
|
|
140
|
+
const rocket_citations = rocketSrcs.map((e) => e.idx);
|
|
141
|
+
|
|
142
|
+
// Cassette: majority
|
|
143
|
+
const cassVals = indexed
|
|
144
|
+
.filter((e) => e.isCassette !== undefined)
|
|
145
|
+
.map((e) => e.isCassette as boolean);
|
|
146
|
+
const isCassette =
|
|
147
|
+
cassVals.length > 0
|
|
148
|
+
? cassVals.filter(Boolean).length > cassVals.length / 2
|
|
149
|
+
: undefined;
|
|
150
|
+
|
|
151
|
+
// Hits: median
|
|
152
|
+
const hitsVals = indexed
|
|
153
|
+
.filter((e) => e.hitsConfirmed !== undefined)
|
|
154
|
+
.map((e) => e.hitsConfirmed as number)
|
|
155
|
+
.sort((a, b) => a - b);
|
|
156
|
+
const hitsConfirmed =
|
|
157
|
+
hitsVals.length > 0 ? hitsVals[Math.floor(hitsVals.length / 2)] : undefined;
|
|
158
|
+
|
|
159
|
+
// Hits citations
|
|
160
|
+
const hitsSrcs = indexed.filter(
|
|
161
|
+
(e) => e.hitsConfirmed !== undefined && e.hitsConfirmed > 0,
|
|
162
|
+
);
|
|
163
|
+
const hits_citations = hitsSrcs.map((e) => e.idx);
|
|
164
|
+
|
|
165
|
+
// Weighted confidence
|
|
166
|
+
const totalWeight = indexed.reduce(
|
|
167
|
+
(s, e) => s + e.sourceTrust * e.confidence,
|
|
168
|
+
0,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
etaRefinedMinutes: bestEta?.etaRefinedMinutes ?? undefined,
|
|
173
|
+
eta_citations: bestEta ? [bestEta.idx] : [],
|
|
174
|
+
countryOrigins,
|
|
175
|
+
rocketCountMin,
|
|
176
|
+
rocketCountMax,
|
|
177
|
+
isCassette,
|
|
178
|
+
rocket_citations,
|
|
179
|
+
hitsConfirmed,
|
|
180
|
+
hits_citations,
|
|
181
|
+
confidence: Math.round((totalWeight / indexed.length) * 100) / 100,
|
|
182
|
+
sourcesCount: indexed.length,
|
|
183
|
+
citedSources,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function insertBeforeBlockEnd(text: string, line: string): string {
|
|
188
|
+
const bqIdx = text.lastIndexOf("</blockquote>");
|
|
189
|
+
if (bqIdx !== -1) {
|
|
190
|
+
return text.slice(0, bqIdx) + line + "\n" + text.slice(bqIdx);
|
|
191
|
+
}
|
|
192
|
+
const timeLinePattern = /(<b>Время оповещения:<\/b>)/;
|
|
193
|
+
const match = text.match(timeLinePattern);
|
|
194
|
+
if (match?.index) {
|
|
195
|
+
return text.slice(0, match.index) + line + "\n" + text.slice(match.index);
|
|
196
|
+
}
|
|
197
|
+
const lines = text.split("\n");
|
|
198
|
+
lines.splice(Math.max(lines.length - 1, 0), 0, line);
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function refineEtaInPlace(
|
|
203
|
+
text: string,
|
|
204
|
+
minutes: number,
|
|
205
|
+
alertTs: number,
|
|
206
|
+
citations: number[],
|
|
207
|
+
): string {
|
|
208
|
+
const absTime = new Date(alertTs + minutes * 60_000).toLocaleTimeString(
|
|
209
|
+
"he-IL",
|
|
210
|
+
{ hour: "2-digit", minute: "2-digit", timeZone: "Asia/Jerusalem" },
|
|
211
|
+
);
|
|
212
|
+
const refined = `~${absTime}${sup(citations)}`;
|
|
213
|
+
|
|
214
|
+
const etaPatterns = [
|
|
215
|
+
/~\d+[–-]\d+\s*мин/,
|
|
216
|
+
/~\d+[–-]\d+\s*min/,
|
|
217
|
+
/~\d+[–-]\d+\s*דקות/,
|
|
218
|
+
/1\.5\s*мин/,
|
|
219
|
+
/1\.5\s*min/,
|
|
220
|
+
];
|
|
221
|
+
for (const pattern of etaPatterns) {
|
|
222
|
+
if (pattern.test(text)) return text.replace(pattern, refined);
|
|
223
|
+
}
|
|
224
|
+
return text;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildEnrichedMessage(
|
|
228
|
+
currentText: string,
|
|
229
|
+
alertTs: number,
|
|
230
|
+
r: ReturnType<typeof vote>,
|
|
231
|
+
): string {
|
|
232
|
+
let text = currentText;
|
|
233
|
+
|
|
234
|
+
if (r.etaRefinedMinutes !== undefined && r.eta_citations.length > 0) {
|
|
235
|
+
text = refineEtaInPlace(
|
|
236
|
+
text,
|
|
237
|
+
r.etaRefinedMinutes,
|
|
238
|
+
alertTs,
|
|
239
|
+
r.eta_citations,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (r.countryOrigins && r.countryOrigins.length > 0) {
|
|
244
|
+
const parts = r.countryOrigins.map((c) => {
|
|
245
|
+
const ru = COUNTRY_RU[c.name] ?? c.name;
|
|
246
|
+
return `${ru}${sup(c.citations)}`;
|
|
247
|
+
});
|
|
248
|
+
// Leading \n creates blank line between ETA and intel block
|
|
249
|
+
text = insertBeforeBlockEnd(text, `\n<b>Откуда:</b> ${parts.join(" + ")}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (r.rocketCountMin !== undefined && r.rocketCountMax !== undefined) {
|
|
253
|
+
const countStr =
|
|
254
|
+
r.rocketCountMin === r.rocketCountMax
|
|
255
|
+
? `${r.rocketCountMin}`
|
|
256
|
+
: `~${r.rocketCountMin}-${r.rocketCountMax}`;
|
|
257
|
+
const cassette = r.isCassette ? " (кассет.)" : "";
|
|
258
|
+
text = insertBeforeBlockEnd(text, `<b>Ракет:</b> ${countStr}${cassette}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (r.hitsConfirmed !== undefined && r.hitsConfirmed > 0) {
|
|
262
|
+
const hitsCite = r.hits_citations.length > 0 ? sup(r.hits_citations) : "";
|
|
263
|
+
text = insertBeforeBlockEnd(
|
|
264
|
+
text,
|
|
265
|
+
`<b>Попадания (Дан центр):</b> ${r.hitsConfirmed}${hitsCite}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sourcesWithUrl = r.citedSources.filter((s) => s.messageUrl);
|
|
270
|
+
if (sourcesWithUrl.length > 0) {
|
|
271
|
+
const links = sourcesWithUrl
|
|
272
|
+
.map((s) => `<a href="${s.messageUrl}">[${s.index}]</a>`)
|
|
273
|
+
.join(" ");
|
|
274
|
+
text += `\n—\n<i>Источники: ${links}</i>`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
const voted = vote(MOCK_EXTRACTIONS);
|
|
283
|
+
|
|
284
|
+
console.log("\n=== VOTE RESULT ===");
|
|
285
|
+
console.log(JSON.stringify(voted, undefined, 2));
|
|
286
|
+
|
|
287
|
+
const enriched = buildEnrichedMessage(BASE_MESSAGE, ALERT_TS, voted);
|
|
288
|
+
|
|
289
|
+
console.log("\n=== ENRICHED MESSAGE (HTML) ===");
|
|
290
|
+
console.log(enriched);
|
|
291
|
+
|
|
292
|
+
console.log("\n=== PLAIN TEXT PREVIEW ===");
|
|
293
|
+
console.log(
|
|
294
|
+
enriched
|
|
295
|
+
.replace(/<[^>]*>/g, "")
|
|
296
|
+
.replace(/</g, "<")
|
|
297
|
+
.replace(/>/g, ">"),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
console.log(`\n=== STATS ===`);
|
|
301
|
+
console.log(`Confidence: ${voted.confidence}`);
|
|
302
|
+
console.log(`Sources: ${voted.sourcesCount}`);
|
|
303
|
+
console.log(`Chars: ${enriched.length} (TG caption limit: 1024)`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BullMQ queue — enrich alert jobs.
|
|
3
|
+
*
|
|
4
|
+
* Job payload: { alertId, alertTs }
|
|
5
|
+
* Job is added with a delay (config.agent.enrichDelayMs) after alert fires,
|
|
6
|
+
* then the worker runs the LangGraph pipeline and edits the Telegram message.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as logger from "@easyoref/monitoring";
|
|
10
|
+
import { config } from "@easyoref/shared";
|
|
11
|
+
import { Queue } from "bullmq";
|
|
12
|
+
|
|
13
|
+
export interface EnrichJobData {
|
|
14
|
+
alertId: string;
|
|
15
|
+
alertTs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let _queue: Queue<EnrichJobData> | undefined = undefined;
|
|
19
|
+
|
|
20
|
+
export function getEnrichQueue(): Queue<EnrichJobData> {
|
|
21
|
+
if (!_queue) {
|
|
22
|
+
_queue = new Queue<EnrichJobData>("enrich-alert", {
|
|
23
|
+
connection: {
|
|
24
|
+
host: new URL(config.agent.redisUrl).hostname,
|
|
25
|
+
port: Number(new URL(config.agent.redisUrl).port || 6379),
|
|
26
|
+
password: new URL(config.agent.redisUrl).password || undefined,
|
|
27
|
+
},
|
|
28
|
+
defaultJobOptions: {
|
|
29
|
+
removeOnComplete: 100,
|
|
30
|
+
removeOnFail: 50,
|
|
31
|
+
attempts: 2,
|
|
32
|
+
backoff: { type: "exponential", delay: 10_000 },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return _queue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function enqueueEnrich(
|
|
40
|
+
alertId: string,
|
|
41
|
+
alertTs: number,
|
|
42
|
+
delayMs?: number,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (!config.agent.enabled) return;
|
|
45
|
+
|
|
46
|
+
const delay = delayMs ?? config.agent.enrichDelayMs;
|
|
47
|
+
const queue = getEnrichQueue();
|
|
48
|
+
await queue.add("enrich", { alertId, alertTs }, { delay });
|
|
49
|
+
logger.info("Enrich job enqueued", {
|
|
50
|
+
alertId,
|
|
51
|
+
delay_ms: delay,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis singleton (ioredis).
|
|
3
|
+
* Lazily initialized so the main bot still works when agent.enabled=false.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as logger from "@easyoref/monitoring";
|
|
7
|
+
import { config } from "@easyoref/shared";
|
|
8
|
+
import { Redis as IORedis } from "ioredis";
|
|
9
|
+
|
|
10
|
+
let _redis: IORedis | undefined = undefined;
|
|
11
|
+
|
|
12
|
+
export function getRedis(): IORedis {
|
|
13
|
+
if (!_redis) {
|
|
14
|
+
_redis = new IORedis(config.agent.redisUrl, {
|
|
15
|
+
lazyConnect: false,
|
|
16
|
+
maxRetriesPerRequest: 3,
|
|
17
|
+
enableReadyCheck: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
_redis.on("error", (err: Error) => {
|
|
21
|
+
logger.warn("Redis error", { error: String(err) });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
_redis.on("connect", () => {
|
|
25
|
+
logger.info("Redis connected", {
|
|
26
|
+
url: config.agent.redisUrl.replace(/:[^:@]+@/, ":***@"),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return _redis;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function closeRedis(): Promise<void> {
|
|
34
|
+
if (_redis) {
|
|
35
|
+
await _redis.quit();
|
|
36
|
+
_redis = undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BullMQ worker — processes "enrich-alert" jobs.
|
|
3
|
+
*
|
|
4
|
+
* Session-aware scheduling:
|
|
5
|
+
* early_warning → every 20s, up to 30 min
|
|
6
|
+
* red_alert → every 20s, up to 15 min
|
|
7
|
+
* resolved → every 60s, up to 10 min (tail — detailed intel)
|
|
8
|
+
*
|
|
9
|
+
* After each job, checks the session phase and re-enqueues
|
|
10
|
+
* with the appropriate delay. Stops when phase expires.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as logger from "@easyoref/monitoring";
|
|
14
|
+
import {
|
|
15
|
+
clearSession,
|
|
16
|
+
config,
|
|
17
|
+
getActiveSession,
|
|
18
|
+
getLanguagePack,
|
|
19
|
+
isPhaseExpired,
|
|
20
|
+
PHASE_ENRICH_DELAY_MS,
|
|
21
|
+
TelegramMessage,
|
|
22
|
+
} from "@easyoref/shared";
|
|
23
|
+
import { Worker } from "bullmq";
|
|
24
|
+
import { Bot } from "grammy";
|
|
25
|
+
import { runEnrichment } from "../graph.js";
|
|
26
|
+
import { MONITORING_RE, stripMonitoring } from "../nodes/message.js";
|
|
27
|
+
import { enqueueEnrich, type EnrichJobData } from "./queue.js";
|
|
28
|
+
|
|
29
|
+
let _worker: Worker | undefined = undefined;
|
|
30
|
+
|
|
31
|
+
/** Remove ⏳ monitoring indicator from all chat messages (best-effort) */
|
|
32
|
+
async function removeMonitoringIndicator(session: {
|
|
33
|
+
chatId: string;
|
|
34
|
+
latestMessageId: number;
|
|
35
|
+
isCaption: boolean;
|
|
36
|
+
currentText: string;
|
|
37
|
+
telegramMessages?: TelegramMessage[];
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
if (!config.botToken || !MONITORING_RE.test(session.currentText)) return;
|
|
40
|
+
const cleaned = stripMonitoring(session.currentText);
|
|
41
|
+
const tgBot = new Bot(config.botToken);
|
|
42
|
+
const targets: TelegramMessage[] = session.telegramMessages ?? [
|
|
43
|
+
{
|
|
44
|
+
chatId: session.chatId,
|
|
45
|
+
messageId: session.latestMessageId,
|
|
46
|
+
isCaption: session.isCaption,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
for (const cm of targets) {
|
|
50
|
+
try {
|
|
51
|
+
if (cm.isCaption) {
|
|
52
|
+
await tgBot.api.editMessageCaption(cm.chatId, cm.messageId, {
|
|
53
|
+
caption: cleaned,
|
|
54
|
+
parse_mode: "HTML",
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
await tgBot.api.editMessageText(cm.chatId, cm.messageId, cleaned, {
|
|
58
|
+
parse_mode: "HTML",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const errStr = String(err);
|
|
63
|
+
if (!errStr.includes("message is not modified")) {
|
|
64
|
+
logger.error("Failed to remove monitoring indicator", {
|
|
65
|
+
error: errStr,
|
|
66
|
+
chatId: cm.chatId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
logger.info("Removed monitoring indicator", { targets: targets.length });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function startEnrichWorker(): void {
|
|
75
|
+
if (!config.agent.enabled) return;
|
|
76
|
+
|
|
77
|
+
const connection = {
|
|
78
|
+
host: new URL(config.agent.redisUrl).hostname,
|
|
79
|
+
port: Number(new URL(config.agent.redisUrl).port || 6379),
|
|
80
|
+
password: new URL(config.agent.redisUrl).password || undefined,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
_worker = new Worker<EnrichJobData>(
|
|
84
|
+
"enrich-alert",
|
|
85
|
+
async (job) => {
|
|
86
|
+
const { alertId } = job.data;
|
|
87
|
+
logger.info("Enrich worker: processing job", { alertId, jobId: job.id });
|
|
88
|
+
|
|
89
|
+
const session = await getActiveSession();
|
|
90
|
+
if (!session) {
|
|
91
|
+
logger.info("Enrich worker: no active session — skipping", { alertId });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Phase expired → end session
|
|
96
|
+
if (isPhaseExpired(session)) {
|
|
97
|
+
logger.info("Enrich worker: phase expired — ending session", {
|
|
98
|
+
alertId: session.latestAlertId,
|
|
99
|
+
phase: session.phase,
|
|
100
|
+
});
|
|
101
|
+
await removeMonitoringIndicator(session);
|
|
102
|
+
await clearSession();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const langPack = getLanguagePack(config.language);
|
|
107
|
+
|
|
108
|
+
// Run enrichment using latest alert's message as edit target
|
|
109
|
+
await runEnrichment({
|
|
110
|
+
alertId: session.latestAlertId,
|
|
111
|
+
alertTs: session.latestAlertTs,
|
|
112
|
+
alertType: session.phase,
|
|
113
|
+
alertAreas: session.alertAreas,
|
|
114
|
+
chatId: session.chatId,
|
|
115
|
+
messageId: session.latestMessageId,
|
|
116
|
+
isCaption: session.isCaption,
|
|
117
|
+
telegramMessages: session.telegramMessages,
|
|
118
|
+
currentText: session.baseText ?? session.currentText,
|
|
119
|
+
monitoringLabel: langPack.labels.monitoring,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Re-check session after enrichment (may have changed phase)
|
|
123
|
+
const after = await getActiveSession();
|
|
124
|
+
if (!after) return;
|
|
125
|
+
|
|
126
|
+
if (isPhaseExpired(after)) {
|
|
127
|
+
logger.info(
|
|
128
|
+
"Enrich worker: phase expired post-enrich — ending session",
|
|
129
|
+
{
|
|
130
|
+
phase: after.phase,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
await removeMonitoringIndicator(after);
|
|
134
|
+
await clearSession();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Re-enqueue with phase-appropriate delay
|
|
139
|
+
const delay = PHASE_ENRICH_DELAY_MS[after.phase];
|
|
140
|
+
await enqueueEnrich(after.latestAlertId, after.latestAlertTs, delay);
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
connection,
|
|
144
|
+
concurrency: 1,
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
_worker.on("completed", (job) => {
|
|
149
|
+
logger.info("Enrich worker: job completed", { jobId: job.id });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
_worker.on("failed", (job, err) => {
|
|
153
|
+
logger.error("Enrich worker: job failed", {
|
|
154
|
+
jobId: job?.id,
|
|
155
|
+
error: String(err),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
logger.info("Enrich worker started");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function stopEnrichWorker(): Promise<void> {
|
|
163
|
+
if (_worker) {
|
|
164
|
+
await _worker.close();
|
|
165
|
+
_worker = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|