toolroll 0.9.17 → 0.9.19
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 +19 -0
- package/dist/browser/workspace.js +14 -14
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-decide.d.ts +203 -0
- package/dist/chat-decide.js +554 -0
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +40 -1
- package/dist/chat-quiet.d.ts +4 -1
- package/dist/chat-quiet.js +6 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +2 -0
- package/dist/discord-api.js +3 -1
- package/dist/discord-chat.js +22 -3
- package/dist/modes.d.ts +6 -0
- package/dist/modes.js +6 -0
- package/dist/operate.js +18 -8
- package/dist/project-delete.js +3 -0
- package/dist/result-shots.d.ts +53 -0
- package/dist/result-shots.js +124 -0
- package/dist/serve.js +23 -21
- package/dist/slack-chat.js +23 -4
- package/dist/store.d.ts +24 -0
- package/dist/store.js +119 -7
- package/dist/teams-chat.js +5 -0
- package/dist/telegram-mate.d.ts +10 -0
- package/dist/telegram.d.ts +5 -3
- package/dist/telegram.js +240 -22
- package/package.json +1 -1
package/dist/telegram.js
CHANGED
|
@@ -27,7 +27,8 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
27
27
|
import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { validateNote } from "./decision.js";
|
|
30
|
-
import {
|
|
30
|
+
import { resultShotsFor } from "./result-shots.js";
|
|
31
|
+
import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
31
32
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
33
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
34
|
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
@@ -36,6 +37,8 @@ import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team
|
|
|
36
37
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
37
38
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
38
39
|
import { focusContextFor, taskInCeiling } from "./chat-channel.js";
|
|
40
|
+
import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, dropDecideTokens, hasLiveDecideTokens, linksFor, placeDecidePrompt, decideOffer, decideTargetOf, isDecideToken, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, placeDecideTokens, recordChatMerge, retireDecideTokens } from "./chat-decide.js";
|
|
41
|
+
import { mergePullRequest } from "./pull-request-flow.js";
|
|
39
42
|
import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_HELP, notificationIdentity, phoneTaskChoices, resolvePhoneTask, phoneFocusText, phoneTaskListText, phoneText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
40
43
|
import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
|
|
41
44
|
import { envValue } from "./names.js";
|
|
@@ -159,7 +162,7 @@ export function scrub(text, token) {
|
|
|
159
162
|
return token === "" ? text : text.split(token).join(redactToken(token));
|
|
160
163
|
}
|
|
161
164
|
export function createTransport(token, timeoutMs = 30_000) {
|
|
162
|
-
return async (method, params, signal, upload) => {
|
|
165
|
+
return async (method, params, signal, upload, more) => {
|
|
163
166
|
const controller = new AbortController();
|
|
164
167
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
165
168
|
const onAbort = () => controller.abort();
|
|
@@ -176,7 +179,7 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
176
179
|
// the verified bytes as one Blob under the file name given.
|
|
177
180
|
const request = upload === undefined
|
|
178
181
|
? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(params), signal: controller.signal }
|
|
179
|
-
: { method: "POST", body: multipartOf(params, upload), signal: controller.signal };
|
|
182
|
+
: { method: "POST", body: multipartOf(params, upload, ...(more ?? [])), signal: controller.signal };
|
|
180
183
|
const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, request);
|
|
181
184
|
const body = (await response.json());
|
|
182
185
|
// A missing or malformed acknowledgement does not prove a send failed.
|
|
@@ -248,14 +251,15 @@ export function transportError(error) {
|
|
|
248
251
|
return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
|
|
249
252
|
}
|
|
250
253
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
251
|
-
export function multipartOf(params, upload) {
|
|
254
|
+
export function multipartOf(params, upload, ...more) {
|
|
252
255
|
const form = new FormData();
|
|
253
256
|
for (const [key, value] of Object.entries(params)) {
|
|
254
257
|
if (value === undefined || value === null)
|
|
255
258
|
continue;
|
|
256
259
|
form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
257
260
|
}
|
|
258
|
-
|
|
261
|
+
for (const one of [upload, ...more])
|
|
262
|
+
form.append(one.field, new Blob([new Uint8Array(one.bytes)], { type: one.contentType }), one.fileName);
|
|
259
263
|
return form;
|
|
260
264
|
}
|
|
261
265
|
// ---- pairing ---------------------------------------------------------------
|
|
@@ -601,6 +605,29 @@ function viewKeyboard(phoneOrigin, view) {
|
|
|
601
605
|
return [];
|
|
602
606
|
}
|
|
603
607
|
}
|
|
608
|
+
/** Where a paired person taps: their binding and private chat. */
|
|
609
|
+
function decideSeat(binding) {
|
|
610
|
+
return { channel: "telegram", binding: binding.id, chat: binding.chatId, approver: binding.approver, generation: binding.approverGeneration };
|
|
611
|
+
}
|
|
612
|
+
/** Decide buttons as Telegram draws them: an act's opaque token, or a link under the trusted origin read now (none without one). */
|
|
613
|
+
function decideKeyboard(phoneOrigin, rows) {
|
|
614
|
+
let origin = null;
|
|
615
|
+
try {
|
|
616
|
+
origin = phoneOrigin?.() ?? null;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
origin = null;
|
|
620
|
+
}
|
|
621
|
+
return rows.map(row => row.flatMap((one) => "token" in one ? [{ text: one.label, callback_data: one.token }]
|
|
622
|
+
: phoneLinkButton(origin, one.link) ?? [])).filter(row => row.length > 0);
|
|
623
|
+
}
|
|
624
|
+
/** What this card may act on in the chat now, for this person under their current ceiling; null keeps its link. */
|
|
625
|
+
function decideOfferFor(store, binding, target, projects, now, root) {
|
|
626
|
+
if (target === null || target === undefined)
|
|
627
|
+
return null;
|
|
628
|
+
const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
|
|
629
|
+
return principal.ok ? decideOffer(store, target, principal.who, now, root) : null;
|
|
630
|
+
}
|
|
604
631
|
/** 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. */
|
|
605
632
|
async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
|
|
606
633
|
if (store.listTelegramTeamChats(botId).length === 0)
|
|
@@ -631,7 +658,7 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
|
|
|
631
658
|
// Routine progress facts are not a problem to fix: a first pairing
|
|
632
659
|
// starts from now and settles them as history. Anything else pending
|
|
633
660
|
// is named, once per pass, as before.
|
|
634
|
-
if (store.listNotifications("pending").some(row => !isLifecycleNotification(row)))
|
|
661
|
+
if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && row.kind !== RESULT_SHOTS_KIND))
|
|
635
662
|
report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
|
|
636
663
|
return;
|
|
637
664
|
}
|
|
@@ -663,7 +690,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
663
690
|
const groups = [];
|
|
664
691
|
for (const row of claimed) {
|
|
665
692
|
const last = groups[groups.length - 1];
|
|
666
|
-
if (!quiet && digest.everyMs !== null &&
|
|
693
|
+
if (!quiet && digest.everyMs !== null && joinsDigest(row) && last !== undefined && joinsDigest(last[0]))
|
|
667
694
|
last.push(row);
|
|
668
695
|
else
|
|
669
696
|
groups.push([row]);
|
|
@@ -760,18 +787,26 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
760
787
|
const sent = await sender(`A screenshot for result #${row.run} could not be sent: ${verified.problem}. Inspect the result before accepting.`);
|
|
761
788
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
762
789
|
}
|
|
790
|
+
// Already sent with its result (result-shots.ts): the same screenshot is not sent again.
|
|
791
|
+
const artifact = Number(match[2]);
|
|
792
|
+
if (store.resultShotsSent(row.destination, row.run).has(artifact))
|
|
793
|
+
return { ok: true, receipt: "skipped:screenshot-sent" };
|
|
794
|
+
// Marked while it goes, so the screenshots sent with the result skip it; any failure unmarks it for the retry.
|
|
795
|
+
store.markResultShotsSent(row.destination, row.run, [artifact], clock());
|
|
763
796
|
// No await between the access fence, file validation and upload.
|
|
764
797
|
let answer;
|
|
765
798
|
try {
|
|
766
799
|
answer = await transport("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
|
|
767
|
-
field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run,
|
|
800
|
+
field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, artifact, verified.format),
|
|
768
801
|
});
|
|
769
802
|
}
|
|
770
803
|
catch {
|
|
804
|
+
store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
|
|
771
805
|
return { ok: false, error: "Screenshot delivery is unconfirmed; retry may duplicate it" };
|
|
772
806
|
}
|
|
773
807
|
const messageId = answer.result?.message_id;
|
|
774
808
|
if (!answer.ok || !Number.isSafeInteger(messageId)) {
|
|
809
|
+
store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
|
|
775
810
|
if (answer.parameters?.retry_after !== undefined)
|
|
776
811
|
store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
|
|
777
812
|
return { ok: false, error: answer.uncertain || (answer.ok && messageId == null) ? "Screenshot delivery is unconfirmed; retry may duplicate it" : "Telegram did not accept the screenshot" };
|
|
@@ -780,7 +815,56 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
780
815
|
const after = (await readAccess()) ?? fence();
|
|
781
816
|
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
|
|
782
817
|
};
|
|
783
|
-
|
|
818
|
+
/** Screenshots with a result (result-shots.ts): photos, several as one album, re-proved before every call. Each is
|
|
819
|
+
* marked sent before it goes, so an unconfirmed send is never repeated; only Telegram's outright refusal unmarks it. */
|
|
820
|
+
const shotsSender = async (row) => {
|
|
821
|
+
let receipt = null;
|
|
822
|
+
for (let call = 0; call <= 4; call++) {
|
|
823
|
+
const problem = (await readAccess()) ?? fence();
|
|
824
|
+
if (problem !== null)
|
|
825
|
+
return { ok: false, error: problem };
|
|
826
|
+
const plan = resultShotsFor(store, evidenceRoot, telegramConversationRepos(store, binding.approver, projects), row, store.resultShotsSent(row.destination, row.run ?? 0));
|
|
827
|
+
if (plan.kind === "none")
|
|
828
|
+
return call > 0 ? { ok: true, receipt } : { ok: true, receipt: `skipped:screenshots-${plan.why}` };
|
|
829
|
+
if (plan.kind === "line") {
|
|
830
|
+
const sent = await sender(plan.text, undefined, [row]);
|
|
831
|
+
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
832
|
+
}
|
|
833
|
+
// No await between the access fence, the files' verification and the upload.
|
|
834
|
+
const photos = plan.shots.filter(one => one.photo);
|
|
835
|
+
const batch = photos.length >= 2 ? photos : [plan.shots[0]];
|
|
836
|
+
const upload = (one, field) => ({ field, fileName: one.fileName, contentType: one.format === "png" ? "image/png" : "image/jpeg", bytes: one.bytes });
|
|
837
|
+
const ids = batch.map(one => one.artifact);
|
|
838
|
+
store.markResultShotsSent(row.destination, plan.run, ids, clock());
|
|
839
|
+
let answer;
|
|
840
|
+
try {
|
|
841
|
+
answer = batch.length > 1
|
|
842
|
+
? await transport("sendMediaGroup", { chat_id: binding.chatId, media: batch.map((one, index) => ({ type: "photo", media: `attach://shot${index}`, ...(index === 0 ? { caption: one.caption } : {}) })) }, undefined, upload(batch[0], "shot0"), batch.slice(1).map((one, index) => upload(one, `shot${index + 1}`)))
|
|
843
|
+
: batch[0].photo
|
|
844
|
+
? await transport("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
|
|
845
|
+
: await transport("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
answer = { ok: false, uncertain: true };
|
|
849
|
+
}
|
|
850
|
+
if (!answer.ok && answer.uncertain !== true) {
|
|
851
|
+
store.unmarkResultShotsSent(row.destination, plan.run, ids);
|
|
852
|
+
if (answer.parameters?.retry_after !== undefined)
|
|
853
|
+
store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
|
|
854
|
+
return { ok: false, error: "Telegram did not accept the screenshots" };
|
|
855
|
+
}
|
|
856
|
+
const messages = (Array.isArray(answer.result) ? answer.result : [answer.result])
|
|
857
|
+
.map(one => one?.message_id).filter((one) => Number.isSafeInteger(one));
|
|
858
|
+
// An unconfirmed send stays marked: it may have arrived, so it is never sent again.
|
|
859
|
+
if (!answer.ok || messages.length === 0)
|
|
860
|
+
report.problems.push(`notification ${row.id}: Telegram did not confirm the screenshots; they are not sent again`);
|
|
861
|
+
for (const one of messages)
|
|
862
|
+
store.recordTelegramMessage(row, binding, String(one), clock());
|
|
863
|
+
receipt = receiptFor(botId, binding.chatId, messages.length === 0 ? null : String(messages[0]));
|
|
864
|
+
}
|
|
865
|
+
return { ok: true, receipt };
|
|
866
|
+
};
|
|
867
|
+
const batched = !quiet && digest.everyMs !== null && joinsDigest(rows[0]);
|
|
784
868
|
const row = rows[0];
|
|
785
869
|
const progressRun = batched ? null : store.telegramProgressRun(row);
|
|
786
870
|
const progress = progressRun !== null && isTelegramProgressNotification(row);
|
|
@@ -885,35 +969,65 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
885
969
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
886
970
|
return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
887
971
|
}
|
|
972
|
+
// A message about one result, plan, failure or pull request acts in place (chat-decide.ts); its buttons are
|
|
973
|
+
// minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps the
|
|
974
|
+
// ones already in the chat.
|
|
975
|
+
const offer = decideOfferFor(store, binding, view.target, projects, clock(), evidenceRoot);
|
|
976
|
+
const text = offer?.text ?? view.text;
|
|
888
977
|
// While a lone update is still the only item, its edit keeps that update's own action button.
|
|
889
|
-
const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
|
|
890
|
-
|
|
891
|
-
const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
|
|
978
|
+
const own = offer === null && lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
|
|
979
|
+
let keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
|
|
980
|
+
const shown = createHash("sha256").update(JSON.stringify(offer === null ? [view.text, keyboard] : [text, offerFingerprint(offer)])).digest("hex");
|
|
981
|
+
let tokens = [];
|
|
982
|
+
// Minted unplaced: they ride a message only once it shows them, and until then the old card's buttons work.
|
|
983
|
+
const mintFor = () => {
|
|
984
|
+
if (offer === null)
|
|
985
|
+
return;
|
|
986
|
+
const minted = mintDecideButtons(store, decideSeat(binding), view.target, offer, clock());
|
|
987
|
+
keyboard = decideKeyboard(phoneOrigin, minted.rows);
|
|
988
|
+
tokens = minted.tokens;
|
|
989
|
+
};
|
|
892
990
|
if (batch.message !== null) {
|
|
893
|
-
if (batch.digest === shown) {
|
|
991
|
+
if (batch.digest === shown && (offer === null || hasLiveDecideTokens(store, "telegram", binding.chatId, batch.message, clock()))) {
|
|
894
992
|
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
895
993
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
|
|
896
994
|
}
|
|
897
995
|
const problem = (await readAccess()) ?? fence();
|
|
898
996
|
if (problem !== null)
|
|
899
997
|
return { ok: false, error: problem };
|
|
900
|
-
|
|
998
|
+
mintFor();
|
|
999
|
+
const edited = await editProgress(transport, binding.chatId, batch.message, text, keyboard);
|
|
901
1000
|
if (edited.ok) {
|
|
1001
|
+
// The repaint landed: the old words' buttons retire and the new ones ride the message.
|
|
1002
|
+
retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
|
|
1003
|
+
placeDecideTokens(store, tokens, batch.message);
|
|
902
1004
|
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
903
1005
|
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
904
1006
|
const after = (await readAccess()) ?? fence();
|
|
905
1007
|
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
|
|
906
1008
|
}
|
|
1009
|
+
// A failed edit: the buttons it carried never act, and the card's old ones keep working.
|
|
1010
|
+
dropDecideTokens(store, tokens, clock());
|
|
907
1011
|
if (edited.retryAfter !== undefined)
|
|
908
1012
|
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
909
1013
|
// Only Telegram's definitive missing/uneditable answer permits a new message.
|
|
910
1014
|
if (!edited.replace)
|
|
911
1015
|
return edited;
|
|
912
1016
|
}
|
|
913
|
-
|
|
1017
|
+
// A new message: its buttons are minted for it, never the ones a failed edit was given.
|
|
1018
|
+
mintFor();
|
|
1019
|
+
const sent = await sender(text, keyboard.length === 0 ? undefined : keyboard, [fact]);
|
|
914
1020
|
const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
|
|
915
1021
|
if (placed !== null)
|
|
916
1022
|
store.setChatBatchMessage(batch.id, placed, shown);
|
|
1023
|
+
if (sent.ok && sent.messageId !== null) {
|
|
1024
|
+
// The new card replaces the old one: only its buttons act.
|
|
1025
|
+
if (batch.message !== null)
|
|
1026
|
+
retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
|
|
1027
|
+
placeDecideTokens(store, tokens, sent.messageId);
|
|
1028
|
+
}
|
|
1029
|
+
else
|
|
1030
|
+
dropDecideTokens(store, tokens, clock());
|
|
917
1031
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
918
1032
|
}
|
|
919
1033
|
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
@@ -944,7 +1058,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
944
1058
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
945
1059
|
};
|
|
946
1060
|
let outcome;
|
|
947
|
-
if (rows.length === 1 && row.kind ===
|
|
1061
|
+
if (rows.length === 1 && row.kind === RESULT_SHOTS_KIND)
|
|
1062
|
+
outcome = await shotsSender(row);
|
|
1063
|
+
else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
|
|
948
1064
|
outcome = await leadSayOutcome(row);
|
|
949
1065
|
else if (quiet && rows.length === 1 && isTaskFact(row))
|
|
950
1066
|
outcome = await quietOutcome(row);
|
|
@@ -988,6 +1104,11 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
988
1104
|
function isUrgent(notification) {
|
|
989
1105
|
return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready";
|
|
990
1106
|
}
|
|
1107
|
+
/** A routine fact the digest may carry. Screenshots are never urgent (they follow their result message, which the
|
|
1108
|
+
* task's order fence holds them behind) and never a digest line: they go as their own photos once it has gone. */
|
|
1109
|
+
function joinsDigest(notification) {
|
|
1110
|
+
return !isUrgent(notification) && notification.kind !== RESULT_SHOTS_KIND;
|
|
1111
|
+
}
|
|
991
1112
|
/** The digest text: a header with the count and the window, then one
|
|
992
1113
|
* fact per entry — its subject, then its body's first line, indented.
|
|
993
1114
|
* Plain text, no buttons: nothing in a digest is tappable. */
|
|
@@ -1066,8 +1187,19 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1066
1187
|
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
1067
1188
|
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
1068
1189
|
// The task's title once: in front, unless the words already name it.
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1190
|
+
// A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link.
|
|
1191
|
+
const target = alreadyAccepted || !current ? null : decideTargetOf(notification);
|
|
1192
|
+
const offer = decideOfferFor(store, binding, target, projects, clock(), evidenceRoot);
|
|
1193
|
+
const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
|
|
1194
|
+
const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1195
|
+
const fallback = offer === null ? decideFallbackLink(target) : null;
|
|
1196
|
+
const button = fallback !== null ? (() => { try {
|
|
1197
|
+
return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
return null;
|
|
1201
|
+
} })()
|
|
1202
|
+
: offer === null && notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
1071
1203
|
// A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
|
|
1072
1204
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
1073
1205
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
@@ -1079,16 +1211,22 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1079
1211
|
let last = null;
|
|
1080
1212
|
for (const [index, part] of parts.entries()) {
|
|
1081
1213
|
const final = index === parts.length - 1;
|
|
1082
|
-
const
|
|
1214
|
+
const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
|
|
1215
|
+
const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : decideKeys.length > 0 ? decideKeys : button !== null ? [button] : undefined;
|
|
1083
1216
|
const sent = await sender(part, keyboard);
|
|
1084
|
-
if (!sent.ok)
|
|
1217
|
+
if (!sent.ok) {
|
|
1218
|
+
if (decided !== null)
|
|
1219
|
+
dropDecideTokens(store, decided.tokens, clock());
|
|
1085
1220
|
return { ok: false, error: sent.error };
|
|
1221
|
+
}
|
|
1086
1222
|
last = sent.messageId;
|
|
1087
1223
|
}
|
|
1088
1224
|
if (flowKeys !== null && last !== null)
|
|
1089
1225
|
store.placeTelegramFlowActions(flowKeys.tokens, last);
|
|
1090
1226
|
if (questionKeys !== null && last !== null)
|
|
1091
1227
|
store.placeTelegramQuestionActions(questionKeys.tokens, last);
|
|
1228
|
+
if (decided !== null && last !== null)
|
|
1229
|
+
placeDecideTokens(store, decided.tokens, last);
|
|
1092
1230
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
|
|
1093
1231
|
}
|
|
1094
1232
|
// A decision. Every safety-bearing word goes out before anything tappable
|
|
@@ -1335,13 +1473,15 @@ async function projectsForTap(context, update) {
|
|
|
1335
1473
|
const callback = update.callback_query;
|
|
1336
1474
|
// Flow decision buttons (v86) work with or without the lead's conversation on this phone.
|
|
1337
1475
|
const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
|
|
1338
|
-
|
|
1476
|
+
// So do a result's, plan's or pull request's own buttons (chat-decide.ts).
|
|
1477
|
+
const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
|
|
1478
|
+
if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
|
|
1339
1479
|
return null;
|
|
1340
1480
|
const binding = callback.from === undefined ? null : context.store.liveTelegramBindingFor(context.botId, String(callback.from.id));
|
|
1341
1481
|
if (binding === null || callback.from === undefined ||
|
|
1342
1482
|
callback.message?.chat === undefined ||
|
|
1343
1483
|
// Proposal cards and task picks read the chat's project ceiling.
|
|
1344
|
-
(context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !(callback.data ?? "").startsWith("pick:")))
|
|
1484
|
+
(context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !decideTap && !(callback.data ?? "").startsWith("pick:")))
|
|
1345
1485
|
return null;
|
|
1346
1486
|
const chat = callback.message.chat;
|
|
1347
1487
|
const chatId = String(chat.id);
|
|
@@ -1482,6 +1622,26 @@ function applyMessage(context, update, effects) {
|
|
|
1482
1622
|
}
|
|
1483
1623
|
return;
|
|
1484
1624
|
}
|
|
1625
|
+
// After Request changes asked "What should change?": the person's next message in their own chat is the feedback.
|
|
1626
|
+
// A reply to some other message (a decision, a result) keeps its own meaning.
|
|
1627
|
+
const decidePrompt = binding !== null && chat.type === "private" && String(chat.id) === binding.chatId && typeof message.text === "string" && message.text.trim() !== "" && !message.text.startsWith("/")
|
|
1628
|
+
? openPromptFor(store, { channel: "telegram", binding: binding.id, chat: binding.chatId }, clock()) : null;
|
|
1629
|
+
if (binding !== null && decidePrompt !== null && answersPrompt(decidePrompt, message.reply_to_message === undefined ? null : String(message.reply_to_message.message_id))) {
|
|
1630
|
+
const repos = context.projects === null ? [] : telegramConversationRepos(store, binding.approver, context.projects);
|
|
1631
|
+
const answer = applyDecideFeedback(store, decideSeat(binding), decidePrompt, message.text ?? "", repos, context.conversation?.evidenceRoot, clock());
|
|
1632
|
+
effects.push(async () => {
|
|
1633
|
+
let button = null;
|
|
1634
|
+
try {
|
|
1635
|
+
button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, answer.link);
|
|
1636
|
+
}
|
|
1637
|
+
catch {
|
|
1638
|
+
button = null;
|
|
1639
|
+
}
|
|
1640
|
+
await transport("sendMessage", { chat_id: binding.chatId, text: answer.said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
|
|
1641
|
+
...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
|
|
1642
|
+
});
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1485
1645
|
// A message to a teammate by name (v96), in the person's own chat: a card on its desk, and the answer comes back here.
|
|
1486
1646
|
if (binding !== null && message.reply_to_message === undefined && message.chat?.type === "private" && String(chat.id) === binding.chatId && store.accountOf(binding.approver)?.role === "approver") {
|
|
1487
1647
|
const repos = context.projects === null ? store.knownRepos().filter(repo => store.accountCanAccess(binding.approver, repo)) : telegramConversationRepos(store, binding.approver, context.projects);
|
|
@@ -1881,6 +2041,14 @@ function applyCallback(context, update, effects) {
|
|
|
1881
2041
|
const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
|
|
1882
2042
|
const tapChat = message?.chat === undefined ? null : String(message.chat.id);
|
|
1883
2043
|
const followedGroup = tapChat !== null && binding !== null && tapChat !== binding.chatId && store.telegramTeamChat(botId, tapChat)?.kind === "group";
|
|
2044
|
+
// A result's, plan's, failure's or pull request's own buttons act only in the paired person's own chat; a tap
|
|
2045
|
+
// anywhere else is still answered, so the button never just spins.
|
|
2046
|
+
if (isDecideToken(store, token) && (binding === null || tapChat === null || tapChat !== binding.chatId)) {
|
|
2047
|
+
report.ignored++;
|
|
2048
|
+
ack(binding === null ? "These buttons work only for the person they were sent to. Nothing was done."
|
|
2049
|
+
: "These buttons work only in your own chat with the bot. Nothing was done.");
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
1884
2052
|
if (binding === null ||
|
|
1885
2053
|
from === undefined ||
|
|
1886
2054
|
message === undefined ||
|
|
@@ -2010,6 +2178,56 @@ function applyCallback(context, update, effects) {
|
|
|
2010
2178
|
editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
|
|
2011
2179
|
return;
|
|
2012
2180
|
}
|
|
2181
|
+
// A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): in the person's own chat only.
|
|
2182
|
+
if (isDecideToken(store, token)) {
|
|
2183
|
+
const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
|
|
2184
|
+
if (repos === null) {
|
|
2185
|
+
ack("Your projects couldn't be read just now. Try again.");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
const messageId = String(message.message_id);
|
|
2189
|
+
const tapped = applyDecideTap(store, decideSeat(binding), { token, message: messageId, shown: message.text ?? "", repos, ...(context.conversation === undefined ? {} : { root: context.conversation.evidenceRoot }), now: clock() });
|
|
2190
|
+
if (tapped === null) {
|
|
2191
|
+
report.ignored++;
|
|
2192
|
+
ack("That button doesn't do anything now.");
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
if (tapped.ignored === true)
|
|
2196
|
+
report.ignored++;
|
|
2197
|
+
ack(tapped.ack.slice(0, 190));
|
|
2198
|
+
if (tapped.edit !== undefined)
|
|
2199
|
+
editText(tapped.edit.text.slice(0, 4000), decideKeyboard(context.conversation?.phoneOrigin, tapped.edit.rows));
|
|
2200
|
+
if (tapped.prompt !== undefined) {
|
|
2201
|
+
const prompt = tapped.prompt;
|
|
2202
|
+
effects.push(async () => {
|
|
2203
|
+
const answer = await transport("sendMessage", { chat_id: binding.chatId, text: prompt.text, link_preview_options: { is_disabled: true },
|
|
2204
|
+
reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: "What should change?" } });
|
|
2205
|
+
const id = answer.result?.message_id;
|
|
2206
|
+
if (answer.ok && Number.isSafeInteger(id))
|
|
2207
|
+
placeDecidePrompt(store, prompt.id, String(id));
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
if (tapped.merge !== undefined) {
|
|
2211
|
+
const merge = tapped.merge, shown = tapped.edit?.text ?? message.text ?? "";
|
|
2212
|
+
// GitHub is a network call: after the tap's transaction, then the card says how it went.
|
|
2213
|
+
effects.push(async () => {
|
|
2214
|
+
let merged;
|
|
2215
|
+
try {
|
|
2216
|
+
merged = context.conversation?.merge !== undefined ? await context.conversation.merge({ runId: merge.runId, by: merge.by })
|
|
2217
|
+
: await mergePullRequest(store, { runId: merge.runId, by: merge.by, clock });
|
|
2218
|
+
}
|
|
2219
|
+
catch {
|
|
2220
|
+
merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
|
|
2221
|
+
}
|
|
2222
|
+
// The ledger says what happened, failure included, once GitHub has answered.
|
|
2223
|
+
recordChatMerge(store, merge, merged.ok ? { ok: true } : { ok: false, message: merged.message }, clock());
|
|
2224
|
+
// A merge that didn't land keeps a way to the task.
|
|
2225
|
+
await transport("editMessageText", { chat_id: binding.chatId, message_id: message.message_id, text: mergedText(shown, merged.ok ? { ok: true } : { ok: false, message: merged.message }).slice(0, 4000),
|
|
2226
|
+
link_preview_options: { is_disabled: true }, reply_markup: { inline_keyboard: merged.ok ? [] : decideKeyboard(context.conversation?.phoneOrigin, linksFor({ kind: "merge", taskId: merge.taskId, run: merge.runId })) } });
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2013
2231
|
const action = store.getTelegramAction(token);
|
|
2014
2232
|
if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
|
|
2015
2233
|
// A proposal card's button: the shared confirm door, inside this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.19",
|
|
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"
|