toolroll 0.9.9 → 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 +15 -0
- package/dist/assignment.js +3 -1
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +49 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/discord-chat.js +1 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +118 -37
- package/dist/project-delete.js +1 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/serve.js +9 -2
- package/dist/slack-chat.js +2 -0
- package/dist/store.d.ts +39 -1
- package/dist/store.js +112 -2
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +11 -4
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +3 -2
- package/dist/telegram.d.ts +15 -1
- package/dist/telegram.js +164 -52
- package/package.json +1 -1
package/dist/telegram.js
CHANGED
|
@@ -29,7 +29,8 @@ import { join } from "node:path";
|
|
|
29
29
|
import { validateNote } from "./decision.js";
|
|
30
30
|
import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
|
-
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView
|
|
32
|
+
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
|
+
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
33
34
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
34
35
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
35
36
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -193,7 +194,7 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
193
194
|
catch (error) {
|
|
194
195
|
return {
|
|
195
196
|
ok: false,
|
|
196
|
-
description: scrub(
|
|
197
|
+
description: scrub(transportError(error), token),
|
|
197
198
|
uncertain: true,
|
|
198
199
|
};
|
|
199
200
|
}
|
|
@@ -203,6 +204,48 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
203
204
|
}
|
|
204
205
|
};
|
|
205
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
|
+
}
|
|
206
249
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
207
250
|
export function multipartOf(params, upload) {
|
|
208
251
|
const form = new FormData();
|
|
@@ -331,12 +374,16 @@ export async function followBridge(store, options) {
|
|
|
331
374
|
// turn must not stall the long poll, and each cycle's lease re-acquire is
|
|
332
375
|
// the renewal that keeps this the only live poller meanwhile. One
|
|
333
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).
|
|
334
381
|
let inFlight = null;
|
|
335
382
|
const kick = () => {
|
|
336
383
|
if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
|
|
337
384
|
return;
|
|
338
385
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
339
|
-
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")
|
|
340
387
|
.catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
|
|
341
388
|
.finally(() => {
|
|
342
389
|
inFlight = null;
|
|
@@ -438,6 +485,8 @@ export async function followBridge(store, options) {
|
|
|
438
485
|
await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
|
|
439
486
|
}
|
|
440
487
|
catch { /* typed commands still work */ }
|
|
488
|
+
// After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
|
|
489
|
+
await nameTelegramBot(transport, "upgrade");
|
|
441
490
|
}
|
|
442
491
|
const startedAt = Date.now();
|
|
443
492
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
@@ -446,13 +495,18 @@ export async function followBridge(store, options) {
|
|
|
446
495
|
await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
|
|
447
496
|
await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
|
|
448
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
|
+
}
|
|
449
502
|
// Pushed updates first (some may wait from before a fall back), then — only while
|
|
450
503
|
// Telegram isn't pushing — ask for more.
|
|
451
504
|
const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
|
|
452
505
|
if (pushed > 0)
|
|
453
506
|
report.pushed = pushed;
|
|
454
507
|
if (!pushing()) {
|
|
455
|
-
|
|
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);
|
|
456
510
|
// A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
|
|
457
511
|
if (report.webhookActive === true && push === null)
|
|
458
512
|
await transport("deleteWebhook", { drop_pending_updates: false }, signal);
|
|
@@ -471,11 +525,13 @@ export async function followBridge(store, options) {
|
|
|
471
525
|
total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
|
|
472
526
|
if (report.chatConfirmed !== undefined)
|
|
473
527
|
total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
|
|
528
|
+
if (report.chatAnswered !== undefined)
|
|
529
|
+
total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
|
|
474
530
|
total.problems.push(...report.problems);
|
|
475
|
-
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) {
|
|
476
532
|
options.onCycle?.(report);
|
|
477
533
|
}
|
|
478
|
-
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) {
|
|
479
535
|
// The wire is down. Back off; the counter resets on the first clean cycle.
|
|
480
536
|
if (!signal.aborted) {
|
|
481
537
|
await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
|
|
@@ -490,7 +546,7 @@ export async function followBridge(store, options) {
|
|
|
490
546
|
const took = Date.now() - startedAt;
|
|
491
547
|
// While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
|
|
492
548
|
const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
|
|
493
|
-
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) {
|
|
494
550
|
await wait(floor - took);
|
|
495
551
|
}
|
|
496
552
|
}
|
|
@@ -498,37 +554,50 @@ export async function followBridge(store, options) {
|
|
|
498
554
|
finally {
|
|
499
555
|
// A turn in flight finishes on its own bounds (the engine's wall clock);
|
|
500
556
|
// its reply is fenced on the claim and the channel like every other part.
|
|
501
|
-
if (inFlight !== null)
|
|
557
|
+
if (inFlight !== null) {
|
|
502
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
|
+
}
|
|
503
568
|
store.releaseBridgeLease(botId, owner, clock());
|
|
504
569
|
}
|
|
505
570
|
return total;
|
|
506
571
|
}
|
|
507
572
|
/** The queued turns for one bot, counted onto the pass's report. */
|
|
508
|
-
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) {
|
|
509
574
|
const chat = { answered: 0, refused: 0, problems: [] };
|
|
510
|
-
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 }) });
|
|
511
576
|
if (chat.answered > 0)
|
|
512
577
|
report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
|
|
513
578
|
if (chat.refused > 0)
|
|
514
579
|
report.chatRefused = (report.chatRefused ?? 0) + chat.refused;
|
|
515
580
|
report.problems.push(...chat.problems);
|
|
516
581
|
}
|
|
517
|
-
/**
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
582
|
+
/** A fact's own next-action button row under the trusted origin read now; none without one. */
|
|
583
|
+
function factButton(phoneOrigin, link) {
|
|
584
|
+
try {
|
|
585
|
+
return phoneLinkButton(phoneOrigin?.() ?? null, { label: factLinkLabel(link), path: link });
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** A view's buttons on one row: its link, then any others ([Merge] [Look first]); none without a trusted origin. */
|
|
592
|
+
function viewKeyboard(phoneOrigin, view) {
|
|
593
|
+
try {
|
|
594
|
+
const origin = phoneOrigin?.() ?? null;
|
|
595
|
+
const row = [view.link, ...(view.also ?? [])].flatMap(link => phoneLinkButton(origin, link) ?? []);
|
|
596
|
+
return row.length === 0 ? [] : [row];
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
532
601
|
}
|
|
533
602
|
/** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
|
|
534
603
|
async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
|
|
@@ -720,7 +789,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
720
789
|
const messageId = store.telegramProgressMessage(binding, progressRun);
|
|
721
790
|
if (messageId === null && onlyExisting)
|
|
722
791
|
return null;
|
|
723
|
-
const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot);
|
|
792
|
+
const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot, binding.approver);
|
|
724
793
|
let button = null;
|
|
725
794
|
try {
|
|
726
795
|
button = phoneLinkButton(phoneOrigin?.() ?? null, card.link);
|
|
@@ -750,7 +819,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
750
819
|
if (problem !== null)
|
|
751
820
|
return { ok: false, error: problem };
|
|
752
821
|
const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
|
|
753
|
-
const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
|
|
822
|
+
const view = quietCardView(store, card.tasks, clock(), evidenceRoot, binding.approver);
|
|
754
823
|
if (view === null)
|
|
755
824
|
return { ok: true, messageId: card.message };
|
|
756
825
|
let button = null;
|
|
@@ -792,14 +861,55 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
792
861
|
if (!needsPerson(fact))
|
|
793
862
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
794
863
|
const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
|
|
795
|
-
if (readyRun !== null) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
864
|
+
if (readyRun !== null || (fact.kind !== "run-finished" && joinsBatch(fact))) {
|
|
865
|
+
// Any update for this person joins their open batch: one message, edited in place while it grows.
|
|
866
|
+
const batch = store.chatBatchFor(fact.destination, fact.taskRef, readyRun?.id ?? store.telegramProgressRun(fact)?.id ?? null, clock(), BATCH_MS, readyRun !== null ? null : fact.id);
|
|
867
|
+
// A lone update that is not finished work goes out exactly as it always has; a later one edits it in place.
|
|
868
|
+
const lone = batch.items.length === 1 && batch.items[0].notification !== null;
|
|
869
|
+
if (batch.message === null && lone) {
|
|
870
|
+
const sent = await deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
871
|
+
const placed = store.telegramMessageOf(fact.id, fact.destination);
|
|
872
|
+
if (placed !== null)
|
|
873
|
+
store.setChatBatchMessage(batch.id, placed, "");
|
|
874
|
+
return sent;
|
|
875
|
+
}
|
|
876
|
+
const view = finishedView(store, batch, clock(), evidenceRoot, binding.approver);
|
|
877
|
+
// Nothing in the batch reads as a line. Finished work left out on purpose (a release check, a replaced task,
|
|
878
|
+
// the reader's own completion) stays quiet; any other update that needs this person still reaches them alone.
|
|
879
|
+
if (view === null) {
|
|
880
|
+
if (readyRun !== null)
|
|
881
|
+
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
882
|
+
return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
800
883
|
}
|
|
801
|
-
|
|
802
|
-
const
|
|
884
|
+
// While a lone update is still the only item, its edit keeps that update's own action button.
|
|
885
|
+
const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
|
|
886
|
+
const keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
|
|
887
|
+
const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
|
|
888
|
+
if (batch.message !== null) {
|
|
889
|
+
if (batch.digest === shown) {
|
|
890
|
+
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
891
|
+
return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
|
|
892
|
+
}
|
|
893
|
+
const problem = (await readAccess()) ?? fence();
|
|
894
|
+
if (problem !== null)
|
|
895
|
+
return { ok: false, error: problem };
|
|
896
|
+
const edited = await editProgress(transport, binding.chatId, batch.message, view.text, keyboard);
|
|
897
|
+
if (edited.ok) {
|
|
898
|
+
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
899
|
+
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
900
|
+
const after = (await readAccess()) ?? fence();
|
|
901
|
+
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
|
|
902
|
+
}
|
|
903
|
+
if (edited.retryAfter !== undefined)
|
|
904
|
+
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
905
|
+
// Only Telegram's definitive missing/uneditable answer permits a new message.
|
|
906
|
+
if (!edited.replace)
|
|
907
|
+
return edited;
|
|
908
|
+
}
|
|
909
|
+
const sent = await sender(view.text, keyboard.length === 0 ? undefined : keyboard, [fact]);
|
|
910
|
+
const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
|
|
911
|
+
if (placed !== null)
|
|
912
|
+
store.setChatBatchMessage(batch.id, placed, shown);
|
|
803
913
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
804
914
|
}
|
|
805
915
|
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
@@ -815,7 +925,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
815
925
|
const updated = progress ? await updateProgress(false) : null;
|
|
816
926
|
outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
|
|
817
927
|
: rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
|
|
818
|
-
? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
|
|
928
|
+
? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock, taskId => chatTitle(store, taskId))
|
|
819
929
|
: await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
|
|
820
930
|
}
|
|
821
931
|
const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
|
|
@@ -850,26 +960,29 @@ function isUrgent(notification) {
|
|
|
850
960
|
/** The digest text: a header with the count and the window, then one
|
|
851
961
|
* fact per entry — its subject, then its body's first line, indented.
|
|
852
962
|
* Plain text, no buttons: nothing in a digest is tappable. */
|
|
853
|
-
export function digestText(rows, since, now) {
|
|
963
|
+
export function digestText(rows, since, now, titleOf) {
|
|
854
964
|
const window = since === null ? "" : ` since ${since.slice(0, 16).replace("T", " ")}`;
|
|
855
965
|
const lines = [`digest — ${rows.length} routine fact(s)${window} (as of ${now.toISOString().slice(0, 16).replace("T", " ")})`, ""];
|
|
856
966
|
for (const row of rows) {
|
|
857
|
-
lines.push(digestEntry(row));
|
|
967
|
+
lines.push(digestEntry(row, titleOf));
|
|
858
968
|
}
|
|
859
969
|
return lines.join("\n");
|
|
860
970
|
}
|
|
861
|
-
function digestEntry(row) {
|
|
971
|
+
function digestEntry(row, titleOf) {
|
|
972
|
+
const title = row.taskId === null ? undefined : titleOf?.(row.taskId);
|
|
973
|
+
const task = row.taskId === null ? [] : [{ id: row.taskId, ...(title === undefined ? {} : { title }) }];
|
|
974
|
+
const subject = chatText(row.subject, task);
|
|
862
975
|
const first = row.body.split("\n").map(one => one.trim()).find(one => one !== "");
|
|
863
976
|
const body = first === undefined ? "" : `\n ${first.length > 200 ? `${first.slice(0, 200).replace(/[\uD800-\uDBFF]$/, "")}…` : first}`;
|
|
864
|
-
return `• ${notificationIdentity(row)}${
|
|
977
|
+
return `• ${notificationIdentity(row, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}${chatText(body, task)}`;
|
|
865
978
|
}
|
|
866
|
-
async function deliverDigest(botId, binding, sender, rows, since, clock) {
|
|
867
|
-
const text = digestText(rows, since, clock());
|
|
979
|
+
async function deliverDigest(botId, binding, sender, rows, since, clock, titleOf) {
|
|
980
|
+
const text = digestText(rows, since, clock(), titleOf);
|
|
868
981
|
// Track the exact rows represented by each text part. A split row may bind
|
|
869
982
|
// several messages; a digest message may bind several rows.
|
|
870
983
|
let offset = text.indexOf("\n\n") + 2;
|
|
871
984
|
const spans = rows.map(row => {
|
|
872
|
-
const length = digestEntry(row).length;
|
|
985
|
+
const length = digestEntry(row, titleOf).length;
|
|
873
986
|
const span = { row, start: offset, end: offset + length };
|
|
874
987
|
offset += length + 1;
|
|
875
988
|
return span;
|
|
@@ -917,16 +1030,13 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
917
1030
|
else
|
|
918
1031
|
body = `${packet.text}\n\n${notification.body}`;
|
|
919
1032
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
button = null;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
1033
|
+
// A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
1034
|
+
const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
|
|
1035
|
+
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
1036
|
+
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.subject}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
1037
|
+
// The task's title once: in front, unless the words already name it.
|
|
1038
|
+
const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1039
|
+
const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
930
1040
|
// A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
|
|
931
1041
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
932
1042
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
@@ -1403,6 +1513,8 @@ function applyMessage(context, update, effects) {
|
|
|
1403
1513
|
text: `paired: this chat now answers as ${approver}\n\nSend /status to check recent work, /task <id> for one task, or /help for your options.`,
|
|
1404
1514
|
link_preview_options: { is_disabled: true },
|
|
1405
1515
|
});
|
|
1516
|
+
// The bot is Toolroll in every chat list from here on (best effort; pairing never waits on it).
|
|
1517
|
+
await nameTelegramBot(transport, "pairing");
|
|
1406
1518
|
});
|
|
1407
1519
|
}
|
|
1408
1520
|
/**
|
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"
|