talon-agent 5.26.0 → 5.26.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/package.json +1 -1
- package/src/backend/claude-sdk/model-provider.ts +59 -12
- package/src/core/frontend-runtime/admin-notify.ts +46 -6
- package/src/core/frontend-runtime/alerts.ts +10 -6
- package/src/core/models/active-model.ts +57 -48
- package/src/core/plugin/loader.ts +47 -12
- package/src/frontend/telegram/actions/media.ts +60 -19
- package/src/frontend/telegram/actions/messaging.ts +9 -0
package/package.json
CHANGED
|
@@ -71,19 +71,68 @@ function isSelectedModel(currentModel: string, candidateId: string): boolean {
|
|
|
71
71
|
|
|
72
72
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
query: string,
|
|
76
|
-
): Promise<UnifiedModelResolution> {
|
|
77
|
-
const canonicalId = resolveModelId(query);
|
|
78
|
-
const model = getModel(canonicalId);
|
|
74
|
+
const ONE_MILLION_SUFFIX = "[1m]";
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Split a `[1m]` context-variant suffix off a query ("opus[1m]" →
|
|
78
|
+
* { stem: "opus", oneMillion: true }). Claude Code accepts the suffix on
|
|
79
|
+
* any alias or id; the SDK's `supportedModels()` list does not enumerate
|
|
80
|
+
* the suffixed forms, so the catalog can't match them directly.
|
|
81
|
+
*/
|
|
82
|
+
function splitOneMillion(query: string): { stem: string; oneMillion: boolean } {
|
|
83
|
+
const trimmed = query.trim();
|
|
84
|
+
if (trimmed.toLowerCase().endsWith(ONE_MILLION_SUFFIX)) {
|
|
81
85
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
storedValue: model.id,
|
|
86
|
+
stem: trimmed.slice(0, -ONE_MILLION_SUFFIX.length).trim(),
|
|
87
|
+
oneMillion: true,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
return { stem: trimmed, oneMillion: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Exact-match a query against the registry: its id/alias directly, or —
|
|
95
|
+
* for a `[1m]` query the registry doesn't list — the base model, returned
|
|
96
|
+
* as a 1M variant whose id keeps the suffix so the SDK actually selects
|
|
97
|
+
* the 1M context window.
|
|
98
|
+
*
|
|
99
|
+
* The SDK reports no context-window metadata per model, so there's no
|
|
100
|
+
* evidence to reject a `[1m]` request on; the binary is the authority.
|
|
101
|
+
* The one form refused is `default[1m]`: "default" is a moving target,
|
|
102
|
+
* not an alias Claude Code suffixes.
|
|
103
|
+
*/
|
|
104
|
+
function resolveExactUnified(query: string): UnifiedModelInfo | undefined {
|
|
105
|
+
const direct = getModel(resolveModelId(query));
|
|
106
|
+
if (direct) return toUnified(direct);
|
|
107
|
+
|
|
108
|
+
const { stem, oneMillion } = splitOneMillion(query);
|
|
109
|
+
if (!oneMillion || !stem) return undefined;
|
|
110
|
+
const baseId = resolveModelId(stem);
|
|
111
|
+
const base = getModel(baseId);
|
|
112
|
+
if (!base || stem.toLowerCase() === "default") return undefined;
|
|
113
|
+
|
|
114
|
+
// Pass the SDK a form it knows: the canonical id when it's concrete, the
|
|
115
|
+
// user's own stem when the canonical is the "default" alias (e.g. "opus"
|
|
116
|
+
// folds into "default" when default currently serves Opus).
|
|
117
|
+
const sdkStem = baseId === "default" ? stem : baseId;
|
|
118
|
+
const displayName = /\(1m context\)/i.test(base.displayName)
|
|
119
|
+
? base.displayName
|
|
120
|
+
: `${base.displayName} (1M context)`;
|
|
121
|
+
return {
|
|
122
|
+
...toUnified(base),
|
|
123
|
+
id: `${sdkStem}${ONE_MILLION_SUFFIX}`,
|
|
124
|
+
displayName,
|
|
125
|
+
contextWindow: 1_000_000,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resolveModel(
|
|
130
|
+
query: string,
|
|
131
|
+
): Promise<UnifiedModelResolution> {
|
|
132
|
+
const exact = resolveExactUnified(query);
|
|
133
|
+
if (exact) {
|
|
134
|
+
return { kind: "exact", model: exact, storedValue: exact.id };
|
|
135
|
+
}
|
|
87
136
|
|
|
88
137
|
// No exact match -- try a substring search across display names and aliases
|
|
89
138
|
const allModels = getModels(PROVIDER_ID);
|
|
@@ -112,9 +161,7 @@ export async function resolveModel(
|
|
|
112
161
|
export async function getModelInfo(
|
|
113
162
|
id: string,
|
|
114
163
|
): Promise<UnifiedModelInfo | undefined> {
|
|
115
|
-
|
|
116
|
-
const model = getModel(canonicalId);
|
|
117
|
-
return model ? toUnified(model) : undefined;
|
|
164
|
+
return resolveExactUnified(id);
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
export async function getSettingsPresentation(
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* wiring, which does the same thing privately). Alerts raised before that
|
|
8
8
|
* — early boot is exactly when restore reports and security alerts fire —
|
|
9
9
|
* are held in a small bounded queue and flushed, oldest first, the moment
|
|
10
|
-
* a notifier is wired.
|
|
10
|
+
* a notifier is wired. An alert carrying a key (operator alerts do) is
|
|
11
|
+
* deduplicated while it waits: a re-raise replaces the queued copy instead
|
|
12
|
+
* of queueing another, and a key that resolves before anyone could hear it
|
|
13
|
+
* is withdrawn outright. With nothing ever wired (tests, terminal mode with
|
|
11
14
|
* no admin) they stay a log line; nothing throws.
|
|
12
15
|
*
|
|
13
16
|
* First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
|
|
@@ -25,7 +28,14 @@ let deliver: Deliver | null = null;
|
|
|
25
28
|
/** Most alerts held while no notifier is wired; the oldest are dropped past it. */
|
|
26
29
|
export const ADMIN_NOTIFY_QUEUE_MAX = 20;
|
|
27
30
|
|
|
28
|
-
type Pending = {
|
|
31
|
+
type Pending = {
|
|
32
|
+
text: string;
|
|
33
|
+
at: number;
|
|
34
|
+
/** Dedup key (operator alert key); unkeyed alerts never coalesce. */
|
|
35
|
+
key?: string;
|
|
36
|
+
/** Re-raises folded into this entry while it waited. */
|
|
37
|
+
repeats: number;
|
|
38
|
+
};
|
|
29
39
|
const pending: Pending[] = [];
|
|
30
40
|
let droppedWhileUnwired = 0;
|
|
31
41
|
let flushing: Promise<void> | null = null;
|
|
@@ -68,6 +78,7 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
68
78
|
batch.unshift({
|
|
69
79
|
text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
|
|
70
80
|
at: Date.now(),
|
|
81
|
+
repeats: 0,
|
|
71
82
|
});
|
|
72
83
|
}
|
|
73
84
|
log("notify", `Flushing ${batch.length} queued admin alert(s)`);
|
|
@@ -79,7 +90,10 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
79
90
|
continue;
|
|
80
91
|
}
|
|
81
92
|
const ageS = Math.round((Date.now() - item.at) / 1000);
|
|
82
|
-
const
|
|
93
|
+
const repeated =
|
|
94
|
+
item.repeats > 0 ? `\n(raised ${item.repeats + 1}× while starting)` : "";
|
|
95
|
+
const text =
|
|
96
|
+
(ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text) + repeated;
|
|
83
97
|
try {
|
|
84
98
|
await fn(text);
|
|
85
99
|
log("notify", `Admin notified (queued): ${preview(item.text)}`);
|
|
@@ -95,6 +109,17 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
function enqueue(item: Pending): void {
|
|
112
|
+
if (item.key !== undefined) {
|
|
113
|
+
const i = pending.findIndex((p) => p.key === item.key);
|
|
114
|
+
if (i >= 0) {
|
|
115
|
+
// Same fault raised again while waiting: keep one entry, newest text,
|
|
116
|
+
// original timestamp (so the "delayed" note stays honest).
|
|
117
|
+
const prior = pending[i];
|
|
118
|
+
prior.text = item.text;
|
|
119
|
+
prior.repeats += item.repeats + 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
98
123
|
pending.push(item);
|
|
99
124
|
while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
|
|
100
125
|
const lost = pending.shift();
|
|
@@ -111,14 +136,29 @@ function preview(text: string): string {
|
|
|
111
136
|
return text.slice(0, 80).replace(/\n/g, " ");
|
|
112
137
|
}
|
|
113
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Withdraw a queued, not-yet-delivered alert by key (its fault cleared
|
|
141
|
+
* before a notifier was wired). Returns whether one was withdrawn.
|
|
142
|
+
*/
|
|
143
|
+
export function withdrawAdminNotification(key: string): boolean {
|
|
144
|
+
const i = pending.findIndex((p) => p.key === key);
|
|
145
|
+
if (i < 0) return false;
|
|
146
|
+
const [gone] = pending.splice(i, 1);
|
|
147
|
+
log("notify", `Withdrew queued admin alert ${key}: ${preview(gone.text)}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
114
151
|
/**
|
|
115
152
|
* Send `text` to the admin chat. Never throws; returns whether it was
|
|
116
153
|
* delivered now (false = failed, or queued because no notifier is wired
|
|
117
|
-
* yet — it is sent when one is).
|
|
154
|
+
* yet — it is sent when one is). `key` deduplicates while queued.
|
|
118
155
|
*/
|
|
119
|
-
export async function notifyAdmin(
|
|
156
|
+
export async function notifyAdmin(
|
|
157
|
+
text: string,
|
|
158
|
+
key?: string,
|
|
159
|
+
): Promise<boolean> {
|
|
120
160
|
if (!deliver) {
|
|
121
|
-
enqueue({ text, at: Date.now() });
|
|
161
|
+
enqueue({ text, at: Date.now(), key, repeats: 0 });
|
|
122
162
|
logWarn(
|
|
123
163
|
"notify",
|
|
124
164
|
`No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { log, logWarn } from "../../util/log.js";
|
|
18
|
-
import { notifyAdmin } from "./admin-notify.js";
|
|
18
|
+
import { notifyAdmin, withdrawAdminNotification } from "./admin-notify.js";
|
|
19
19
|
|
|
20
20
|
export type AlertSeverity = "warn" | "error" | "critical";
|
|
21
21
|
|
|
@@ -39,7 +39,8 @@ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
|
|
|
39
39
|
const active = new Map<string, ActiveAlert>();
|
|
40
40
|
let cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
41
41
|
let enabled = true;
|
|
42
|
-
|
|
42
|
+
type Send = (text: string, key?: string) => Promise<unknown>;
|
|
43
|
+
let send: Send = notifyAdmin;
|
|
43
44
|
|
|
44
45
|
/** Apply operator settings (config `alerts`). */
|
|
45
46
|
export function configureAlerts(opts: {
|
|
@@ -86,7 +87,9 @@ export function raiseAlert(
|
|
|
86
87
|
// Nobody heard it: don't let the cooldown swallow the next raise.
|
|
87
88
|
if (active.get(key) === entry) entry.lastSentAt = 0;
|
|
88
89
|
};
|
|
89
|
-
|
|
90
|
+
// The key lets a still-queued copy (no notifier wired yet) be replaced by
|
|
91
|
+
// this raise rather than queued twice.
|
|
92
|
+
void send(`${ICON[severity]} ${message}${repeat}`, key).then((ok) => {
|
|
90
93
|
if (ok === false) undelivered();
|
|
91
94
|
}, undelivered);
|
|
92
95
|
}
|
|
@@ -99,6 +102,9 @@ export function resolveAlert(key: string, message?: string): void {
|
|
|
99
102
|
const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
|
|
100
103
|
log("alert", `resolved ${key} after ${mins} min`);
|
|
101
104
|
if (!enabled) return;
|
|
105
|
+
// Still queued for a notifier that never got to send it: withdraw it, and
|
|
106
|
+
// there is nothing to announce a recovery from.
|
|
107
|
+
if (withdrawAdminNotification(key)) return;
|
|
102
108
|
void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
|
|
103
109
|
() => {},
|
|
104
110
|
);
|
|
@@ -120,9 +126,7 @@ export function activeAlerts(): ReadonlyArray<{
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** Test seam: reset state and swap the delivery function. */
|
|
123
|
-
export function resetAlertsForTest(
|
|
124
|
-
deliver: (text: string) => Promise<unknown> = notifyAdmin,
|
|
125
|
-
): void {
|
|
129
|
+
export function resetAlertsForTest(deliver: Send = notifyAdmin): void {
|
|
126
130
|
active.clear();
|
|
127
131
|
cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
128
132
|
enabled = true;
|
|
@@ -36,19 +36,20 @@
|
|
|
36
36
|
* 1. `modelByBackend[B]` — per-chat-per-backend pick, if it still
|
|
37
37
|
* validates against B's catalog. Cross-backend orphans surface
|
|
38
38
|
* as `kind: "missing"` and fall through.
|
|
39
|
-
* 2. `
|
|
39
|
+
* 2. `config.backendDefaults[B]` — operator-configured per-backend
|
|
40
|
+
* default in `talon.json`.
|
|
41
|
+
* 3. `config.model` — only when B is the global chat-role backend
|
|
42
|
+
* (`config.backend === B`).
|
|
43
|
+
* Operator picks (2/3) must validate against B's catalog when B
|
|
44
|
+
* has a canonical default to fall back to; an unknown pin falls
|
|
45
|
+
* through to step 4 (what the boot-time model audit warns about).
|
|
46
|
+
* 4. `backend.models?.getDefaultModelId()` — backend's canonical default.
|
|
40
47
|
* Codex picks auth-aware (`gpt-5-codex` on API key, `gpt-5.5`
|
|
41
48
|
* on ChatGPT OAuth). Claude SDK returns the `"default"` alias.
|
|
42
49
|
* Stock OpenAI Agents returns a constant.
|
|
43
50
|
* Catalog-driven backends without a canonical (Kilo, OpenCode,
|
|
44
51
|
* OpenAI Agents on OpenRouter / custom OpenAI-compatible) do
|
|
45
|
-
* NOT implement this
|
|
46
|
-
* 3. `config.backendDefaults[B]` — operator-configured per-backend
|
|
47
|
-
* default in `talon.json`. Escape hatch for "no canonical"
|
|
48
|
-
* backends.
|
|
49
|
-
* 4. `config.model` — only when B is the global chat-role backend
|
|
50
|
-
* (`config.backend === B`). Back-compat for installs that
|
|
51
|
-
* predate `backendDefaults`.
|
|
52
|
+
* NOT implement this.
|
|
52
53
|
* 5. `null` → UI renders "No model selected", send guard refuses
|
|
53
54
|
* with a "use /model to pick one" reply.
|
|
54
55
|
*
|
|
@@ -56,6 +57,10 @@
|
|
|
56
57
|
* is called. Only `kind: "exact"` with `selectable: true` honours the
|
|
57
58
|
* stored override. Anything else falls through to step 2.
|
|
58
59
|
*
|
|
60
|
+
* Operator config ranks above the canonical so a pinned `config.model`
|
|
61
|
+
* is actually honoured (it used to lose to Claude's `"default"`, so a
|
|
62
|
+
* claude install pinned to any model silently ran the default).
|
|
63
|
+
*
|
|
59
64
|
* Backends with no `resolveModel` (rare — defensive fallback only)
|
|
60
65
|
* have their stored override returned verbatim — no way to validate.
|
|
61
66
|
*/
|
|
@@ -157,8 +162,8 @@ async function runChain(
|
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
// ──
|
|
161
|
-
//
|
|
165
|
+
// ── Steps 2-5: backendDefaults → config.model (chat-role only) →
|
|
166
|
+
// backend canonical → null
|
|
162
167
|
return stepsTwoThroughFive(backend, backendId, config, null);
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -196,57 +201,61 @@ async function stepsTwoThroughFive(
|
|
|
196
201
|
config: TalonConfig,
|
|
197
202
|
fallbackSourceOverride: "override-invalid-fallback" | null,
|
|
198
203
|
): Promise<{ model: string | null; source: ActiveModelSource }> {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const withSource = (model: string, source: ActiveModelSource) => ({
|
|
205
|
+
model,
|
|
206
|
+
source: fallbackSourceOverride ?? source,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const operatorPick = pickOperatorDefault(backendId, config);
|
|
210
|
+
const canonical = backend?.models ? await safeBackendDefault(backend) : null;
|
|
211
|
+
|
|
212
|
+
// Operator config (steps 2/3) beats the backend canonical (step 4): a
|
|
213
|
+
// pinned `config.model` is an explicit choice, and letting the canonical
|
|
214
|
+
// win meant a claude install pinned to e.g. "opus[1m]" silently ran
|
|
215
|
+
// "default" on every turn. The pin must still validate — a withdrawn id
|
|
216
|
+
// falls back to the canonical, exactly what the boot-time model audit
|
|
217
|
+
// warns about. Without a canonical to fall back to, the pin is returned
|
|
218
|
+
// unvalidated (catalog-driven backends with no default, unchanged).
|
|
219
|
+
if (operatorPick) {
|
|
220
|
+
if (!canonical) return withSource(operatorPick.model, operatorPick.source);
|
|
221
|
+
if (await validateModelOnBackend(backend, operatorPick.model)) {
|
|
222
|
+
return withSource(operatorPick.model, operatorPick.source);
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
// Step
|
|
226
|
+
// Step 4: backend.models.getDefaultModelId()
|
|
227
|
+
if (canonical) return withSource(canonical, "backend-canonical");
|
|
228
|
+
|
|
229
|
+
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
230
|
+
return { model: null, source: "none" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The operator-configured default for a backend, if any:
|
|
235
|
+
* - `config.backendDefaults[B]`;
|
|
236
|
+
* - else `config.model` — only when B is the global chat-role backend
|
|
237
|
+
* (`config.backend === B`), or when no backend id is known at all
|
|
238
|
+
* (pre-bootstrap callers passing null).
|
|
239
|
+
*/
|
|
240
|
+
function pickOperatorDefault(
|
|
241
|
+
backendId: string | null,
|
|
242
|
+
config: TalonConfig,
|
|
243
|
+
): { model: string; source: ActiveModelSource } | null {
|
|
211
244
|
if (backendId && config.backendDefaults) {
|
|
212
245
|
const operatorDefault = config.backendDefaults[backendId];
|
|
213
246
|
if (operatorDefault && operatorDefault.length > 0) {
|
|
214
|
-
return {
|
|
215
|
-
model: operatorDefault,
|
|
216
|
-
source: fallbackSourceOverride ?? "config-backend-defaults",
|
|
217
|
-
};
|
|
247
|
+
return { model: operatorDefault, source: "config-backend-defaults" };
|
|
218
248
|
}
|
|
219
249
|
}
|
|
220
|
-
|
|
221
|
-
// Step 4: legacy config.model — only for the global chat-role backend
|
|
250
|
+
const modelAppliesHere = !backendId || backendId === config.backend;
|
|
222
251
|
if (
|
|
223
|
-
|
|
224
|
-
backendId === config.backend &&
|
|
252
|
+
modelAppliesHere &&
|
|
225
253
|
typeof config.model === "string" &&
|
|
226
254
|
config.model.length > 0
|
|
227
255
|
) {
|
|
228
|
-
return {
|
|
229
|
-
model: config.model,
|
|
230
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
231
|
-
};
|
|
256
|
+
return { model: config.model, source: "config-legacy-global" };
|
|
232
257
|
}
|
|
233
|
-
|
|
234
|
-
// Step 4b: even without a backendId, honour config.model if no
|
|
235
|
-
// backend is bound at all (callers passing null for both — rare,
|
|
236
|
-
// typically pre-bootstrap code paths).
|
|
237
|
-
if (
|
|
238
|
-
!backendId &&
|
|
239
|
-
typeof config.model === "string" &&
|
|
240
|
-
config.model.length > 0
|
|
241
|
-
) {
|
|
242
|
-
return {
|
|
243
|
-
model: config.model,
|
|
244
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
249
|
-
return { model: null, source: "none" };
|
|
258
|
+
return null;
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
async function validateModelOnBackend(
|
|
@@ -98,6 +98,15 @@ function registerPluginInstance(
|
|
|
98
98
|
return loaded;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Run a plugin's init, waiting at most `timeoutMs` for it before boot moves
|
|
103
|
+
* on. The deadline bounds how long boot waits, not the init itself: a
|
|
104
|
+
* plugin stays registered either way (its tools are served from
|
|
105
|
+
* `mcpServer` whether or not init finished), so an init that outlives the
|
|
106
|
+
* deadline keeps running and, when it does finish, clears the alert the
|
|
107
|
+
* timeout raised — a slow handshake on a busy boot is a delay, not a
|
|
108
|
+
* failure that lingers until the next restart.
|
|
109
|
+
*/
|
|
101
110
|
export async function initPluginWithTimeout(
|
|
102
111
|
plugin: TalonPlugin,
|
|
103
112
|
config: Record<string, unknown>,
|
|
@@ -108,28 +117,54 @@ export async function initPluginWithTimeout(
|
|
|
108
117
|
if (!plugin.init) return;
|
|
109
118
|
|
|
110
119
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
-
|
|
112
120
|
const alertKey = `plugin.${plugin.name}`;
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
// Started (and timed) only here, when this plugin's own init begins.
|
|
123
|
+
const init = Promise.resolve().then(() => plugin.init!(config));
|
|
124
|
+
const TIMED_OUT = Symbol("timed out");
|
|
125
|
+
|
|
113
126
|
try {
|
|
114
|
-
await Promise.race([
|
|
115
|
-
|
|
116
|
-
new Promise<
|
|
117
|
-
timer = setTimeout(() =>
|
|
118
|
-
reject(
|
|
119
|
-
new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
|
|
120
|
-
);
|
|
121
|
-
}, timeoutMs);
|
|
127
|
+
const outcome = await Promise.race([
|
|
128
|
+
init,
|
|
129
|
+
new Promise<typeof TIMED_OUT>((settle) => {
|
|
130
|
+
timer = setTimeout(() => settle(TIMED_OUT), timeoutMs);
|
|
122
131
|
timer.unref?.();
|
|
123
132
|
}),
|
|
124
133
|
]);
|
|
125
|
-
|
|
134
|
+
if (outcome !== TIMED_OUT) {
|
|
135
|
+
resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const message = `${timeoutLabel} timed out after ${timeoutMs / 1000}s`;
|
|
139
|
+
logError("plugin", `${errorPrefix}: ${message}; still waiting for it`);
|
|
140
|
+
raiseAlert(
|
|
141
|
+
alertKey,
|
|
142
|
+
`Plugin "${plugin.name}" failed to initialise: ${message}. Its tools stay registered; this clears itself if init finishes late.`,
|
|
143
|
+
);
|
|
144
|
+
void init.then(
|
|
145
|
+
() => {
|
|
146
|
+
// Reloaded or unloaded meanwhile: this instance's verdict is moot.
|
|
147
|
+
if (registry.getByName(plugin.name)?.plugin !== plugin) return;
|
|
148
|
+
const took = Math.round((Date.now() - startedAt) / 1000);
|
|
149
|
+
log("plugin", `${plugin.name} init finished late (${took}s)`);
|
|
150
|
+
resolveAlert(
|
|
151
|
+
alertKey,
|
|
152
|
+
`Plugin "${plugin.name}" finished initialising late (${took}s).`,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
(err: unknown) =>
|
|
156
|
+
logError(
|
|
157
|
+
"plugin",
|
|
158
|
+
`${errorPrefix} (after timing out): ${err instanceof Error ? err.message : err}`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
126
161
|
} catch (err) {
|
|
127
162
|
logError(
|
|
128
163
|
"plugin",
|
|
129
164
|
`${errorPrefix}: ${err instanceof Error ? err.message : err}`,
|
|
130
165
|
);
|
|
131
|
-
//
|
|
132
|
-
//
|
|
166
|
+
// An init that threw won't retry on its own: it needs a reload or
|
|
167
|
+
// restart, so this needs no threshold.
|
|
133
168
|
raiseAlert(
|
|
134
169
|
alertKey,
|
|
135
170
|
`Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
|
|
@@ -54,8 +54,8 @@ export function resolveMediaInput(
|
|
|
54
54
|
|
|
55
55
|
/** Telegram's hard limit on media captions, counted after entity parsing. */
|
|
56
56
|
export const TELEGRAM_MAX_CAPTION = 1024;
|
|
57
|
-
/**
|
|
58
|
-
const
|
|
57
|
+
/** Below this head budget the splitter is fighting pathological markup; give up. */
|
|
58
|
+
const MIN_CAPTION_HEAD = 64;
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Drop every `<...>` tag in one linear pass. Only used to *measure* the
|
|
@@ -104,35 +104,76 @@ export function visibleCaptionText(html: string): string {
|
|
|
104
104
|
export type FittedCaption = {
|
|
105
105
|
caption?: string;
|
|
106
106
|
parse_mode?: "HTML";
|
|
107
|
-
/**
|
|
107
|
+
/** Caption text that did not fit, to deliver as follow-up message(s). */
|
|
108
108
|
overflow?: string;
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
+
/** Visible (Telegram-counted) length of a markdown caption once rendered. */
|
|
112
|
+
export function captionUnits(markdown: string): number {
|
|
113
|
+
// Telegram counts UTF-16 code units, which is what .length measures.
|
|
114
|
+
return visibleCaptionText(markdownToTelegramHtml(markdown)).length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Split a markdown caption into the leading part that fits `max` visible
|
|
119
|
+
* units and the remainder. Splits the *markdown source* with the shared
|
|
120
|
+
* message splitter (paragraph → newline → space boundaries, surrogate-safe,
|
|
121
|
+
* ``` fences closed/reopened), so the head is rendered to HTML on its own
|
|
122
|
+
* and can never strand a tag or entity. Returns null if no clean head fits.
|
|
123
|
+
*/
|
|
124
|
+
export function splitCaption(
|
|
125
|
+
text: string,
|
|
126
|
+
max: number = TELEGRAM_MAX_CAPTION,
|
|
127
|
+
): { head: string; rest: string } | null {
|
|
128
|
+
// Rendering usually shrinks markdown (markers and link URLs vanish), so
|
|
129
|
+
// the first try nearly always fits; shrink the budget if it does not.
|
|
130
|
+
for (let budget = max; budget >= MIN_CAPTION_HEAD;) {
|
|
131
|
+
const chunks = splitMessage(text, budget);
|
|
132
|
+
const head = chunks[0] ?? "";
|
|
133
|
+
if (head.trim() && captionUnits(head) <= max) {
|
|
134
|
+
const at = text.indexOf(head);
|
|
135
|
+
// The splitter only trims at boundaries, so the head is normally a
|
|
136
|
+
// verbatim prefix; when it closed a ``` fence it is not, and the
|
|
137
|
+
// splitter's own reopened chunks carry the remainder instead.
|
|
138
|
+
const rest =
|
|
139
|
+
at >= 0
|
|
140
|
+
? text.slice(at + head.length).replace(/^\s+/, "")
|
|
141
|
+
: chunks.slice(1).join("\n\n");
|
|
142
|
+
return { head, rest };
|
|
143
|
+
}
|
|
144
|
+
budget = Math.floor(budget * 0.8);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
/**
|
|
112
150
|
* Convert a markdown caption to what Telegram accepts. Captions over 1024
|
|
113
151
|
* visible characters are rejected outright ("message caption is too long"),
|
|
114
|
-
* losing the media along with them — so an oversized caption is
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* send as a normal, chunked text message.
|
|
152
|
+
* losing the media along with them — so an oversized caption is split: the
|
|
153
|
+
* leading part that fits rides on the media (still formatted), and the rest
|
|
154
|
+
* is returned as `overflow` for the caller to send as follow-up text.
|
|
118
155
|
*/
|
|
119
156
|
export function fitCaption(raw: unknown): FittedCaption {
|
|
120
157
|
if (!raw) return {};
|
|
121
158
|
const text = String(raw);
|
|
122
159
|
const html = markdownToTelegramHtml(text);
|
|
123
|
-
|
|
124
|
-
// Telegram counts UTF-16 code units, which is what .length measures.
|
|
125
|
-
if (visible.length <= TELEGRAM_MAX_CAPTION)
|
|
160
|
+
if (visibleCaptionText(html).length <= TELEGRAM_MAX_CAPTION)
|
|
126
161
|
return { caption: html, parse_mode: "HTML" };
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
const split = splitCaption(text);
|
|
163
|
+
if (split) {
|
|
164
|
+
return {
|
|
165
|
+
caption: markdownToTelegramHtml(split.head),
|
|
166
|
+
parse_mode: "HTML",
|
|
167
|
+
...(split.rest ? { overflow: split.rest } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// No clean boundary fits (pathological markup): send the media bare and
|
|
171
|
+
// deliver the whole caption as text rather than cut through it.
|
|
172
|
+
return { overflow: text };
|
|
132
173
|
}
|
|
133
174
|
|
|
134
175
|
/**
|
|
135
|
-
* Deliver the
|
|
176
|
+
* Deliver the part of a caption that did not fit, threaded as a reply
|
|
136
177
|
* to the media it belongs to. Best-effort: the media already landed, so a
|
|
137
178
|
* failure here is reported as a warning rather than failing the send.
|
|
138
179
|
*/
|
|
@@ -167,7 +208,7 @@ async function sendCaptionOverflow(
|
|
|
167
208
|
`Caption overflow follow-up failed (chat=${chatId}): ${msg}`,
|
|
168
209
|
);
|
|
169
210
|
return {
|
|
170
|
-
warning: `Media sent
|
|
211
|
+
warning: `Media sent, but the rest of its caption (past Telegram's ${TELEGRAM_MAX_CAPTION}-char limit) failed to send: ${msg}`,
|
|
171
212
|
};
|
|
172
213
|
}
|
|
173
214
|
}
|
|
@@ -176,8 +217,8 @@ function overflowResult(
|
|
|
176
217
|
r: { message_ids: number[] } | { warning: string },
|
|
177
218
|
): Record<string, unknown> {
|
|
178
219
|
return "warning" in r
|
|
179
|
-
? {
|
|
180
|
-
: {
|
|
220
|
+
? { caption_split: true, warning: r.warning }
|
|
221
|
+
: { caption_split: true, caption_message_ids: r.message_ids };
|
|
181
222
|
}
|
|
182
223
|
|
|
183
224
|
const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
richMessagesAvailable,
|
|
22
22
|
} from "./rich-messages.js";
|
|
23
23
|
import { toPositiveId } from "./coerce.js";
|
|
24
|
+
import { captionUnits, TELEGRAM_MAX_CAPTION } from "./media.js";
|
|
24
25
|
import { resolveThreadId } from "../topics.js";
|
|
25
26
|
import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
|
|
26
27
|
|
|
@@ -260,6 +261,14 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
260
261
|
// Media messages have captions, not text — editMessageText on them fails
|
|
261
262
|
// with "there is no text in the message to edit".
|
|
262
263
|
if (body.is_caption === true) {
|
|
264
|
+
// An edit cannot spill into a follow-up message the way a send does,
|
|
265
|
+
// so refuse up front with guidance rather than a raw Bot API 400.
|
|
266
|
+
const units = captionUnits(text);
|
|
267
|
+
if (units > TELEGRAM_MAX_CAPTION)
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: `Caption too long (${units} chars, max ${TELEGRAM_MAX_CAPTION}) — shorten it, or send the rest as a separate message`,
|
|
271
|
+
};
|
|
263
272
|
await withRetry(async () => {
|
|
264
273
|
try {
|
|
265
274
|
await bot.api.editMessageCaption(chatId, Number(body.message_id), {
|