toolroll 0.9.10 → 0.9.11
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/CHANGELOG.md +7 -0
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery.js +4 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +108 -36
- package/dist/serve.js +9 -2
- package/dist/store.d.ts +15 -1
- package/dist/store.js +30 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram.d.ts +14 -0
- package/dist/telegram.js +71 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.11 — 2026-10-02
|
|
4
|
+
|
|
5
|
+
- **The Telegram bot always answers.** Replies and notifications go out from
|
|
6
|
+
the service itself, never from inside a build. If it can't answer, it says
|
|
7
|
+
why in plain words, and a session mix-up starts a fresh conversation instead
|
|
8
|
+
of refusing.
|
|
9
|
+
|
|
3
10
|
## 0.9.10 — 2026-10-02
|
|
4
11
|
|
|
5
12
|
- **Chat reads like a teammate.** Short messages, outcome first, with real
|
package/dist/chat-channel.d.ts
CHANGED
|
@@ -12,18 +12,18 @@ export type ResolvedMate = {
|
|
|
12
12
|
};
|
|
13
13
|
} | {
|
|
14
14
|
ok: false;
|
|
15
|
-
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "
|
|
15
|
+
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
|
|
16
16
|
said: string | null;
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* The session and thread a paired phone speaks in: the approver's live
|
|
20
20
|
* session when it is compatible (same generation, same membership
|
|
21
|
-
* credential, same ceiling), else one minted under the very terms
|
|
22
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
-
* ceiling digest)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
22
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
24
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
25
|
+
* always gets an answer — except while a turn is still running there,
|
|
26
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveChannelMate(store: Store, binding: {
|
|
29
29
|
approver: string;
|
|
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
|
|
|
34
34
|
* chat as well, so the task shows everything asked of it wherever it was
|
|
35
35
|
* asked. Best effort: the phone conversation never depends on it. */
|
|
36
36
|
export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
|
|
37
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
38
|
+
export declare function couldNotAnswerText(reason: string): string;
|
|
37
39
|
export declare function tooLongText(length: number): string;
|
|
38
40
|
/** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
|
|
39
41
|
export declare function whichTaskText(titles: readonly string[]): string;
|
package/dist/chat-channel.js
CHANGED
|
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
|
|
|
25
25
|
/**
|
|
26
26
|
* The session and thread a paired phone speaks in: the approver's live
|
|
27
27
|
* session when it is compatible (same generation, same membership
|
|
28
|
-
* credential, same ceiling), else one minted under the very terms
|
|
29
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
-
* ceiling digest)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
29
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
31
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
32
|
+
* always gets an answer — except while a turn is still running there,
|
|
33
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
34
34
|
*/
|
|
35
35
|
export function resolveChannelMate(store, binding, repos, now) {
|
|
36
36
|
const config = store.getChatConfig();
|
|
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
60
60
|
return { ok: false, reason: "unpaired", said: null };
|
|
61
61
|
const who = verified.who;
|
|
62
62
|
const credentialKey = subscriptionCredentialKey(config.provider);
|
|
63
|
-
const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
|
|
64
63
|
let session = store.activeMateSession(who.name);
|
|
65
|
-
|
|
64
|
+
const liveThread = store.liveMateThreadFor(who.name);
|
|
65
|
+
const sessionMismatch = session !== null &&
|
|
66
66
|
(session.approverGeneration !== who.generation ||
|
|
67
67
|
session.credentialKey !== credentialKey ||
|
|
68
|
-
session.ceilingDigest !== who.ceilingDigest)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (session === null) {
|
|
68
|
+
session.ceilingDigest !== who.ceilingDigest);
|
|
69
|
+
const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
|
|
70
|
+
// Replacing either would end a turn still running on the computer.
|
|
71
|
+
if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
|
|
72
|
+
return { ok: false, reason: "busy", said: null };
|
|
73
|
+
if (session === null || sessionMismatch) {
|
|
75
74
|
const termsDigest = createHash("sha256")
|
|
76
75
|
.update(`0\n${who.ceilingDigest}`)
|
|
77
76
|
.digest("hex");
|
|
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
85
84
|
}, now);
|
|
86
85
|
session = store.getMateSession(id);
|
|
87
86
|
if (session === null)
|
|
88
|
-
return { ok: false, reason: "session
|
|
87
|
+
return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
|
|
89
88
|
}
|
|
90
|
-
|
|
89
|
+
// openMateThread closes a thread kept under another ceiling.
|
|
90
|
+
const thread = threadMismatch || liveThread === null
|
|
91
|
+
? store.openMateThread(who.name, who.ceilingDigest, now).thread
|
|
92
|
+
: liveThread;
|
|
91
93
|
return {
|
|
92
94
|
ok: true,
|
|
93
95
|
who,
|
|
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
|
|
|
113
115
|
// The task chat is a copy; the phone's own conversation already has it.
|
|
114
116
|
}
|
|
115
117
|
}
|
|
118
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
119
|
+
export function couldNotAnswerText(reason) {
|
|
120
|
+
return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
|
|
121
|
+
}
|
|
116
122
|
export function tooLongText(length) {
|
|
117
123
|
return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
|
|
118
124
|
}
|
package/dist/chat-delivery.js
CHANGED
|
@@ -202,6 +202,10 @@ export async function processChatEvent(options) {
|
|
|
202
202
|
return true;
|
|
203
203
|
}
|
|
204
204
|
const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
|
|
205
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
206
|
+
state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
205
209
|
if (!resolved.ok) {
|
|
206
210
|
state.plan(event.id, [
|
|
207
211
|
{
|
package/dist/operate.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type OperateOptions = {
|
|
|
50
50
|
gitRunner?: CommandRunner;
|
|
51
51
|
/** Injected by tests: the Telegram Bot API. Production dials the real one. */
|
|
52
52
|
telegramTransport?: TelegramTransport;
|
|
53
|
+
/** Injected by tests: the environment checked for a fenced network before Telegram is reached (default: this process's, with the real transport). */
|
|
54
|
+
telegramEnv?: Record<string, string | undefined>;
|
|
53
55
|
publishExec?: PublishExec;
|
|
54
56
|
/** Injected by tests: the stop fence a watch would set. */
|
|
55
57
|
shouldStop?: () => boolean;
|
package/dist/operate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
1
2
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
2
3
|
import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
|
|
3
4
|
import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
|
|
@@ -138,7 +139,7 @@ import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch
|
|
|
138
139
|
import { createDecisionServer } from "./serve.js";
|
|
139
140
|
import { readTailnetNames } from "./tailnet.js";
|
|
140
141
|
import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
|
|
141
|
-
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
142
|
+
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
142
143
|
import { scanRepo } from "./capscan.js";
|
|
143
144
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
144
145
|
import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
|
|
@@ -820,6 +821,7 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
820
821
|
...(options.agentRunner === undefined ? {} : { agentRunner: options.agentRunner }),
|
|
821
822
|
...(options.gitRunner === undefined ? {} : { gitRunner: options.gitRunner }),
|
|
822
823
|
...(options.telegramTransport === undefined ? {} : { telegramTransport: options.telegramTransport }),
|
|
824
|
+
...(options.telegramEnv === undefined ? {} : { telegramEnv: options.telegramEnv }),
|
|
823
825
|
...(options.publishExec === undefined ? {} : { publishExec: options.publishExec }),
|
|
824
826
|
...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
|
|
825
827
|
...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
|
|
@@ -7288,6 +7290,50 @@ async function daemonCommand(positional, flags, context) {
|
|
|
7288
7290
|
const WATCH_LEASE_MS = 90_000;
|
|
7289
7291
|
const WATCH_HEARTBEAT_MS = 30_000;
|
|
7290
7292
|
/** The start repair runs once per process, not once per project loop. */
|
|
7293
|
+
/**
|
|
7294
|
+
* The Telegram follower: one actor on the wire that sends notifications and
|
|
7295
|
+
* replies and reads what arrives, all over the same transport. Null when no
|
|
7296
|
+
* bot token is saved, or when this process's network is fenced (it says so
|
|
7297
|
+
* once instead of failing every send).
|
|
7298
|
+
*/
|
|
7299
|
+
function startTelegramFollower(args) {
|
|
7300
|
+
const { context, progress, label } = args;
|
|
7301
|
+
const source = loadBotToken(process.env, context.telegramTokenFile);
|
|
7302
|
+
if (source === null)
|
|
7303
|
+
return null;
|
|
7304
|
+
const fence = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
|
|
7305
|
+
if (fence !== null) {
|
|
7306
|
+
progress(networkFenceLine(fence));
|
|
7307
|
+
return null;
|
|
7308
|
+
}
|
|
7309
|
+
const transport = context.telegramTransport ?? createTransport(source.token);
|
|
7310
|
+
// v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
|
|
7311
|
+
const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
|
|
7312
|
+
return followBridge(context.store, {
|
|
7313
|
+
push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
|
|
7314
|
+
readProjects: telegramReadProjects(context),
|
|
7315
|
+
canDeliver: telegramCanDeliver(context, source.token),
|
|
7316
|
+
conversation: telegramConversation(context, args.publicUrl === undefined ? {} : { serverOrigin: args.publicUrl }),
|
|
7317
|
+
botId: source.botId,
|
|
7318
|
+
transport,
|
|
7319
|
+
signal: args.signal,
|
|
7320
|
+
...(args.owner === undefined ? {} : { owner: args.owner }),
|
|
7321
|
+
onCycle: cycle => {
|
|
7322
|
+
// progress(), not write(): under --json this line went to stdout
|
|
7323
|
+
// BESIDE the final envelope — the one stdout contamination in the
|
|
7324
|
+
// watch (round-4 finding 12).
|
|
7325
|
+
progress(`${label} sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
|
|
7326
|
+
((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
|
|
7327
|
+
((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
|
|
7328
|
+
((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
|
|
7329
|
+
((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
|
|
7330
|
+
(cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
|
|
7331
|
+
},
|
|
7332
|
+
}).catch(error => {
|
|
7333
|
+
progress(`${args.died.prefix}the telegram follower died — ${describe(error)}; ${args.died.then}`);
|
|
7334
|
+
return null;
|
|
7335
|
+
});
|
|
7336
|
+
}
|
|
7291
7337
|
let staleStatusesRepaired = false;
|
|
7292
7338
|
async function runWatchLoop(args) {
|
|
7293
7339
|
const { context, flags, runner, token, repo, progress } = args;
|
|
@@ -7366,38 +7412,13 @@ async function runWatchLoop(args) {
|
|
|
7366
7412
|
// moment they arrive, and answering bumps the wake sequence, so the very
|
|
7367
7413
|
// loop below wakes and resumes the freed task — phone to build, seconds.
|
|
7368
7414
|
// The poll lease keeps this the only live poller; a cron `bridge
|
|
7369
|
-
// telegram` overlapping it loses the lease race and reports busy.
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
|
|
7377
|
-
follower = followBridge(store, {
|
|
7378
|
-
push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
|
|
7379
|
-
readProjects: telegramReadProjects(context),
|
|
7380
|
-
canDeliver: telegramCanDeliver(context, followSource.token),
|
|
7381
|
-
conversation: telegramConversation(context, publicUrl === undefined ? {} : { serverOrigin: publicUrl }),
|
|
7382
|
-
botId: followSource.botId,
|
|
7383
|
-
transport,
|
|
7384
|
-
signal: followController.signal,
|
|
7385
|
-
onCycle: cycle => {
|
|
7386
|
-
// progress(), not write(): under --json this line went to stdout
|
|
7387
|
-
// BESIDE the final envelope — the one stdout contamination in the
|
|
7388
|
-
// watch (round-4 finding 12).
|
|
7389
|
-
progress(`watch: bridge sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
|
|
7390
|
-
((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
|
|
7391
|
-
((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
|
|
7392
|
-
((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
|
|
7393
|
-
((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
|
|
7394
|
-
(cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
|
|
7395
|
-
},
|
|
7396
|
-
}).catch(error => {
|
|
7397
|
-
progress(`watch: the telegram follower died — ${describe(error)}; taps wait for the next watch`);
|
|
7398
|
-
return null;
|
|
7399
|
-
});
|
|
7400
|
-
}
|
|
7415
|
+
// telegram` overlapping it loses the lease race and reports busy. Under
|
|
7416
|
+
// `up` the service runs the one follower itself and project loops leave
|
|
7417
|
+
// Telegram to it.
|
|
7418
|
+
const serviceBridge = args.serviceBridge === true;
|
|
7419
|
+
const follower = serviceBridge
|
|
7420
|
+
? null
|
|
7421
|
+
: startTelegramFollower({ context, publicUrl: text(flags, "public-url"), signal: followController.signal, progress, label: "watch: bridge", died: { prefix: "watch: ", then: "taps wait for the next watch" } });
|
|
7401
7422
|
const slackFollower = followSlack({ store, dir: dirname(context.databaseFile), signal: followController.signal,
|
|
7402
7423
|
readProjects: telegramReadProjects(context), evidenceRoot: context.evidenceRoot,
|
|
7403
7424
|
...(context.mateSeams?.subscriptionRunner ? { subscriptionRunner: context.mateSeams.subscriptionRunner } : {}),
|
|
@@ -7690,7 +7711,7 @@ async function runWatchLoop(args) {
|
|
|
7690
7711
|
// pass is the fallback shape for a watch started before a token existed.
|
|
7691
7712
|
if (follower === null && now - lastBridge >= bridgeEveryMs) {
|
|
7692
7713
|
lastBridge = now;
|
|
7693
|
-
const source = loadBotToken(process.env, context.telegramTokenFile);
|
|
7714
|
+
const source = serviceBridge ? null : loadBotToken(process.env, context.telegramTokenFile);
|
|
7694
7715
|
const dir = dirname(context.databaseFile);
|
|
7695
7716
|
// Exactly ONE service carries the pages — the chosen primary, or
|
|
7696
7717
|
// the sensible implicit one. Telegram keeps draining taps and
|
|
@@ -8538,6 +8559,7 @@ async function upCommand(flags, context) {
|
|
|
8538
8559
|
onFollowController: controller => followControllers.push(controller),
|
|
8539
8560
|
containmentLines,
|
|
8540
8561
|
passes: projectPasses,
|
|
8562
|
+
serviceBridge: true,
|
|
8541
8563
|
onReady: () => {
|
|
8542
8564
|
markReady();
|
|
8543
8565
|
if (bind)
|
|
@@ -8557,6 +8579,33 @@ async function upCommand(flags, context) {
|
|
|
8557
8579
|
await ready;
|
|
8558
8580
|
};
|
|
8559
8581
|
await Promise.all(repos.map(repo => launchRepo(repo, false)));
|
|
8582
|
+
// Telegram: one follower for the whole service, never one per project
|
|
8583
|
+
// loop. Replies and notifications go out from it, over the same transport
|
|
8584
|
+
// in the same cycle (Oct 2: a reply sent by a project loop's own follower
|
|
8585
|
+
// failed "fetch failed" while notifications went out). A token saved
|
|
8586
|
+
// while the service runs connects within seconds; a follower that dies
|
|
8587
|
+
// is started again.
|
|
8588
|
+
const telegramController = new AbortController();
|
|
8589
|
+
followControllers.push(telegramController);
|
|
8590
|
+
const telegramSupervisor = (async () => {
|
|
8591
|
+
const pause = (ms) => new Promise(resolvePause => {
|
|
8592
|
+
const timer = setTimeout(done, ms);
|
|
8593
|
+
function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
|
|
8594
|
+
telegramController.signal.addEventListener("abort", done, { once: true });
|
|
8595
|
+
});
|
|
8596
|
+
while (!stopping && !telegramController.signal.aborted) {
|
|
8597
|
+
const follower = startTelegramFollower({
|
|
8598
|
+
context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
|
|
8599
|
+
died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
|
|
8600
|
+
});
|
|
8601
|
+
if (follower !== null)
|
|
8602
|
+
await follower;
|
|
8603
|
+
// Without a token (or on a fenced network) look again shortly; the fence line is said once.
|
|
8604
|
+
if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
|
|
8605
|
+
break;
|
|
8606
|
+
await pause(follower === null ? 2_000 : 5_000);
|
|
8607
|
+
}
|
|
8608
|
+
})();
|
|
8560
8609
|
const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
|
|
8561
8610
|
const rejected = new Set();
|
|
8562
8611
|
const registrySupervisor = (async () => {
|
|
@@ -8691,6 +8740,7 @@ async function upCommand(flags, context) {
|
|
|
8691
8740
|
await stopped;
|
|
8692
8741
|
await registrySupervisor;
|
|
8693
8742
|
await Promise.all([...loopResults.values()]);
|
|
8743
|
+
await telegramSupervisor;
|
|
8694
8744
|
clearInterval(runnerHeartbeat);
|
|
8695
8745
|
if (runTimer !== undefined)
|
|
8696
8746
|
clearTimeout(runTimer);
|
|
@@ -8738,6 +8788,7 @@ async function upCommand(flags, context) {
|
|
|
8738
8788
|
* bridge telegram token [<bot-token>|--clear] set the credential file
|
|
8739
8789
|
* bridge telegram status
|
|
8740
8790
|
* bridge telegram digest [--every 30m|2h|24h | --off] away mode: routine facts batch, decisions still page
|
|
8791
|
+
* bridge telegram retry send unsent assistant replies again now
|
|
8741
8792
|
*
|
|
8742
8793
|
* The bot token comes from ${TOKEN_ENV} or the credential file this command
|
|
8743
8794
|
* writes (0600, beside the database). Cron the pass right after tick; a
|
|
@@ -8754,7 +8805,14 @@ async function bridgeCommand(positional, flags, context) {
|
|
|
8754
8805
|
return demoFence;
|
|
8755
8806
|
const [channel, action] = positional;
|
|
8756
8807
|
if (channel !== "telegram") {
|
|
8757
|
-
return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest]`", EXIT.usage);
|
|
8808
|
+
return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest|retry]`", EXIT.usage);
|
|
8809
|
+
}
|
|
8810
|
+
if (action === "retry") {
|
|
8811
|
+
// Replies already written and fenced on the pairing like every send: this only moves their next attempt to now.
|
|
8812
|
+
const retried = store.retryTelegramReplies(null, clock());
|
|
8813
|
+
return succeed(write, json, "bridge retry", { retried }, () => [
|
|
8814
|
+
retried === 0 ? "No unsent Telegram replies." : `Sending ${retried} Telegram ${retried === 1 ? "reply" : "replies"} again on the next bridge pass.`,
|
|
8815
|
+
]);
|
|
8758
8816
|
}
|
|
8759
8817
|
if (action === "token") {
|
|
8760
8818
|
const value = positional[2];
|
|
@@ -8871,6 +8929,9 @@ async function bridgeCommand(positional, flags, context) {
|
|
|
8871
8929
|
if (source === null) {
|
|
8872
8930
|
return fail(write, json, "bridge", "no-token", `no bot token — set ${TOKEN_ENV}, run \`toolroll bridge telegram token <t>\`, or use the serve settings card`, EXIT.refused);
|
|
8873
8931
|
}
|
|
8932
|
+
const fenced = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
|
|
8933
|
+
if (fenced !== null)
|
|
8934
|
+
return fail(write, json, "bridge", "network-fenced", networkFenceLine(fenced), EXIT.refused);
|
|
8874
8935
|
const transport = context.telegramTransport ?? createTransport(source.token);
|
|
8875
8936
|
// --follow: stay on the wire. One long-poll actor holds the poll lease;
|
|
8876
8937
|
// an answer tapped on a phone lands in seconds instead of at the next
|
|
@@ -9588,7 +9649,18 @@ async function statusCommand(positional, flags, context) {
|
|
|
9588
9649
|
const projects = projectBuildsStatus(context);
|
|
9589
9650
|
const projectsLine = projects.length === 0 ? null
|
|
9590
9651
|
: `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
|
|
9591
|
-
|
|
9652
|
+
// A Telegram reply written but still not sent after two minutes, with its Retry.
|
|
9653
|
+
const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
|
|
9654
|
+
const unsentLine = unsentRepliesLine(unsent);
|
|
9655
|
+
const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
|
|
9656
|
+
return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
|
|
9657
|
+
}
|
|
9658
|
+
function unsentRepliesLine(unsent) {
|
|
9659
|
+
if (unsent.length === 0)
|
|
9660
|
+
return null;
|
|
9661
|
+
const oldest = unsent[0];
|
|
9662
|
+
const what = unsent.length === 1 ? "A Telegram reply hasn't" : `${unsent.length} Telegram replies haven't`;
|
|
9663
|
+
return `${what} been sent since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : ` (${oldest.error})`}. Retry: toolroll bridge telegram retry`;
|
|
9592
9664
|
}
|
|
9593
9665
|
/** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
|
|
9594
9666
|
function projectBuildsStatus(context) {
|
package/dist/serve.js
CHANGED
|
@@ -1674,7 +1674,7 @@ export function createDecisionServer(options) {
|
|
|
1674
1674
|
// separately proves cookie, approver standing and password; instance bot
|
|
1675
1675
|
// settings remain outside this exact allowlist.
|
|
1676
1676
|
if ((request.method === "GET" && path === "/settings/telegram") ||
|
|
1677
|
-
(request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair"].includes(path)))
|
|
1677
|
+
(request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair", "/settings/telegram/retry"].includes(path)))
|
|
1678
1678
|
return true;
|
|
1679
1679
|
// Coding routes require an instance operator, then prove saved ownership and project access.
|
|
1680
1680
|
if (path === "/code" || path.startsWith("/code/"))
|
|
@@ -4066,7 +4066,7 @@ export function createDecisionServer(options) {
|
|
|
4066
4066
|
// The settings view holds no secret (a pairing code only appears in
|
|
4067
4067
|
// the response to Pair, which stays script-free), so it joins the
|
|
4068
4068
|
// workspace like every other settings page.
|
|
4069
|
-
return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf), { chrome: chromeFor(project, "settings") }));
|
|
4069
|
+
return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { now }), { chrome: chromeFor(project, "settings") }));
|
|
4070
4070
|
}
|
|
4071
4071
|
if (url.pathname === "/settings/teams") {
|
|
4072
4072
|
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
@@ -7885,6 +7885,13 @@ export function createDecisionServer(options) {
|
|
|
7885
7885
|
}
|
|
7886
7886
|
return redirect(response, "/settings/slack");
|
|
7887
7887
|
}
|
|
7888
|
+
if (url.pathname === "/settings/telegram/retry") {
|
|
7889
|
+
// The signed-in person's own unsent replies only: already written and fenced on the pairing; this sends them again now.
|
|
7890
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
7891
|
+
return refuse(response, who, 403, "An approver can retry their own replies.", "/settings");
|
|
7892
|
+
store.retryTelegramReplies(who.name, now);
|
|
7893
|
+
return redirect(response, "/settings/telegram");
|
|
7894
|
+
}
|
|
7888
7895
|
if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
|
|
7889
7896
|
// The person's own pairing, under their password: a code minted for
|
|
7890
7897
|
// them alone, or their own chats revoked. Teammates' pairings are
|
package/dist/store.d.ts
CHANGED
|
@@ -7294,7 +7294,7 @@ export declare class Store {
|
|
|
7294
7294
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
7295
7295
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
7296
7296
|
*/
|
|
7297
|
-
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date): TelegramConversation | null;
|
|
7297
|
+
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date, only?: "turns" | "replies"): TelegramConversation | null;
|
|
7298
7298
|
/** Extend a held claim; false means it lapsed and somebody else may hold the row. */
|
|
7299
7299
|
renewTelegramConversation(id: number, owner: string, ttlMs: number, now: Date): boolean;
|
|
7300
7300
|
/**
|
|
@@ -7321,6 +7321,20 @@ export declare class Store {
|
|
|
7321
7321
|
outcome: string;
|
|
7322
7322
|
nextAttemptAt: string;
|
|
7323
7323
|
}, now: Date): boolean;
|
|
7324
|
+
/**
|
|
7325
|
+
* Replies the assistant wrote that still have not reached the person
|
|
7326
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
7327
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
7328
|
+
* written, and the last send error.
|
|
7329
|
+
*/
|
|
7330
|
+
unsentTelegramReplies(botId: string | null, approver: string | null, since: Date): {
|
|
7331
|
+
conversation: number;
|
|
7332
|
+
approver: string;
|
|
7333
|
+
since: string;
|
|
7334
|
+
error: string | null;
|
|
7335
|
+
}[];
|
|
7336
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
7337
|
+
retryTelegramReplies(approver: string | null, now: Date): number;
|
|
7324
7338
|
/**
|
|
7325
7339
|
* Persist the whole outbound half of a turn — every reply part and every
|
|
7326
7340
|
* card, with the cards' tokens already minted — in one transaction, BEFORE
|
package/dist/store.js
CHANGED
|
@@ -21621,14 +21621,18 @@ export class Store {
|
|
|
21621
21621
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
21622
21622
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
21623
21623
|
*/
|
|
21624
|
-
claimTelegramConversation(botId, owner, ttlMs, now) {
|
|
21624
|
+
claimTelegramConversation(botId, owner, ttlMs, now, only) {
|
|
21625
21625
|
return this.transact(() => {
|
|
21626
21626
|
const stamp = now.toISOString();
|
|
21627
|
+
// "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
|
|
21628
|
+
const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
|
|
21629
|
+
const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
|
|
21627
21630
|
const row = this.db
|
|
21628
21631
|
.prepare(`SELECT * FROM telegram_conversation
|
|
21629
21632
|
WHERE bot_id = ? AND state IN ('queued','running')
|
|
21630
21633
|
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
21631
21634
|
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
21635
|
+
${which}
|
|
21632
21636
|
ORDER BY id LIMIT 1`)
|
|
21633
21637
|
.get(botId, stamp, stamp);
|
|
21634
21638
|
if (row === undefined)
|
|
@@ -21686,6 +21690,31 @@ export class Store {
|
|
|
21686
21690
|
.run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
|
|
21687
21691
|
return Number(changes) === 1;
|
|
21688
21692
|
}
|
|
21693
|
+
/**
|
|
21694
|
+
* Replies the assistant wrote that still have not reached the person
|
|
21695
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
21696
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
21697
|
+
* written, and the last send error.
|
|
21698
|
+
*/
|
|
21699
|
+
unsentTelegramReplies(botId, approver, since) {
|
|
21700
|
+
return this.db
|
|
21701
|
+
.prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
|
|
21702
|
+
(SELECT q.last_error FROM telegram_conversation_part q WHERE q.conversation = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
|
|
21703
|
+
FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
|
|
21704
|
+
WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
|
|
21705
|
+
GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
|
|
21706
|
+
.all(botId, botId, approver, approver, since.toISOString())
|
|
21707
|
+
.map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
|
|
21708
|
+
}
|
|
21709
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
21710
|
+
retryTelegramReplies(approver, now) {
|
|
21711
|
+
const { changes } = this.db
|
|
21712
|
+
.prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
|
|
21713
|
+
WHERE state = 'queued' AND (? IS NULL OR approver = ?)
|
|
21714
|
+
AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
|
|
21715
|
+
.run(now.toISOString(), approver, approver);
|
|
21716
|
+
return Number(changes);
|
|
21717
|
+
}
|
|
21689
21718
|
// ---- the outbound parts (v63) ------------------------------------------------
|
|
21690
21719
|
/**
|
|
21691
21720
|
* Persist the whole outbound half of a turn — every reply part and every
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export declare function splitParts(text: string): string[];
|
|
|
122
122
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
123
123
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
124
124
|
* flood cannot pin one pass forever.
|
|
125
|
+
*
|
|
126
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
127
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
128
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
129
|
+
* place, in the same order and over the same connection as notifications,
|
|
130
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
125
131
|
*/
|
|
126
132
|
export declare function processTelegramConversations(args: {
|
|
127
133
|
store: Store;
|
|
@@ -134,4 +140,5 @@ export declare function processTelegramConversations(args: {
|
|
|
134
140
|
report: ConversationReport;
|
|
135
141
|
signal?: AbortSignal;
|
|
136
142
|
limit?: number;
|
|
143
|
+
only?: "turns" | "replies";
|
|
137
144
|
}): Promise<void>;
|
package/dist/telegram-mate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
|
|
2
|
-
import { taskInCeiling, mirrorToTaskChat, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
2
|
+
import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
3
3
|
export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
|
|
4
4
|
/**
|
|
5
5
|
* The paired phone as one more way to use the same assistant.
|
|
@@ -299,11 +299,17 @@ export function splitParts(text) {
|
|
|
299
299
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
300
300
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
301
301
|
* flood cannot pin one pass forever.
|
|
302
|
+
*
|
|
303
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
304
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
305
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
306
|
+
* place, in the same order and over the same connection as notifications,
|
|
307
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
302
308
|
*/
|
|
303
309
|
export async function processTelegramConversations(args) {
|
|
304
310
|
const limit = args.limit ?? 20;
|
|
305
311
|
for (let count = 0; count < limit && args.signal?.aborted !== true; count++) {
|
|
306
|
-
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock());
|
|
312
|
+
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock(), args.only);
|
|
307
313
|
if (row === null)
|
|
308
314
|
return;
|
|
309
315
|
try {
|
|
@@ -318,6 +324,8 @@ export async function processTelegramConversations(args) {
|
|
|
318
324
|
}
|
|
319
325
|
class PlanRefused extends Error {
|
|
320
326
|
}
|
|
327
|
+
/** The outcome a planned reply waits under until the bridge's cycle sends it. */
|
|
328
|
+
const ANSWERED_SENDING = "answered:sending";
|
|
321
329
|
/**
|
|
322
330
|
* One claimed message, in order of what is already known about it:
|
|
323
331
|
*
|
|
@@ -334,12 +342,13 @@ class PlanRefused extends Error {
|
|
|
334
342
|
* the ceiling the turn opened under; only a confirmed message id counts.
|
|
335
343
|
*/
|
|
336
344
|
async function runTelegramConversation(row, args) {
|
|
337
|
-
const { store, botId, transport, owner, clock, readProjects, options, report } = args;
|
|
345
|
+
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
338
346
|
const finish = (result) => store.finishTelegramConversation(row.id, owner, result, clock());
|
|
339
347
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
340
348
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
341
|
-
const requeue = (outcome) => {
|
|
349
|
+
const requeue = async (outcome) => {
|
|
342
350
|
if (ageMs > CONVERSATION_MAX_AGE_MS) {
|
|
351
|
+
await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
|
|
343
352
|
finish({ state: "failed", outcome: `${outcome}:gave-up` });
|
|
344
353
|
report.refused++;
|
|
345
354
|
return;
|
|
@@ -384,6 +393,7 @@ async function runTelegramConversation(row, args) {
|
|
|
384
393
|
const bound = store.getTelegramConversation(row.id)?.session ?? null;
|
|
385
394
|
const session = bound === null ? null : store.getMateSession(bound);
|
|
386
395
|
if (session === null) {
|
|
396
|
+
await notify(couldNotAnswerText("the chat session for this reply is gone"));
|
|
387
397
|
finish({ state: "failed", outcome: "unsent:no-session" });
|
|
388
398
|
report.refused++;
|
|
389
399
|
return;
|
|
@@ -407,6 +417,7 @@ async function runTelegramConversation(row, args) {
|
|
|
407
417
|
}
|
|
408
418
|
if (now.getTime() - Date.parse(row.createdAt) > DELIVERY_MAX_AGE_MS) {
|
|
409
419
|
report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
|
|
420
|
+
await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
|
|
410
421
|
finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
|
|
411
422
|
report.refused++;
|
|
412
423
|
return;
|
|
@@ -512,15 +523,24 @@ async function runTelegramConversation(row, args) {
|
|
|
512
523
|
...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
|
|
513
524
|
});
|
|
514
525
|
}
|
|
526
|
+
// The first time the answer itself fails to go out, the person hears so once,
|
|
527
|
+
// in plain words; the reply itself keeps being retried and shows as
|
|
528
|
+
// unsent in status and the console after two minutes.
|
|
529
|
+
const firstFailure = part.kind === "reply" && store.listTelegramConversationParts(row.id).every(one => one.attempts <= (one.state === "sent" ? 1 : 0));
|
|
530
|
+
const failed = async (until, error, uncertain) => {
|
|
531
|
+
if (!store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain, retryAt: until.toISOString() }, clock()))
|
|
532
|
+
return;
|
|
533
|
+
if (firstFailure)
|
|
534
|
+
await notify(couldNotAnswerText(uncertain ? "my reply may not have reached Telegram" : "Telegram didn't accept my reply"));
|
|
535
|
+
defer(until, error);
|
|
536
|
+
};
|
|
515
537
|
let answer;
|
|
516
538
|
try {
|
|
517
539
|
answer = await send();
|
|
518
540
|
}
|
|
519
541
|
catch {
|
|
520
542
|
// The answer was lost: Telegram may or may not have the message. Said so, retried, counted.
|
|
521
|
-
|
|
522
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
523
|
-
defer(backoff, error);
|
|
543
|
+
await failed(backoff, "Telegram transport failed; delivery may be uncertain", true);
|
|
524
544
|
return;
|
|
525
545
|
}
|
|
526
546
|
if (!answer.ok) {
|
|
@@ -535,15 +555,12 @@ async function runTelegramConversation(row, args) {
|
|
|
535
555
|
if (paused > until)
|
|
536
556
|
until = paused;
|
|
537
557
|
}
|
|
538
|
-
|
|
539
|
-
defer(until, error);
|
|
558
|
+
await failed(until, error, uncertain);
|
|
540
559
|
return;
|
|
541
560
|
}
|
|
542
561
|
const id = answer.result?.message_id;
|
|
543
562
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
544
|
-
|
|
545
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
546
|
-
defer(backoff, error);
|
|
563
|
+
await failed(backoff, "Telegram returned no confirmed message identity", true);
|
|
547
564
|
return;
|
|
548
565
|
}
|
|
549
566
|
const messageId = String(id);
|
|
@@ -596,11 +613,11 @@ async function runTelegramConversation(row, args) {
|
|
|
596
613
|
}
|
|
597
614
|
store.bindTelegramConversationTurn(row.id, owner, session, turnId);
|
|
598
615
|
if (turn.state === "queued" || turn.state === "running") {
|
|
599
|
-
requeue("replayed:running");
|
|
616
|
+
await requeue("replayed:running");
|
|
600
617
|
return;
|
|
601
618
|
}
|
|
602
619
|
if (turn.state !== "answered") {
|
|
603
|
-
await notify(`
|
|
620
|
+
await notify(couldNotAnswerText(`the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)})`));
|
|
604
621
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
605
622
|
report.refused++;
|
|
606
623
|
return;
|
|
@@ -609,11 +626,19 @@ async function runTelegramConversation(row, args) {
|
|
|
609
626
|
const proposals = store.listMateProposals(turn.thread, ["pending"]).filter(one => one.turn === turn.id);
|
|
610
627
|
if (!plan(session, turnId, reply, proposals, repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
611
628
|
return;
|
|
612
|
-
await
|
|
629
|
+
await sendOrHandOff("replayed");
|
|
630
|
+
};
|
|
631
|
+
/** Send now, or — for the follower's turn worker — leave it to the bridge's cycle, which sends it next. */
|
|
632
|
+
const sendOrHandOff = async (outcomeWord) => {
|
|
633
|
+
if (only !== "turns") {
|
|
634
|
+
await deliver(outcomeWord);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
finish({ state: "queued", outcome: outcomeWord === "answered" ? ANSWERED_SENDING : "delivering", nextAttemptAt: clock().toISOString() });
|
|
613
638
|
};
|
|
614
639
|
// 2. Parts already planned: the turn answered; only the sending remains.
|
|
615
640
|
if (store.listTelegramConversationParts(row.id).length > 0) {
|
|
616
|
-
await deliver("replayed");
|
|
641
|
+
await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
|
|
617
642
|
return;
|
|
618
643
|
}
|
|
619
644
|
// 3. The ceiling, reloaded now.
|
|
@@ -623,7 +648,7 @@ async function runTelegramConversation(row, args) {
|
|
|
623
648
|
}
|
|
624
649
|
catch {
|
|
625
650
|
report.problems.push("telegram chat could not read the current project records");
|
|
626
|
-
requeue("registry-unavailable");
|
|
651
|
+
await requeue("registry-unavailable");
|
|
627
652
|
return;
|
|
628
653
|
}
|
|
629
654
|
const repos = telegramConversationRepos(store, binding.approver, registry);
|
|
@@ -638,6 +663,10 @@ async function runTelegramConversation(row, args) {
|
|
|
638
663
|
}
|
|
639
664
|
// 5. The session and thread — shared with the console and the CLI.
|
|
640
665
|
const resolved = resolveTelegramMate(store, binding, repos, clock());
|
|
666
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
667
|
+
await requeue("busy");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
641
670
|
if (!resolved.ok) {
|
|
642
671
|
if (resolved.said !== null)
|
|
643
672
|
await notify(resolved.said);
|
|
@@ -676,10 +705,10 @@ async function runTelegramConversation(row, args) {
|
|
|
676
705
|
store.bindTelegramConversationTurn(row.id, owner, session.id, receipt.turn);
|
|
677
706
|
if (!outcome.ok && "refused" in outcome) {
|
|
678
707
|
if (outcome.refused === "concurrent") {
|
|
679
|
-
requeue("busy");
|
|
708
|
+
await requeue("busy");
|
|
680
709
|
return;
|
|
681
710
|
}
|
|
682
|
-
await notify(
|
|
711
|
+
await notify(phoneText(couldNotAnswerText(outcome.message), 1_000));
|
|
683
712
|
finish({ state: "failed", outcome: `refused:${outcome.refused}` });
|
|
684
713
|
report.refused++;
|
|
685
714
|
return;
|
|
@@ -687,7 +716,7 @@ async function runTelegramConversation(row, args) {
|
|
|
687
716
|
if (!outcome.ok) {
|
|
688
717
|
// Truthful: what failed, that nothing was kept, and that a new message
|
|
689
718
|
// is a new turn. A failed turn's drafts are already gone.
|
|
690
|
-
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : `
|
|
719
|
+
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : couldNotAnswerText(`the assistant's reply did not complete (${outcome.message})`);
|
|
691
720
|
await notify(phoneText(words, 1_000));
|
|
692
721
|
finish({ state: "failed", outcome: `failed:${outcome.failed}` });
|
|
693
722
|
report.refused++;
|
|
@@ -703,5 +732,5 @@ async function runTelegramConversation(row, args) {
|
|
|
703
732
|
const proposals = store.listMateProposals(thread.id, ["pending"]).filter(one => one.turn === outcome.turn);
|
|
704
733
|
if (!plan(session.id, outcome.turn, outcome.reply, proposals, who.repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
705
734
|
return;
|
|
706
|
-
await
|
|
735
|
+
await sendOrHandOff("answered");
|
|
707
736
|
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* the settings page; this card only mints or revokes the signed-in person's
|
|
4
4
|
* pairing, never anyone else's. */
|
|
5
5
|
import type { Store } from "./store.js";
|
|
6
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
7
|
+
export declare const UNSENT_REPLY_MS: number;
|
|
6
8
|
export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, csrf: string, options?: {
|
|
7
9
|
code?: string;
|
|
8
10
|
problem?: string;
|
|
11
|
+
now?: Date;
|
|
9
12
|
}): string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
2
|
+
export const UNSENT_REPLY_MS = 2 * 60_000;
|
|
1
3
|
const escape = (text) => text
|
|
2
4
|
.replaceAll("&", "&")
|
|
3
5
|
.replaceAll("<", "<")
|
|
@@ -17,6 +19,15 @@ export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
|
|
|
17
19
|
const mine = bindings.filter(binding => binding.approver === who);
|
|
18
20
|
const others = bindings.length - mine.length;
|
|
19
21
|
content = `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`;
|
|
22
|
+
// A reply the assistant wrote that has not reached this person's phone for two minutes.
|
|
23
|
+
const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
|
|
24
|
+
if (unsent.length > 0) {
|
|
25
|
+
const oldest = unsent[0];
|
|
26
|
+
content +=
|
|
27
|
+
`<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
|
|
28
|
+
`<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
|
|
29
|
+
`<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
|
|
30
|
+
}
|
|
20
31
|
if (options.code !== undefined) {
|
|
21
32
|
content +=
|
|
22
33
|
"<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
|
package/dist/telegram.d.ts
CHANGED
|
@@ -98,6 +98,20 @@ export type TelegramUpload = {
|
|
|
98
98
|
bytes: Buffer;
|
|
99
99
|
};
|
|
100
100
|
export declare function createTransport(token: string, timeoutMs?: number): TelegramTransport;
|
|
101
|
+
/**
|
|
102
|
+
* Why this process can't reach Telegram the way the service does, or null.
|
|
103
|
+
* A coding agent's sandbox fences the network: Codex says so in its own
|
|
104
|
+
* variables, and Claude Code's sandbox routes traffic through a proxy on
|
|
105
|
+
* this computer, which curl uses and Node's fetch does not — so sends fail
|
|
106
|
+
* "fetch failed" while curl works (Oct 2). Builds, checks and agents also
|
|
107
|
+
* get a database of their own, so none of them can hold the live bridge;
|
|
108
|
+
* this keeps an agent-launched `watch` or `bridge` from holding it either.
|
|
109
|
+
*/
|
|
110
|
+
export declare function networkFence(env: Record<string, string | undefined>): string | null;
|
|
111
|
+
/** The plain line a fenced process says instead of polling or sending. */
|
|
112
|
+
export declare function networkFenceLine(reason: string): string;
|
|
113
|
+
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
114
|
+
export declare function transportError(error: unknown): string;
|
|
101
115
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
102
116
|
export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
|
|
103
117
|
export declare function hashPairingCode(code: string): string;
|
package/dist/telegram.js
CHANGED
|
@@ -194,7 +194,7 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
194
194
|
catch (error) {
|
|
195
195
|
return {
|
|
196
196
|
ok: false,
|
|
197
|
-
description: scrub(
|
|
197
|
+
description: scrub(transportError(error), token),
|
|
198
198
|
uncertain: true,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
@@ -204,6 +204,48 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
204
204
|
}
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Why this process can't reach Telegram the way the service does, or null.
|
|
209
|
+
* A coding agent's sandbox fences the network: Codex says so in its own
|
|
210
|
+
* variables, and Claude Code's sandbox routes traffic through a proxy on
|
|
211
|
+
* this computer, which curl uses and Node's fetch does not — so sends fail
|
|
212
|
+
* "fetch failed" while curl works (Oct 2). Builds, checks and agents also
|
|
213
|
+
* get a database of their own, so none of them can hold the live bridge;
|
|
214
|
+
* this keeps an agent-launched `watch` or `bridge` from holding it either.
|
|
215
|
+
*/
|
|
216
|
+
export function networkFence(env) {
|
|
217
|
+
const set = (name) => (env[name] ?? "") !== "";
|
|
218
|
+
if (set("CODEX_SANDBOX_NETWORK_DISABLED") || set("CODEX_SANDBOX"))
|
|
219
|
+
return "a Codex sandbox";
|
|
220
|
+
const agent = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].some(set);
|
|
221
|
+
const loopbackProxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"].some(name => {
|
|
222
|
+
const value = env[name] ?? "";
|
|
223
|
+
if (value === "")
|
|
224
|
+
return false;
|
|
225
|
+
try {
|
|
226
|
+
return /^(?:localhost|127\.\d+\.\d+\.\d+|\[::1\])$/i.test(new URL(value.includes("://") ? value : `http://${value}`).hostname);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
return agent && loopbackProxy ? "a Claude Code sandbox" : null;
|
|
233
|
+
}
|
|
234
|
+
/** The plain line a fenced process says instead of polling or sending. */
|
|
235
|
+
export function networkFenceLine(reason) {
|
|
236
|
+
return `Telegram: not connecting from here — this process runs inside ${reason}, which blocks its network. Replies and notifications go out from the Toolroll service.`;
|
|
237
|
+
}
|
|
238
|
+
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
239
|
+
export function transportError(error) {
|
|
240
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
241
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
242
|
+
if (cause === undefined || cause === null)
|
|
243
|
+
return message;
|
|
244
|
+
const code = typeof cause.code === "string" ? cause.code : null;
|
|
245
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
246
|
+
const why = code !== null && !detail.includes(code) ? `${code}: ${detail}` : detail;
|
|
247
|
+
return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
|
|
248
|
+
}
|
|
207
249
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
208
250
|
export function multipartOf(params, upload) {
|
|
209
251
|
const form = new FormData();
|
|
@@ -332,12 +374,16 @@ export async function followBridge(store, options) {
|
|
|
332
374
|
// turn must not stall the long poll, and each cycle's lease re-acquire is
|
|
333
375
|
// the renewal that keeps this the only live poller meanwhile. One
|
|
334
376
|
// processor at a time; its counts land on the totals when it finishes.
|
|
377
|
+
// It only persists the reply: the cycle sends it, in the same slot as
|
|
378
|
+
// notifications, so a reply never needs a connection of its own beside
|
|
379
|
+
// the long poll (Oct 2: replies failed "fetch failed" for minutes while
|
|
380
|
+
// notifications from the same process went out).
|
|
335
381
|
let inFlight = null;
|
|
336
382
|
const kick = () => {
|
|
337
383
|
if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
|
|
338
384
|
return;
|
|
339
385
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
340
|
-
inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
|
|
386
|
+
inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "turns")
|
|
341
387
|
.catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
|
|
342
388
|
.finally(() => {
|
|
343
389
|
inFlight = null;
|
|
@@ -449,13 +495,18 @@ export async function followBridge(store, options) {
|
|
|
449
495
|
await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
|
|
450
496
|
await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
|
|
451
497
|
}
|
|
498
|
+
// Replies the turn worker has planned go out here, like notifications.
|
|
499
|
+
if (options.conversation !== undefined && options.readProjects !== undefined) {
|
|
500
|
+
await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "replies");
|
|
501
|
+
}
|
|
452
502
|
// Pushed updates first (some may wait from before a fall back), then — only while
|
|
453
503
|
// Telegram isn't pushing — ask for more.
|
|
454
504
|
const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
|
|
455
505
|
if (pushed > 0)
|
|
456
506
|
report.pushed = pushed;
|
|
457
507
|
if (!pushing()) {
|
|
458
|
-
|
|
508
|
+
// While a turn is running, poll briefly so its reply is sent within a second or two of being written.
|
|
509
|
+
await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
|
|
459
510
|
// A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
|
|
460
511
|
if (report.webhookActive === true && push === null)
|
|
461
512
|
await transport("deleteWebhook", { drop_pending_updates: false }, signal);
|
|
@@ -474,11 +525,13 @@ export async function followBridge(store, options) {
|
|
|
474
525
|
total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
|
|
475
526
|
if (report.chatConfirmed !== undefined)
|
|
476
527
|
total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
|
|
528
|
+
if (report.chatAnswered !== undefined)
|
|
529
|
+
total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
|
|
477
530
|
total.problems.push(...report.problems);
|
|
478
|
-
if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
|
|
531
|
+
if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
|
|
479
532
|
options.onCycle?.(report);
|
|
480
533
|
}
|
|
481
|
-
if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
|
|
534
|
+
if (report.problems.length > 0 && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0) {
|
|
482
535
|
// The wire is down. Back off; the counter resets on the first clean cycle.
|
|
483
536
|
if (!signal.aborted) {
|
|
484
537
|
await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
|
|
@@ -493,7 +546,7 @@ export async function followBridge(store, options) {
|
|
|
493
546
|
const took = Date.now() - startedAt;
|
|
494
547
|
// While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
|
|
495
548
|
const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
|
|
496
|
-
if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
|
|
549
|
+
if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
|
|
497
550
|
await wait(floor - took);
|
|
498
551
|
}
|
|
499
552
|
}
|
|
@@ -501,16 +554,25 @@ export async function followBridge(store, options) {
|
|
|
501
554
|
finally {
|
|
502
555
|
// A turn in flight finishes on its own bounds (the engine's wall clock);
|
|
503
556
|
// its reply is fenced on the claim and the channel like every other part.
|
|
504
|
-
if (inFlight !== null)
|
|
557
|
+
if (inFlight !== null) {
|
|
505
558
|
await inFlight;
|
|
559
|
+
// Its reply was written for the cycle that will not come: send it now, while the lease is still ours.
|
|
560
|
+
if (options.conversation !== undefined && options.readProjects !== undefined) {
|
|
561
|
+
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
562
|
+
await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, undefined, "replies").catch(() => { });
|
|
563
|
+
if (report.chatAnswered !== undefined)
|
|
564
|
+
total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
|
|
565
|
+
total.problems.push(...report.problems);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
506
568
|
store.releaseBridgeLease(botId, owner, clock());
|
|
507
569
|
}
|
|
508
570
|
return total;
|
|
509
571
|
}
|
|
510
572
|
/** The queued turns for one bot, counted onto the pass's report. */
|
|
511
|
-
async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
|
|
573
|
+
async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal, only) {
|
|
512
574
|
const chat = { answered: 0, refused: 0, problems: [] };
|
|
513
|
-
await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
|
|
575
|
+
await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }), ...(only === undefined ? {} : { only }) });
|
|
514
576
|
if (chat.answered > 0)
|
|
515
577
|
report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
|
|
516
578
|
if (chat.refused > 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|