omp-conductor 0.13.0 → 0.15.0
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/README.md +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
package/src/diff-flags.ts
CHANGED
|
@@ -290,9 +290,19 @@ function expandBraces(token: string): string[] {
|
|
|
290
290
|
return alternatives.map((alt) => prefix + alt + suffix);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/** A trailing slash-separated extension list:
|
|
294
|
+
* `scripts/a.sh/.py` → [`scripts/a.sh`, `scripts/a.py`]. */
|
|
295
|
+
function expandExtensionAlternation(token: string): string[] {
|
|
296
|
+
const match = /^(.+?)\.([A-Za-z][A-Za-z0-9]{1,7})((?:\/\.[A-Za-z][A-Za-z0-9]{1,7})+)$/.exec(token);
|
|
297
|
+
if (match === null) return [token];
|
|
298
|
+
const [, base, first, rest] = match;
|
|
299
|
+
if (base === undefined || first === undefined || rest === undefined) return [token];
|
|
300
|
+
return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
293
303
|
/** Every path-shaped token on the report's `changed:` line. An absent line and
|
|
294
304
|
* a line naming nothing are the same answer: nothing was disclosed. */
|
|
295
|
-
|
|
305
|
+
function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
|
|
296
306
|
const line = CHANGED_LINE.exec(report)?.[1] ?? "";
|
|
297
307
|
const seen = new Set<string>();
|
|
298
308
|
// Split on whitespace and semicolons, and on commas *outside* a brace group:
|
|
@@ -306,13 +316,21 @@ export function claimedPaths(report: string): string[] {
|
|
|
306
316
|
// directions — sees plain paths: a brace token dies earlier at the
|
|
307
317
|
// CLAIMED_PATH filter if it is never opened up (#224).
|
|
308
318
|
for (const expanded of expandBraces(normalised)) {
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
const alternatives = expandExtensionAlternation(expanded);
|
|
320
|
+
for (const path of alternatives) {
|
|
321
|
+
if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
|
|
322
|
+
seen.add(path);
|
|
323
|
+
if (alternatives.length > 1) extensionAlternatives?.add(path);
|
|
324
|
+
}
|
|
311
325
|
}
|
|
312
326
|
}
|
|
313
327
|
return [...seen];
|
|
314
328
|
}
|
|
315
329
|
|
|
330
|
+
export function claimedPaths(report: string): string[] {
|
|
331
|
+
return parseClaimedPaths(report);
|
|
332
|
+
}
|
|
333
|
+
|
|
316
334
|
/**
|
|
317
335
|
* Whether one claim covers one path. Every rule here is deliberately generous,
|
|
318
336
|
* because each one that fails produces an omission flag on an honest report:
|
|
@@ -505,7 +523,8 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
|
|
|
505
523
|
|
|
506
524
|
function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
507
525
|
const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
|
|
508
|
-
const
|
|
526
|
+
const extensionAlternatives = new Set<string>();
|
|
527
|
+
const claims = parseClaimedPaths(audit.report, extensionAlternatives);
|
|
509
528
|
const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
|
|
510
529
|
// Coverage pools the current report with every prior attempt's disclosures:
|
|
511
530
|
// a file the final report no longer names was disclosed while the work was
|
|
@@ -553,12 +572,62 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
553
572
|
if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
|
|
554
573
|
continue;
|
|
555
574
|
}
|
|
575
|
+
if (extensionAlternatives.has(claim)) {
|
|
576
|
+
const stem = claim.slice(0, claim.lastIndexOf("."));
|
|
577
|
+
const matchedAlternative = claims.some(
|
|
578
|
+
(other) =>
|
|
579
|
+
other !== claim &&
|
|
580
|
+
extensionAlternatives.has(other) &&
|
|
581
|
+
other.slice(0, other.lastIndexOf(".")) === stem &&
|
|
582
|
+
audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
|
|
583
|
+
);
|
|
584
|
+
if (matchedAlternative) continue;
|
|
585
|
+
}
|
|
556
586
|
flags.push({
|
|
557
587
|
kind: "unmatched-claim",
|
|
558
588
|
file: claim,
|
|
559
589
|
detail: "named by the report's `changed:` line but not touched by the PR",
|
|
560
590
|
});
|
|
561
591
|
}
|
|
592
|
+
|
|
593
|
+
// A parser failure can otherwise accuse the report in both directions for
|
|
594
|
+
// the same file. Collapse only that self-refuting pair; unrelated findings
|
|
595
|
+
// remain intact.
|
|
596
|
+
const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
|
|
597
|
+
const removed = new Set<SettlementFlag>();
|
|
598
|
+
let exampleClaim = "";
|
|
599
|
+
let examplePath = "";
|
|
600
|
+
for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
|
|
601
|
+
const name = basename(undisclosed.file);
|
|
602
|
+
const extension = name.lastIndexOf(".");
|
|
603
|
+
const stem = extension > 0 ? name.slice(0, extension) : name;
|
|
604
|
+
if (stem.length < 3) continue;
|
|
605
|
+
for (const claim of unmatched) {
|
|
606
|
+
if (!claim.file.includes(stem)) continue;
|
|
607
|
+
removed.add(undisclosed);
|
|
608
|
+
removed.add(claim);
|
|
609
|
+
if (exampleClaim === "") {
|
|
610
|
+
exampleClaim = claim.file;
|
|
611
|
+
examplePath = undisclosed.file;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (removed.size === 0) return;
|
|
616
|
+
|
|
617
|
+
const kept = flags.filter((flag) => !removed.has(flag));
|
|
618
|
+
const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
|
|
619
|
+
flags.splice(
|
|
620
|
+
0,
|
|
621
|
+
flags.length,
|
|
622
|
+
...kept,
|
|
623
|
+
{
|
|
624
|
+
kind: "report-format-unparsed",
|
|
625
|
+
file: "(report)",
|
|
626
|
+
detail:
|
|
627
|
+
`${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
|
|
628
|
+
`file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
|
|
629
|
+
},
|
|
630
|
+
);
|
|
562
631
|
}
|
|
563
632
|
|
|
564
633
|
function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
package/src/digest-schedule.ts
CHANGED
|
@@ -9,27 +9,53 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ReportingPolicy } from "./types.ts";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
const MINUTE_MS = 60_000;
|
|
13
|
+
const NEXT_DIGEST_HORIZON_MS = 3 * 24 * 60 * MINUTE_MS;
|
|
14
|
+
|
|
15
|
+
export type DigestScheduleState =
|
|
16
|
+
| { mode: "disabled" }
|
|
17
|
+
| { mode: "per-tick" }
|
|
18
|
+
| { mode: "due"; timezone: string }
|
|
19
|
+
| { mode: "scheduled"; timezone: string; nextAt?: number };
|
|
20
|
+
|
|
21
|
+
const localMinuteFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
22
|
+
interface CachedDigestSchedule {
|
|
23
|
+
lastDigestDayKey: string | undefined;
|
|
24
|
+
from: number;
|
|
25
|
+
until: number;
|
|
26
|
+
state: Extract<DigestScheduleState, { mode: "scheduled" }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const schedules = new WeakMap<ReportingPolicy["digest"], CachedDigestSchedule>();
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function localDigestMinute(at: number, timezone?: string): { day: string; clock: string } {
|
|
33
|
+
const key = timezone ?? "";
|
|
34
|
+
let formatter = localMinuteFormatters.get(key);
|
|
35
|
+
if (formatter === undefined) {
|
|
36
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
37
|
+
timeZone: timezone,
|
|
38
|
+
year: "numeric",
|
|
39
|
+
month: "2-digit",
|
|
40
|
+
day: "2-digit",
|
|
41
|
+
hour: "2-digit",
|
|
42
|
+
minute: "2-digit",
|
|
43
|
+
hourCycle: "h23",
|
|
44
|
+
});
|
|
45
|
+
localMinuteFormatters.set(key, formatter);
|
|
46
|
+
}
|
|
47
|
+
const parts = formatter.formatToParts(new Date(at));
|
|
48
|
+
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
49
|
+
parts.find((part) => part.type === type)?.value ?? "00";
|
|
50
|
+
return {
|
|
51
|
+
day: `${get("year")}-${get("month")}-${get("day")}`,
|
|
52
|
+
clock: `${get("hour")}:${get("minute")}`,
|
|
53
|
+
};
|
|
23
54
|
}
|
|
24
55
|
|
|
25
|
-
/** `
|
|
26
|
-
function
|
|
27
|
-
return
|
|
28
|
-
timeZone: timezone,
|
|
29
|
-
hour: "2-digit",
|
|
30
|
-
minute: "2-digit",
|
|
31
|
-
hourCycle: "h23",
|
|
32
|
-
}).format(new Date(at));
|
|
56
|
+
/** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
|
|
57
|
+
export function localDayKey(at: number, timezone?: string): string {
|
|
58
|
+
return localDigestMinute(at, timezone).day;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
/**
|
|
@@ -51,9 +77,51 @@ export function digestDue(
|
|
|
51
77
|
if (cadence === "none") return false;
|
|
52
78
|
if (cadence === "per-tick") return true;
|
|
53
79
|
// daily
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
const local = localDigestMinute(now, timezone);
|
|
81
|
+
if (at === undefined) return lastDigestDayKey !== local.day;
|
|
82
|
+
if (lastDigestDayKey === local.day) return false;
|
|
83
|
+
return local.clock >= at;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mechanical status for the next digest opportunity, using the same
|
|
87
|
+
* predicate that gates report submission. Minute scanning deliberately keeps
|
|
88
|
+
* skipped/repeated DST wall-clock times on the runtime's real timeline. */
|
|
89
|
+
export function digestScheduleState(
|
|
90
|
+
policy: Pick<ReportingPolicy, "digest">,
|
|
91
|
+
lastDigestDayKey: string | undefined,
|
|
92
|
+
now: number,
|
|
93
|
+
): DigestScheduleState {
|
|
94
|
+
if (policy.digest.cadence === "none") return { mode: "disabled" };
|
|
95
|
+
if (policy.digest.cadence === "per-tick") return { mode: "per-tick" };
|
|
96
|
+
|
|
97
|
+
const timezone =
|
|
98
|
+
policy.digest.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
99
|
+
if (digestDue(policy, lastDigestDayKey, now)) return { mode: "due", timezone };
|
|
100
|
+
|
|
101
|
+
const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
|
102
|
+
const cached = schedules.get(policy.digest);
|
|
103
|
+
if (
|
|
104
|
+
cached !== undefined &&
|
|
105
|
+
cached.lastDigestDayKey === lastDigestDayKey &&
|
|
106
|
+
from >= cached.from &&
|
|
107
|
+
from < cached.until
|
|
108
|
+
) {
|
|
109
|
+
return cached.state;
|
|
56
110
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
111
|
+
|
|
112
|
+
const end = now + NEXT_DIGEST_HORIZON_MS;
|
|
113
|
+
let cursor = from + MINUTE_MS;
|
|
114
|
+
while (cursor <= end && !digestDue(policy, lastDigestDayKey, cursor)) cursor += MINUTE_MS;
|
|
115
|
+
const state: Extract<DigestScheduleState, { mode: "scheduled" }> = {
|
|
116
|
+
mode: "scheduled",
|
|
117
|
+
timezone,
|
|
118
|
+
...(cursor > end ? {} : { nextAt: cursor }),
|
|
119
|
+
};
|
|
120
|
+
schedules.set(policy.digest, {
|
|
121
|
+
lastDigestDayKey,
|
|
122
|
+
from,
|
|
123
|
+
until: cursor,
|
|
124
|
+
state,
|
|
125
|
+
});
|
|
126
|
+
return state;
|
|
127
|
+
}
|
package/src/escalate.ts
CHANGED
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
* those strings end up in daemon logs and, on the fallback path, in a public
|
|
21
21
|
* issue comment.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
24
|
import { readFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
|
|
28
|
+
import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
|
|
28
29
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
29
30
|
import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
|
|
30
31
|
|
|
@@ -137,13 +138,20 @@ export function formatEscalation(e: Escalation, project: string): string {
|
|
|
137
138
|
* still escalate, just to an issue comment.
|
|
138
139
|
*/
|
|
139
140
|
export function createEscalator(
|
|
140
|
-
|
|
141
|
+
source: ProjectConfig | (() => ProjectConfig),
|
|
141
142
|
tracker: Tracker,
|
|
142
143
|
store: Store,
|
|
143
144
|
orchestrator?: OrchestratorHandle,
|
|
145
|
+
now: () => number = Date.now,
|
|
146
|
+
deliveryAllowed: () => boolean = () => true,
|
|
144
147
|
): Escalator {
|
|
148
|
+
const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
|
|
145
149
|
return {
|
|
146
150
|
async escalate(e: Escalation): Promise<void> {
|
|
151
|
+
// The daemon's provider resolves the config reloaded at the latest tick
|
|
152
|
+
// boundary. Capture one snapshot for this delivery, including late
|
|
153
|
+
// settlement callbacks, so a mid-call edit cannot split its policy.
|
|
154
|
+
const p = currentProject();
|
|
147
155
|
// Stable across daemon restarts: same project, issue, tier and summary is
|
|
148
156
|
// the same event, however many times the loop rediscovers it.
|
|
149
157
|
const key = `${p.name}:${e.issue}:${e.tier}:${e.summary}`;
|
|
@@ -210,26 +218,45 @@ export function createEscalator(
|
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
if (e.tier === 2) {
|
|
222
|
+
const category = e.category ?? "tier2";
|
|
223
|
+
const at = now();
|
|
224
|
+
let disposition: InterruptDisposition;
|
|
225
|
+
if (!deliveryAllowed()) {
|
|
226
|
+
disposition = "availability";
|
|
227
|
+
} else if (e.urgent) {
|
|
228
|
+
// Urgency may bypass category batching when the digest loop itself is
|
|
229
|
+
// broken (#246), but it cannot invent an out-of-hours bypass the
|
|
230
|
+
// operator did not configure (#273).
|
|
231
|
+
const window = p.reporting?.availability;
|
|
232
|
+
disposition =
|
|
233
|
+
window !== undefined &&
|
|
234
|
+
!availabilityOpen(window, at) &&
|
|
235
|
+
!window.bypass.includes(category)
|
|
236
|
+
? "availability"
|
|
237
|
+
: "interrupt";
|
|
238
|
+
} else {
|
|
239
|
+
disposition = interruptDisposition(p.reporting, category, at);
|
|
240
|
+
}
|
|
241
|
+
if (disposition !== "interrupt") {
|
|
242
|
+
store.addHeldNotice({
|
|
243
|
+
id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
|
|
244
|
+
project: p.name,
|
|
245
|
+
category,
|
|
246
|
+
summary: e.summary,
|
|
247
|
+
detail: text,
|
|
248
|
+
createdAt: at,
|
|
249
|
+
...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
|
|
250
|
+
...(e.urgent === true ? { urgent: true } : {}),
|
|
251
|
+
});
|
|
252
|
+
store.markNotified(key);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
213
257
|
if (e.tier === 2 && chatId) {
|
|
214
258
|
const token = readTelegramToken();
|
|
215
259
|
if (token) {
|
|
216
|
-
// The digest can own deferred delivery only while its orchestrator
|
|
217
|
-
// loop is alive. An urgent escalation says that loop is the failed
|
|
218
|
-
// component, so waiting for its digest would park the only warning
|
|
219
|
-
// behind the failure it reports (#246).
|
|
220
|
-
const category = e.category ?? "tier2";
|
|
221
|
-
const interruptOn = p.reporting?.interruptOn;
|
|
222
|
-
if (!e.urgent && interruptOn !== undefined && !interruptOn.includes(category)) {
|
|
223
|
-
store.addHeldNotice({
|
|
224
|
-
project: p.name,
|
|
225
|
-
category,
|
|
226
|
-
summary: e.summary,
|
|
227
|
-
detail: text,
|
|
228
|
-
createdAt: Date.now(),
|
|
229
|
-
});
|
|
230
|
-
store.markNotified(key);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
260
|
// A send failure throws: `markNotified` stays uncalled so the next
|
|
234
261
|
// poll retries instead of writing the event off as delivered. No
|
|
235
262
|
// backoff in here — the dispatcher tick *is* the retry, and an
|
|
@@ -242,7 +269,7 @@ export function createEscalator(
|
|
|
242
269
|
// the tick re-raises it. A *report* has no such source — it exists
|
|
243
270
|
// once, in the model's head, and nothing regenerates it — which is
|
|
244
271
|
// why that one needed a ledger and this one does not.
|
|
245
|
-
await sendTelegram(token, chatId, text);
|
|
272
|
+
await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
|
|
246
273
|
store.markNotified(key);
|
|
247
274
|
return;
|
|
248
275
|
}
|
|
@@ -330,14 +357,52 @@ export function readTelegramToken(): string | undefined {
|
|
|
330
357
|
* Telegram's own message id when it returns one, and throws on every *known*
|
|
331
358
|
* failure — connection refused, HTTP error, `{"ok":false}` — which is what lets
|
|
332
359
|
* a caller treat a throw as "nobody has this" and a crash as "nobody knows".
|
|
360
|
+
*
|
|
361
|
+
* Optional `topicId` pins the message to a forum topic (`message_thread_id`).
|
|
362
|
+
* A definitive missing-thread reject retries once as a flat chat and warns, so a
|
|
363
|
+
* deleted topic degrades instead of silently losing the page (#318).
|
|
333
364
|
*/
|
|
334
365
|
export async function sendTelegram(
|
|
335
366
|
token: string,
|
|
336
367
|
chatId: string,
|
|
337
368
|
text: string,
|
|
369
|
+
opts?: { topicId?: number },
|
|
370
|
+
): Promise<number | undefined> {
|
|
371
|
+
const topicId =
|
|
372
|
+
opts?.topicId !== undefined && Number.isFinite(opts.topicId) && Number.isSafeInteger(opts.topicId)
|
|
373
|
+
? opts.topicId
|
|
374
|
+
: undefined;
|
|
375
|
+
try {
|
|
376
|
+
return await postTelegramMessage(token, chatId, text, topicId);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (
|
|
379
|
+
topicId === undefined ||
|
|
380
|
+
!(err instanceof TelegramSendError) ||
|
|
381
|
+
err.outcome !== "definitive" ||
|
|
382
|
+
!isMissingTelegramThread(err.message)
|
|
383
|
+
) {
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
warn(
|
|
387
|
+
`escalation.telegramTopicId=${topicId} is stale (${err.message}); retrying flat chat`,
|
|
388
|
+
);
|
|
389
|
+
return await postTelegramMessage(token, chatId, text, undefined);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Telegram's definitive "that forum topic is gone" answers. */
|
|
394
|
+
function isMissingTelegramThread(diagnostic: string): boolean {
|
|
395
|
+
return /message thread not found|topic_id_invalid/i.test(diagnostic);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function postTelegramMessage(
|
|
399
|
+
token: string,
|
|
400
|
+
chatId: string,
|
|
401
|
+
text: string,
|
|
402
|
+
topicId: number | undefined,
|
|
338
403
|
): Promise<number | undefined> {
|
|
339
404
|
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
|
340
|
-
const
|
|
405
|
+
const payload: Record<string, unknown> = {
|
|
341
406
|
chat_id: chatId,
|
|
342
407
|
// ponytail: hard truncation rather than splitting across messages — the
|
|
343
408
|
// tail of a stack trace is rarely the interesting part. Upgrade path is to
|
|
@@ -345,7 +410,9 @@ export async function sendTelegram(
|
|
|
345
410
|
text:
|
|
346
411
|
text.length > TELEGRAM_TEXT_LIMIT ? `${text.slice(0, TELEGRAM_TEXT_LIMIT)}\n[truncated]` : text,
|
|
347
412
|
disable_web_page_preview: true,
|
|
348
|
-
}
|
|
413
|
+
};
|
|
414
|
+
if (topicId !== undefined) payload.message_thread_id = topicId;
|
|
415
|
+
const body = JSON.stringify(payload);
|
|
349
416
|
|
|
350
417
|
let res: Response;
|
|
351
418
|
try {
|