toolroll 0.9.42 → 0.9.43

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.
@@ -22,12 +22,13 @@
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { LIMITS } from "./decision.js";
24
24
  import { keptDraft } from "./flow-draft.js";
25
- import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
26
- import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
25
+ import { decideFlowCard, draftFor, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
26
+ import { chooseFlowCard, flowChoiceAt, flowPersonOf, flowSendPaths, readFlowSend } from "./flow-send.js";
27
27
  import { deciderOf, FLOW_END, replyTarget } from "./flows.js";
28
28
  import { phoneLinkButton } from "./telegram-mate.js";
29
29
  import { telegramButton } from "./contracts/telegram-callback.js";
30
- export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
30
+ /** A flow decision's notice: one per visit, and again (`:ask:<ms>`) when the owner asks the lead to approve it. */
31
+ export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})(?::ask:[0-9]{1,15})?$/;
31
32
  const DRAFT_LIMIT = 4000;
32
33
  /** The card, if it is still at that visit, waiting at a decision. */
33
34
  export function flowDecisionAt(store, cardId, entry) {
@@ -126,24 +127,66 @@ export function applyFlowChoiceTap(store, binding, choice, message, repos, now)
126
127
  const said = chosen.said.startsWith(`${choice.label}.`) ? chosen.said : `You chose “${choice.label}”. ${chosen.said}`;
127
128
  return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ ${said}`.slice(0, 4000) }];
128
129
  }
130
+ /** A card's own fresh buttons, as a repaint carries them. */
131
+ function freshButtons(store, binding, waiting, now) {
132
+ const minted = flowButtons(store, binding, waiting, now);
133
+ return { keyboard: minted.keyboard.map(row => row.flatMap(one => "callback_data" in one && typeof one.callback_data === "string" ? [{ label: one.text, token: one.callback_data }] : [])), place: minted.tokens };
134
+ }
135
+ const CONFIRM_TTL_MS = 10 * 60_000;
136
+ const FLOW_QUESTION = /\n\nApprove “[^\n]*”\? [^\n]*$/;
137
+ /** Where a card is now, for a message whose buttons no longer act: named and linked only when it is in one of the
138
+ * viewer's projects, and linked only while it still waits on someone. Never a live button. */
139
+ export function flowWhereNow(store, cardId, repos) {
140
+ const card = store.getFlowCard(cardId);
141
+ const flow = card === null ? null : store.getFlow(card.flow);
142
+ if (card === null || flow === null || repos === null || !repos.includes(flow.repo))
143
+ return { said: "This card has moved on since.", keyboard: [] };
144
+ if (card.state !== "active")
145
+ return { said: card.state === "done" ? "This card is done." : "This card was closed.", keyboard: [] };
146
+ const definition = flowDefinitionOf(flow);
147
+ const stage = definition?.stages.find(one => one.id === card.stage);
148
+ const where = stage?.title ?? card.stage;
149
+ const open = { label: "Open it in Flows", path: flowCardHref(flow.id, card.id) };
150
+ if (stage?.kind === "approval" || stage?.kind === "choose") {
151
+ const person = stage.kind === "approval" ? deciderOf(stage, flow) ?? "an approver" : flowPersonOf(card, flow);
152
+ return { said: `It's waiting for ${person} in ${where} (${flow.name}).`, keyboard: [[{ label: open.label, link: open }]] };
153
+ }
154
+ if (card.waiting !== null)
155
+ return { said: `It's in ${where} now (${flow.name}): ${card.waiting.replace(/\.$/, "")}.`, keyboard: [[{ label: open.label, link: open }]] };
156
+ return { said: `It moved on to ${where} (${flow.name}).`, keyboard: [] };
157
+ }
129
158
  /** A tapped flow button, applied inside the update's transaction. */
