toolroll 0.9.17 → 0.9.18
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 +11 -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 +32 -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 +17 -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 +51 -0
- package/dist/result-shots.js +119 -0
- package/dist/serve.js +23 -21
- package/dist/slack-chat.js +18 -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 +226 -20
- 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
|
}
|
|
@@ -780,6 +807,55 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
780
807
|
const after = (await readAccess()) ?? fence();
|
|
781
808
|
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
|
|
782
809
|
};
|
|
810
|
+
/** Screenshots with a result (result-shots.ts): photos, several as one album, re-proved before every call. Each is
|
|
811
|
+
* marked sent before it goes, so an unconfirmed send is never repeated; only Telegram's outright refusal unmarks it. */
|
|
812
|
+
const shotsSender = async (row) => {
|
|
813
|
+
let receipt = null;
|
|
814
|
+
for (let call = 0; call <= 4; call++) {
|
|
815
|
+
const problem = (await readAccess()) ?? fence();
|
|
816
|
+
if (problem !== null)
|
|
817
|
+
return { ok: false, error: problem };
|
|
818
|
+
const plan = resultShotsFor(store, evidenceRoot, telegramConversationRepos(store, binding.approver, projects), row, store.resultShotsSent(row.destination, row.run ?? 0));
|
|
819
|
+
if (plan.kind === "none")
|
|
820
|
+
return call > 0 ? { ok: true, receipt } : { ok: true, receipt: `skipped:screenshots-${plan.why}` };
|
|
821
|
+
if (plan.kind === "line") {
|
|
822
|
+
const sent = await sender(plan.text, undefined, [row]);
|
|
823
|
+
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
824
|
+
}
|
|
825
|
+
// No await between the access fence, the files' verification and the upload.
|
|
826
|
+
const photos = plan.shots.filter(one => one.photo);
|
|
827
|
+
const batch = photos.length >= 2 ? photos : [plan.shots[0]];
|
|
828
|
+
const upload = (one, field) => ({ field, fileName: one.fileName, contentType: one.format === "png" ? "image/png" : "image/jpeg", bytes: one.bytes });
|
|
829
|
+
const ids = batch.map(one => one.artifact);
|
|
830
|
+
store.markResultShotsSent(row.destination, plan.run, ids, clock());
|
|
831
|
+
let answer;
|
|
832
|
+
try {
|
|
833
|
+
answer = batch.length > 1
|
|
834
|
+
? 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}`)))
|
|
835
|
+
: batch[0].photo
|
|
836
|
+
? await transport("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
|
|
837
|
+
: await transport("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
answer = { ok: false, uncertain: true };
|
|
841
|
+
}
|
|
842
|
+
if (!answer.ok && answer.uncertain !== true) {
|
|
843
|
+
store.unmarkResultShotsSent(row.destination, plan.run, ids);
|
|
844
|
+
if (answer.parameters?.retry_after !== undefined)
|
|
845
|
+
store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
|
|
846
|
+
return { ok: false, error: "Telegram did not accept the screenshots" };
|
|
847
|
+
}
|
|
848
|
+
const messages = (Array.isArray(answer.result) ? answer.result : [answer.result])
|
|
849
|
+
.map(one => one?.message_id).filter((one) => Number.isSafeInteger(one));
|
|
850
|
+
// An unconfirmed send stays marked: it may have arrived, so it is never sent again.
|
|
851
|
+
if (!answer.ok || messages.length === 0)
|
|
852
|
+
report.problems.push(`notification ${row.id}: Telegram did not confirm the screenshots; they are not sent again`);
|
|
853
|
+
for (const one of messages)
|
|
854
|
+
store.recordTelegramMessage(row, binding, String(one), clock());
|
|
855
|
+
receipt = receiptFor(botId, binding.chatId, messages.length === 0 ? null : String(messages[0]));
|
|
856
|
+
}
|
|
857
|
+
return { ok: true, receipt };
|
|
858
|
+
};
|
|
783
859
|
const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
|
|
784
860
|
const row = rows[0];
|
|
785
861
|
const progressRun = batched ? null : store.telegramProgressRun(row);
|
|
@@ -885,35 +961,65 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
885
961
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
886
962
|
return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
887
963
|
}
|
|
964
|
+
// A message about one result, plan, failure or pull request acts in place (chat-decide.ts); its buttons are
|
|
965
|
+
// minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps the
|
|
966
|
+
// ones already in the chat.
|
|
967
|
+
const offer = decideOfferFor(store, binding, view.target, projects, clock(), evidenceRoot);
|
|
968
|
+
const text = offer?.text ?? view.text;
|
|
888
969
|
// 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");
|
|
970
|
+
const own = offer === null && lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
|
|
971
|
+
let keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
|
|
972
|
+
const shown = createHash("sha256").update(JSON.stringify(offer === null ? [view.text, keyboard] : [text, offerFingerprint(offer)])).digest("hex");
|
|
973
|
+
let tokens = [];
|
|
974
|
+
// Minted unplaced: they ride a message only once it shows them, and until then the old card's buttons work.
|
|
975
|
+
const mintFor = () => {
|
|
976
|
+
if (offer === null)
|
|
977
|
+
return;
|
|
978
|
+
const minted = mintDecideButtons(store, decideSeat(binding), view.target, offer, clock());
|
|
979
|
+
keyboard = decideKeyboard(phoneOrigin, minted.rows);
|
|
980
|
+
tokens = minted.tokens;
|
|
981
|
+
};
|
|
892
982
|
if (batch.message !== null) {
|
|
893
|
-
if (batch.digest === shown) {
|
|
983
|
+
if (batch.digest === shown && (offer === null || hasLiveDecideTokens(store, "telegram", binding.chatId, batch.message, clock()))) {
|
|
894
984
|
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
895
985
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
|
|
896
986
|
}
|
|
897
987
|
const problem = (await readAccess()) ?? fence();
|
|
898
988
|
if (problem !== null)
|
|
899
989
|
return { ok: false, error: problem };
|
|
900
|
-
|
|
990
|
+
mintFor();
|
|
991
|
+
const edited = await editProgress(transport, binding.chatId, batch.message, text, keyboard);
|
|
901
992
|
if (edited.ok) {
|
|
993
|
+
// The repaint landed: the old words' buttons retire and the new ones ride the message.
|
|
994
|
+
retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
|
|
995
|
+
placeDecideTokens(store, tokens, batch.message);
|
|
902
996
|
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
903
997
|
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
904
998
|
const after = (await readAccess()) ?? fence();
|
|
905
999
|
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
|
|
906
1000
|
}
|
|
1001
|
+
// A failed edit: the buttons it carried never act, and the card's old ones keep working.
|
|
1002
|
+
dropDecideTokens(store, tokens, clock());
|
|
907
1003
|
if (edited.retryAfter !== undefined)
|
|
908
1004
|
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
909
1005
|
// Only Telegram's definitive missing/uneditable answer permits a new message.
|
|
910
1006
|
if (!edited.replace)
|
|
911
1007
|
return edited;
|
|
912
1008
|
}
|
|
913
|
-
|
|
1009
|
+
// A new message: its buttons are minted for it, never the ones a failed edit was given.
|
|
1010
|
+
mintFor();
|
|
1011
|
+
const sent = await sender(text, keyboard.length === 0 ? undefined : keyboard, [fact]);
|
|
914
1012
|
const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
|
|
915
1013
|
if (placed !== null)
|
|
916
1014
|
store.setChatBatchMessage(batch.id, placed, shown);
|
|
1015
|
+
if (sent.ok && sent.messageId !== null) {
|
|
1016
|
+
// The new card replaces the old one: only its buttons act.
|
|
1017
|
+
if (batch.message !== null)
|
|
1018
|
+
retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
|
|
1019
|
+
placeDecideTokens(store, tokens, sent.messageId);
|
|
1020
|
+
}
|
|
1021
|
+
else
|
|
1022
|
+
dropDecideTokens(store, tokens, clock());
|
|
917
1023
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
918
1024
|
}
|
|
919
1025
|
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
@@ -944,7 +1050,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
944
1050
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
945
1051
|
};
|
|
946
1052
|
let outcome;
|
|
947
|
-
if (rows.length === 1 && row.kind ===
|
|
1053
|
+
if (rows.length === 1 && row.kind === RESULT_SHOTS_KIND)
|
|
1054
|
+
outcome = await shotsSender(row);
|
|
1055
|
+
else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
|
|
948
1056
|
outcome = await leadSayOutcome(row);
|
|
949
1057
|
else if (quiet && rows.length === 1 && isTaskFact(row))
|
|
950
1058
|
outcome = await quietOutcome(row);
|
|
@@ -986,7 +1094,8 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
986
1094
|
}
|
|
987
1095
|
/** What pages singly whatever the cadence: a decision, or an attention-class fact. */
|
|
988
1096
|
function isUrgent(notification) {
|
|
989
|
-
return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready"
|
|
1097
|
+
return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready"
|
|
1098
|
+
|| notification.kind === RESULT_SHOTS_KIND;
|
|
990
1099
|
}
|
|
991
1100
|
/** The digest text: a header with the count and the window, then one
|
|
992
1101
|
* fact per entry — its subject, then its body's first line, indented.
|
|
@@ -1066,8 +1175,19 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1066
1175
|
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
1067
1176
|
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
1068
1177
|
// The task's title once: in front, unless the words already name it.
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1178
|
+
// A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link.
|
|
1179
|
+
const target = alreadyAccepted || !current ? null : decideTargetOf(notification);
|
|
1180
|
+
const offer = decideOfferFor(store, binding, target, projects, clock(), evidenceRoot);
|
|
1181
|
+
const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
|
|
1182
|
+
const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1183
|
+
const fallback = offer === null ? decideFallbackLink(target) : null;
|
|
1184
|
+
const button = fallback !== null ? (() => { try {
|
|
1185
|
+
return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return null;
|
|
1189
|
+
} })()
|
|
1190
|
+
: offer === null && notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
1071
1191
|
// A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
|
|
1072
1192
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
1073
1193
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
@@ -1079,16 +1199,22 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1079
1199
|
let last = null;
|
|
1080
1200
|
for (const [index, part] of parts.entries()) {
|
|
1081
1201
|
const final = index === parts.length - 1;
|
|
1082
|
-
const
|
|
1202
|
+
const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
|
|
1203
|
+
const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : decideKeys.length > 0 ? decideKeys : button !== null ? [button] : undefined;
|
|
1083
1204
|
const sent = await sender(part, keyboard);
|
|
1084
|
-
if (!sent.ok)
|
|
1205
|
+
if (!sent.ok) {
|
|
1206
|
+
if (decided !== null)
|
|
1207
|
+
dropDecideTokens(store, decided.tokens, clock());
|
|
1085
1208
|
return { ok: false, error: sent.error };
|
|
1209
|
+
}
|
|
1086
1210
|
last = sent.messageId;
|
|
1087
1211
|
}
|
|
1088
1212
|
if (flowKeys !== null && last !== null)
|
|
1089
1213
|
store.placeTelegramFlowActions(flowKeys.tokens, last);
|
|
1090
1214
|
if (questionKeys !== null && last !== null)
|
|
1091
1215
|
store.placeTelegramQuestionActions(questionKeys.tokens, last);
|
|
1216
|
+
if (decided !== null && last !== null)
|
|
1217
|
+
placeDecideTokens(store, decided.tokens, last);
|
|
1092
1218
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
|
|
1093
1219
|
}
|
|
1094
1220
|
// A decision. Every safety-bearing word goes out before anything tappable
|
|
@@ -1335,13 +1461,15 @@ async function projectsForTap(context, update) {
|
|
|
1335
1461
|
const callback = update.callback_query;
|
|
1336
1462
|
// Flow decision buttons (v86) work with or without the lead's conversation on this phone.
|
|
1337
1463
|
const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
|
|
1338
|
-
|
|
1464
|
+
// So do a result's, plan's or pull request's own buttons (chat-decide.ts).
|
|
1465
|
+
const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
|
|
1466
|
+
if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
|
|
1339
1467
|
return null;
|
|
1340
1468
|
const binding = callback.from === undefined ? null : context.store.liveTelegramBindingFor(context.botId, String(callback.from.id));
|
|
1341
1469
|
if (binding === null || callback.from === undefined ||
|
|
1342
1470
|
callback.message?.chat === undefined ||
|
|
1343
1471
|
// 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:")))
|
|
1472
|
+
(context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !decideTap && !(callback.data ?? "").startsWith("pick:")))
|
|
1345
1473
|
return null;
|
|
1346
1474
|
const chat = callback.message.chat;
|
|
1347
1475
|
const chatId = String(chat.id);
|
|
@@ -1482,6 +1610,26 @@ function applyMessage(context, update, effects) {
|
|
|
1482
1610
|
}
|
|
1483
1611
|
return;
|
|
1484
1612
|
}
|
|
1613
|
+
// After Request changes asked "What should change?": the person's next message in their own chat is the feedback.
|
|
1614
|
+
// A reply to some other message (a decision, a result) keeps its own meaning.
|
|
1615
|
+
const decidePrompt = binding !== null && chat.type === "private" && String(chat.id) === binding.chatId && typeof message.text === "string" && message.text.trim() !== "" && !message.text.startsWith("/")
|
|
1616
|
+
? openPromptFor(store, { channel: "telegram", binding: binding.id, chat: binding.chatId }, clock()) : null;
|
|
1617
|
+
if (binding !== null && decidePrompt !== null && answersPrompt(decidePrompt, message.reply_to_message === undefined ? null : String(message.reply_to_message.message_id))) {
|
|
1618
|
+
const repos = context.projects === null ? [] : telegramConversationRepos(store, binding.approver, context.projects);
|
|
1619
|
+
const answer = applyDecideFeedback(store, decideSeat(binding), decidePrompt, message.text ?? "", repos, context.conversation?.evidenceRoot, clock());
|
|
1620
|
+
effects.push(async () => {
|
|
1621
|
+
let button = null;
|
|
1622
|
+
try {
|
|
1623
|
+
button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, answer.link);
|
|
1624
|
+
}
|
|
1625
|
+
catch {
|
|
1626
|
+
button = null;
|
|
1627
|
+
}
|
|
1628
|
+
await transport("sendMessage", { chat_id: binding.chatId, text: answer.said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
|
|
1629
|
+
...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
|
|
1630
|
+
});
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1485
1633
|
// 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
1634
|
if (binding !== null && message.reply_to_message === undefined && message.chat?.type === "private" && String(chat.id) === binding.chatId && store.accountOf(binding.approver)?.role === "approver") {
|
|
1487
1635
|
const repos = context.projects === null ? store.knownRepos().filter(repo => store.accountCanAccess(binding.approver, repo)) : telegramConversationRepos(store, binding.approver, context.projects);
|
|
@@ -1881,6 +2029,14 @@ function applyCallback(context, update, effects) {
|
|
|
1881
2029
|
const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
|
|
1882
2030
|
const tapChat = message?.chat === undefined ? null : String(message.chat.id);
|
|
1883
2031
|
const followedGroup = tapChat !== null && binding !== null && tapChat !== binding.chatId && store.telegramTeamChat(botId, tapChat)?.kind === "group";
|
|
2032
|
+
// A result's, plan's, failure's or pull request's own buttons act only in the paired person's own chat; a tap
|
|
2033
|
+
// anywhere else is still answered, so the button never just spins.
|
|
2034
|
+
if (isDecideToken(store, token) && (binding === null || tapChat === null || tapChat !== binding.chatId)) {
|
|
2035
|
+
report.ignored++;
|
|
2036
|
+
ack(binding === null ? "These buttons work only for the person they were sent to. Nothing was done."
|
|
2037
|
+
: "These buttons work only in your own chat with the bot. Nothing was done.");
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
1884
2040
|
if (binding === null ||
|
|
1885
2041
|
from === undefined ||
|
|
1886
2042
|
message === undefined ||
|
|
@@ -2010,6 +2166,56 @@ function applyCallback(context, update, effects) {
|
|
|
2010
2166
|
editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
|
|
2011
2167
|
return;
|
|
2012
2168
|
}
|
|
2169
|
+
// A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): in the person's own chat only.
|
|
2170
|
+
if (isDecideToken(store, token)) {
|
|
2171
|
+
const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
|
|
2172
|
+
if (repos === null) {
|
|
2173
|
+
ack("Your projects couldn't be read just now. Try again.");
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
const messageId = String(message.message_id);
|
|
2177
|
+
const tapped = applyDecideTap(store, decideSeat(binding), { token, message: messageId, shown: message.text ?? "", repos, ...(context.conversation === undefined ? {} : { root: context.conversation.evidenceRoot }), now: clock() });
|
|
2178
|
+
if (tapped === null) {
|
|
2179
|
+
report.ignored++;
|
|
2180
|
+
ack("That button doesn't do anything now.");
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
if (tapped.ignored === true)
|
|
2184
|
+
report.ignored++;
|
|
2185
|
+
ack(tapped.ack.slice(0, 190));
|
|
2186
|
+
if (tapped.edit !== undefined)
|
|
2187
|
+
editText(tapped.edit.text.slice(0, 4000), decideKeyboard(context.conversation?.phoneOrigin, tapped.edit.rows));
|
|
2188
|
+
if (tapped.prompt !== undefined) {
|
|
2189
|
+
const prompt = tapped.prompt;
|
|
2190
|
+
effects.push(async () => {
|
|
2191
|
+
const answer = await transport("sendMessage", { chat_id: binding.chatId, text: prompt.text, link_preview_options: { is_disabled: true },
|
|
2192
|
+
reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: "What should change?" } });
|
|
2193
|
+
const id = answer.result?.message_id;
|
|
2194
|
+
if (answer.ok && Number.isSafeInteger(id))
|
|
2195
|
+
placeDecidePrompt(store, prompt.id, String(id));
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
if (tapped.merge !== undefined) {
|
|
2199
|
+
const merge = tapped.merge, shown = tapped.edit?.text ?? message.text ?? "";
|
|
2200
|
+
// GitHub is a network call: after the tap's transaction, then the card says how it went.
|
|
2201
|
+
effects.push(async () => {
|
|
2202
|
+
let merged;
|
|
2203
|
+
try {
|
|
2204
|
+
merged = context.conversation?.merge !== undefined ? await context.conversation.merge({ runId: merge.runId, by: merge.by })
|
|
2205
|
+
: await mergePullRequest(store, { runId: merge.runId, by: merge.by, clock });
|
|
2206
|
+
}
|
|
2207
|
+
catch {
|
|
2208
|
+
merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
|
|
2209
|
+
}
|
|
2210
|
+
// The ledger says what happened, failure included, once GitHub has answered.
|
|
2211
|
+
recordChatMerge(store, merge, merged.ok ? { ok: true } : { ok: false, message: merged.message }, clock());
|
|
2212
|
+
// A merge that didn't land keeps a way to the task.
|
|
2213
|
+
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),
|
|
2214
|
+
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 })) } });
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2013
2219
|
const action = store.getTelegramAction(token);
|
|
2014
2220
|
if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
|
|
2015
2221
|
// 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.18",
|
|
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"
|