130
159
  export function applyFlowTap(store, binding, action, message, repos, now) {
131
- if (action.consumedAt !== null || action.expiresAt <= now.toISOString())
132
- return [{ kind: "ack", text: "That was already decided, or these buttons are too old." }];
160
+ if (action.consumedAt !== null || action.expiresAt <= now.toISOString()) {
161
+ // Spent or too old: say where the card is now, with a way to it while it still waits. The old button never acts again.
162
+ const now_ = flowWhereNow(store, action.card, repos);
163
+ return [{ kind: "ack", text: "These buttons were already used, or are too old." }, { kind: "edit", text: `${message.text.replace(FLOW_QUESTION, "")}\n\nThese buttons were already used, or are too old. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
164
+ }
133
165
  const waiting = flowDecisionAt(store, action.card, action.entry);
134
- if (waiting === null)
135
- return [{ kind: "ack", text: "That card has moved on since; nothing was changed." }];
166
+ if (waiting === null) {
167
+ const now_ = flowWhereNow(store, action.card, repos);
168
+ store.retireTelegramFlowVisit(action.card, action.entry, now);
169
+ return [{ kind: "ack", text: "That card has moved on since; nothing was changed." }, { kind: "edit", text: `${message.text}\n\nNothing was changed. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
170
+ }
136
171
  const decider = deciderOf(waiting.stage, waiting.flow);
137
172
  if (decider !== null && decider !== binding.approver)
138
173
  return [{ kind: "ack", text: `Only ${decider} decides here.` }];
139
174
  if (action.action === "approve") {
175
+ // The first tap asks; only the Yes decides, bound to exactly this visit of this card, on this message.
140
176
  if (repos === null)
141
177
  return [{ kind: "ack", text: "Couldn't check your projects just now. Try again in a moment." }];
142
- const decided = decideFlowCard(store, { card: waiting.card.id, decision: "approve", note: null, actor: binding.approver, repos, entry: action.entry }, now);
143
- if (!decided.ok)
144
- return [{ kind: "ack", text: decided.message.slice(0, 190) }];
178
+ if (!repos.includes(waiting.flow.repo))
179
+ return [{ kind: "ack", text: "That card isn't in one of your projects now." }];
145
180
  store.retireTelegramFlowVisit(action.card, action.entry, now);
146
- return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${message.text}\n\n✅ ${decided.said}`.slice(0, 4000) }];
181
+ const mint = (phase) => {
182
+ const token = randomBytes(16).toString("hex");
183
+ store.handle.prepare("INSERT INTO telegram_flow_confirm (token, binding, chat_id, message_id, card, entry, phase, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)")
184
+ .run(token, binding.id, binding.chatId, message.messageId, action.card, action.entry, phase, now.toISOString(), new Date(now.getTime() + CONFIRM_TTL_MS).toISOString());
185
+ return token;
186
+ };
187
+ const where = waiting.stage.next === null ? "The card is then done." : `It moves to ${titleOf(waiting, waiting.stage.next)}.`;
188
+ return [{ kind: "ack", text: "Confirm below." }, { kind: "edit", text: `${message.text}\n\nApprove “${waiting.card.title}”? ${where}`.slice(0, 4000),
189
+ keyboard: [[{ label: "Yes", token: mint("yes") }, { label: "Cancel", token: mint("cancel") }]] }];
147
190
  }
148
191
  const prompt = { chatId: binding.chatId, binding: binding.id, card: action.card, entry: action.entry, mode: action.action };
149
192
  if (action.action === "edit") {
@@ -154,6 +197,47 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
154
197
  return [{ kind: "ack", text: "What should change?" }, { kind: "prompt", prompt, placeholder: "What should change",
155
198
  text: `What should change on “${waiting.card.title}”? Reply to this message and it goes back to ${back} with your note.` }];
156
199
  }
200
+ /** A Yes or Cancel this module armed, or null when the token isn't one. */
201
+ export function flowConfirmOf(store, token) {
202
+ if (!/^[0-9a-f]{32}$/.test(token))
203
+ return null;
204
+ const row = store.handle.prepare("SELECT * FROM telegram_flow_confirm WHERE token = ?").get(token);
205
+ if (row === undefined)
206
+ return null;
207
+ return { token: String(row["token"]), binding: Number(row["binding"]), chatId: String(row["chat_id"]), messageId: String(row["message_id"]), card: Number(row["card"]), entry: Number(row["entry"]),
208
+ phase: String(row["phase"]) === "cancel" ? "cancel" : "yes", expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] == null ? null : String(row["consumed_at"]) };
209
+ }
210
+ /** The second tap, inside the update's transaction: Yes decides the card through the console's door, once; Cancel puts
211
+ * the card's own buttons back. The caller has proved the binding, chat and message are the ones it was armed on. */
212
+ export function applyFlowConfirm(store, binding, confirm, message, repos, now) {
213
+ const body = message.text.replace(FLOW_QUESTION, "");
214
+ const spend = () => store.handle.prepare("UPDATE telegram_flow_confirm SET consumed_at = ? WHERE card = ? AND entry = ? AND message_id = ? AND consumed_at IS NULL").run(now.toISOString(), confirm.card, confirm.entry, confirm.messageId);
215
+ if (confirm.consumedAt !== null || confirm.expiresAt <= now.toISOString()) {
216
+ spend();
217
+ const now_ = flowWhereNow(store, confirm.card, repos);
218
+ const waiting = flowDecisionAt(store, confirm.card, confirm.entry);
219
+ // Too old, but the same visit still waits: fresh buttons, never the old Yes.
220
+ if (waiting !== null && repos !== null && repos.includes(waiting.flow.repo)) {
221
+ return [{ kind: "ack", text: "That Yes expired. Nothing was done." }, { kind: "edit", text: body, ...freshButtons(store, binding, waiting, now) }];
222
+ }
223
+ return [{ kind: "ack", text: "That Yes expired. Nothing was done." }, { kind: "edit", text: `${body}\n\nNothing was done. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
224
+ }
225
+ spend();
226
+ const waiting = flowDecisionAt(store, confirm.card, confirm.entry);
227
+ if (waiting === null || repos === null) {
228
+ const now_ = flowWhereNow(store, confirm.card, repos);
229
+ return [{ kind: "ack", text: repos === null ? "Couldn't check your projects just now. Try again in a moment." : "That card has moved on since; nothing was changed." },
230
+ { kind: "edit", text: `${body}\n\nNothing was changed. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
231
+ }
232
+ if (confirm.phase === "cancel") {
233
+ return [{ kind: "ack", text: "Cancelled." }, { kind: "edit", text: body, ...freshButtons(store, binding, waiting, now) }];
234
+ }
235
+ const decided = decideFlowCard(store, { card: waiting.card.id, decision: "approve", note: null, actor: binding.approver, repos, entry: confirm.entry, where: "Telegram" }, now);
236
+ if (!decided.ok)
237
+ return [{ kind: "ack", text: "Not done." }, { kind: "edit", text: `${body}\n\n✗ Not done: ${decided.message}`.slice(0, 4000), keyboard: flowWhereNow(store, confirm.card, repos).keyboard }];
238
+ store.retireTelegramFlowVisit(confirm.card, confirm.entry, now);
239
+ return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${body}\n\n✅ ${decided.said}`.slice(0, 4000) }];
240
+ }
157
241
  const titleOf = (waiting, id) => flowDefinitionOf(waiting.flow)?.stages.find(one => one.id === id)?.title ?? id;
158
242
  /** A reply to an Edit or Send back prompt, applied inside the update's transaction. */
159
243
  export function applyFlowReply(store, binding, prompt, text, repos, now) {
@@ -200,7 +284,7 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
200
284
  return [{ kind: "say", text: `That's ${said.length.toLocaleString("en-US")} characters. Keep the note to ${LIMITS.note.toLocaleString("en-US")}, or send it back in Toolroll.` }];
201
285
  if (repos === null)
202
286
  return [{ kind: "say", text: "Couldn't check your projects just now. Reply again in a moment." }];
203
- const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said, actor: binding.approver, repos, entry: prompt.entry }, now);
287
+ const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said, actor: binding.approver, repos, entry: prompt.entry, where: "Telegram" }, now);
204
288
  if (!decided.ok)
205
289
  return [{ kind: "say", text: decided.message }];
206
290
  store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
package/dist/telegram.js CHANGED
@@ -34,7 +34,7 @@ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerso
34
34
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
35
35
  import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
36
36
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
37
- import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowChoiceHead, flowDecisionAt, flowDecisionText, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
37
+ import { applyFlowChoiceTap, applyFlowConfirm, applyFlowReply, applyFlowTap, flowConfirmOf, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowChoiceHead, flowDecisionAt, flowDecisionText, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
38
38
  import { flowChoiceAt, flowSendTail, FLOW_CHOOSE_KEY, FLOW_SEND_KEY } from "./flow-send.js";
39
39
  import { cleanFlowMessage, fitFlowMessage } from "./flow-items.js";
40
40
  import { telegramReply } from "./reply-shape.js";
@@ -1523,7 +1523,8 @@ async function enrolledForMessage(context, update) {
1523
1523
  async function projectsForTap(context, update) {
1524
1524
  const callback = update.callback_query;
1525
1525
  // Flow decision buttons (v86) work with or without the lead's conversation on this phone.
1526
- const flowTap = callback !== undefined && (context.store.getTelegramFlowAction(callback.data ?? "") !== null || context.store.getTelegramFlowChoice(callback.data ?? "") !== null);
1526
+ const flowTap = callback !== undefined && (context.store.getTelegramFlowAction(callback.data ?? "") !== null || context.store.getTelegramFlowChoice(callback.data ?? "") !== null
1527
+ || flowConfirmOf(context.store, callback.data ?? "") !== null);
1527
1528
  // So do a result's, plan's or pull request's own buttons (chat-decide.ts).
1528
1529
  const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
1529
1530
  if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
@@ -2172,6 +2173,29 @@ function applyCallback(context, update, effects) {
2172
2173
  }
2173
2174
  return;
2174
2175
  }
2176
+ // A flow decision's repaint: its buttons (fresh ones stamped on this message) or links under the console.
2177
+ const flowEdit = (effect) => {
2178
+ if (effect.place !== undefined && effect.place.length > 0)
2179
+ store.placeTelegramFlowActions(effect.place, String(message.message_id));
2180
+ editText(effect.text, decideKeyboard(context.conversation?.phoneOrigin, effect.keyboard ?? []));
2181
+ };
2182
+ // The Yes or Cancel a flow Approve armed (telegram-flow.ts): on the message it was armed on, by the person it was armed for.
2183
+ const flowConfirm = flowConfirmOf(store, token);
2184
+ if (flowConfirm !== null) {
2185
+ if (flowConfirm.binding !== binding.id || flowConfirm.chatId !== tapChat || flowConfirm.messageId !== String(message.message_id)) {
2186
+ report.ignored++;
2187
+ ack("That button isn't for this chat. Nothing was done.");
2188
+ return;
2189
+ }
2190
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
2191
+ for (const effect of applyFlowConfirm(store, binding, flowConfirm, { text: message.text ?? "" }, repos, clock())) {
2192
+ if (effect.kind === "ack")
2193
+ ack(effect.text);
2194
+ else if (effect.kind === "edit")
2195
+ flowEdit(effect);
2196
+ }
2197
+ return;
2198
+ }
2175
2199
  const flowAction = store.getTelegramFlowAction(token);
2176
2200
  if (flowAction !== null) {
2177
2201
  if (flowAction.binding !== binding.id || flowAction.chatId !== tapChat || (flowAction.messageId !== null && flowAction.messageId !== String(message.message_id))) {
@@ -2184,7 +2208,7 @@ function applyCallback(context, update, effects) {
2184
2208
  if (effect.kind === "ack")
2185
2209
  ack(effect.text);
2186
2210
  else if (effect.kind === "edit")
2187
- editText(effect.text);
2211
+ flowEdit(effect);
2188
2212
  else
2189
2213
  effects.push(async () => {
2190
2214
  // A reply box: whatever they send back as a reply to this prompt is the new draft, or the note.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.42",
3
+ "version": "0.9.43",
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"