toolroll 0.9.20 → 0.9.22
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 +21 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +41 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +20 -4
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +4 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-browser.d.ts +48 -0
- package/dist/scout-browser.js +228 -0
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +67 -0
- package/dist/scout-report.js +108 -1
- package/dist/scout.d.ts +48 -0
- package/dist/scout.js +264 -17
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -310,6 +310,35 @@ CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
|
|
|
310
310
|
`;
|
|
311
311
|
/** Decisions in the chat app (chat-decide.ts; no version bump: additive only). A token names one act on one card (the chat and message it rides); `digest` is
|
|
312
312
|
* what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
|
|
313
|
+
/** A flow's "Send to me" and "Person chooses" zones (no version bump: additive only). flow_send keeps what one visit sent its
|
|
314
|
+
* person (shown on the card, and read again by every chat app that delivers it); telegram_flow_choice is one opaque button
|
|
315
|
+
* per option for one visit of one card, placed on the message it rides. A reply to that message is the note
|
|
316
|
+
* (telegram_flow_prompt, mode send-back). The chat apps keep their buttons beside their messages (chat-delivery-state.ts). */
|
|
317
|
+
export const FLOW_SEND_SCHEMA = `
|
|
318
|
+
CREATE TABLE IF NOT EXISTS flow_send (
|
|
319
|
+
card INTEGER NOT NULL REFERENCES flow_card(id),
|
|
320
|
+
entry INTEGER NOT NULL,
|
|
321
|
+
stage TEXT NOT NULL,
|
|
322
|
+
person TEXT NOT NULL,
|
|
323
|
+
content_json TEXT NOT NULL,
|
|
324
|
+
created_at TEXT NOT NULL,
|
|
325
|
+
PRIMARY KEY (card, entry)
|
|
326
|
+
);
|
|
327
|
+
CREATE TABLE IF NOT EXISTS telegram_flow_choice (
|
|
328
|
+
token TEXT PRIMARY KEY,
|
|
329
|
+
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
330
|
+
card INTEGER NOT NULL REFERENCES flow_card(id),
|
|
331
|
+
entry INTEGER NOT NULL,
|
|
332
|
+
choice INTEGER NOT NULL,
|
|
333
|
+
label TEXT NOT NULL,
|
|
334
|
+
chat_id TEXT NOT NULL,
|
|
335
|
+
message_id TEXT,
|
|
336
|
+
created_at TEXT NOT NULL,
|
|
337
|
+
expires_at TEXT NOT NULL,
|
|
338
|
+
consumed_at TEXT
|
|
339
|
+
);
|
|
340
|
+
CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);
|
|
341
|
+
`;
|
|
313
342
|
export const CHAT_DECIDE_SCHEMA = `
|
|
314
343
|
CREATE TABLE IF NOT EXISTS chat_decide_action (
|
|
315
344
|
token TEXT PRIMARY KEY,
|
|
@@ -821,6 +850,9 @@ const LIFECYCLE_HOLD_WORDS = {
|
|
|
821
850
|
export const RESULT_SCREENSHOTS = ["off", "first", "all"];
|
|
822
851
|
/** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
|
|
823
852
|
export const RESULT_SHOTS_KIND = "result-screenshots";
|
|
853
|
+
/** The screenshots a flow's "Send to me" or "Person chooses" visit sends its person (flow-send.ts): up to four, whatever
|
|
854
|
+
* their result-screenshots choice, since the flow asked for them. */
|
|
855
|
+
export const FLOW_SHOTS_KIND = "flow-screenshots";
|
|
824
856
|
/** Tasks filed this close together share one chat message. */
|
|
825
857
|
export const QUIET_GROUP_MS = 60_000;
|
|
826
858
|
export function isDigestTime(value) {
|
|
@@ -3938,6 +3970,8 @@ function initializeStore(db, file) {
|
|
|
3938
3970
|
db.exec(MATE_ASK_SCHEMA);
|
|
3939
3971
|
// Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
|
|
3940
3972
|
db.exec(CHAT_DECIDE_SCHEMA);
|
|
3973
|
+
// A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
|
|
3974
|
+
db.exec(FLOW_SEND_SCHEMA);
|
|
3941
3975
|
db.exec(SPEND_SCHEMA);
|
|
3942
3976
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
3943
3977
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
@@ -18328,6 +18362,56 @@ export class Store {
|
|
|
18328
18362
|
return null;
|
|
18329
18363
|
return { chatId: String(row["chat_id"]), messageId: String(row["message_id"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), mode: String(row["mode"]) };
|
|
18330
18364
|
}
|
|
18365
|
+
// ---- "Send to me" and "Person chooses" (flow-send.ts) ------------------------------------------
|
|
18366
|
+
/** What one visit sent: recorded once, the first time (a later pass finds it kept). */
|
|
18367
|
+
recordFlowSend(row, now) {
|
|
18368
|
+
return Number(this.db.prepare("INSERT OR IGNORE INTO flow_send (card, entry, stage, person, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
18369
|
+
.run(row.card, row.entry, row.stage, row.person, row.contentJson, now.toISOString()).changes) === 1;
|
|
18370
|
+
}
|
|
18371
|
+
flowSend(card, entry) {
|
|
18372
|
+
const row = this.db.prepare("SELECT * FROM flow_send WHERE card = ? AND entry = ?").get(card, entry);
|
|
18373
|
+
return row === undefined ? null : { card: Number(row["card"]), entry: Number(row["entry"]), stage: String(row["stage"]), person: String(row["person"]), contentJson: String(row["content_json"]), createdAt: String(row["created_at"]) };
|
|
18374
|
+
}
|
|
18375
|
+
/** Every visit's send for a card, newest first: what the card shows it sent. */
|
|
18376
|
+
flowSends(card) {
|
|
18377
|
+
return this.db.prepare("SELECT * FROM flow_send WHERE card = ? ORDER BY entry DESC").all(card).map(row => ({ card: Number(row["card"]), entry: Number(row["entry"]), stage: String(row["stage"]),
|
|
18378
|
+
person: String(row["person"]), contentJson: String(row["content_json"]), createdAt: String(row["created_at"]) }));
|
|
18379
|
+
}
|
|
18380
|
+
createTelegramFlowChoices(at, choices, now, days = 7) {
|
|
18381
|
+
const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
18382
|
+
const insert = this.db.prepare("INSERT INTO telegram_flow_choice (token, binding, card, entry, choice, label, chat_id, message_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
|
|
18383
|
+
for (const one of choices)
|
|
18384
|
+
insert.run(one.token, at.binding, at.card, at.entry, one.choice, one.label, at.chatId, stamp, expires);
|
|
18385
|
+
}
|
|
18386
|
+
getTelegramFlowChoice(token) {
|
|
18387
|
+
if (!/^[0-9a-f]{32}$/.test(token))
|
|
18388
|
+
return null;
|
|
18389
|
+
const row = this.db.prepare("SELECT * FROM telegram_flow_choice WHERE token = ?").get(token);
|
|
18390
|
+
if (row === undefined)
|
|
18391
|
+
return null;
|
|
18392
|
+
return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), choice: Number(row["choice"]), label: String(row["label"]),
|
|
18393
|
+
chatId: String(row["chat_id"]), messageId: row["message_id"] === null ? null : String(row["message_id"]), expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] === null ? null : String(row["consumed_at"]) };
|
|
18394
|
+
}
|
|
18395
|
+
placeTelegramFlowChoices(tokens, messageId) {
|
|
18396
|
+
const place = this.db.prepare("UPDATE telegram_flow_choice SET message_id = ? WHERE token = ?");
|
|
18397
|
+
for (const token of tokens)
|
|
18398
|
+
place.run(messageId, token);
|
|
18399
|
+
}
|
|
18400
|
+
/** Retire every choice button for one visit of a card (and its reply prompt): it was chosen, or the card moved on. */
|
|
18401
|
+
/** A choice was made (or the card moved on): every option button, reply prompt and "Use this as your note?" for that
|
|
18402
|
+
* visit is spent, in Telegram and in every other chat app, whichever place it was made in. */
|
|
18403
|
+
retireFlowChoices(card, entry, now) {
|
|
18404
|
+
const stamp = now.toISOString();
|
|
18405
|
+
this.db.prepare("UPDATE telegram_flow_choice SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
18406
|
+
this.retireTelegramFlowVisit(card, entry, now);
|
|
18407
|
+
// A chat app's flow tables exist only once that app's chat state created them; an install without it has none.
|
|
18408
|
+
const names = ["slack", "discord", "teams"].flatMap(app => ["flow_choice", "flow_note", "flow_prompt", "flow_action"].map(table => `${app}_${table}`));
|
|
18409
|
+
const present = new Set(this.db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${names.map(() => "?").join(", ")})`).all(...names).map(row => String(row["name"])));
|
|
18410
|
+
for (const app of ["slack", "discord", "teams"])
|
|
18411
|
+
for (const table of ["flow_choice", "flow_note", "flow_prompt", "flow_action"])
|
|
18412
|
+
if (present.has(`${app}_${table}`))
|
|
18413
|
+
this.db.prepare(`UPDATE ${app}_${table} SET consumed = ?${table === "flow_note" ? ", words = NULL" : ""} WHERE card = ? AND entry = ? AND consumed IS NULL`).run(stamp, card, entry);
|
|
18414
|
+
}
|
|
18331
18415
|
// ---- v93: a teammate's question on Telegram ------------------------------------------
|
|
18332
18416
|
createTelegramQuestionActions(at, choices, now, days = 7) {
|
|
18333
18417
|
const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
@@ -21730,6 +21814,10 @@ export class Store {
|
|
|
21730
21814
|
recordProjectConcurrency(by, repo, before, after, now) {
|
|
21731
21815
|
this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
|
|
21732
21816
|
}
|
|
21817
|
+
/** Review 827: a project's demo URL changed (`off` when it has none). */
|
|
21818
|
+
recordProjectDemo(by, repo, before, after, now) {
|
|
21819
|
+
this.recordPolicy(by, repo, "demo URL", before ?? "off", after ?? "off", now);
|
|
21820
|
+
}
|
|
21733
21821
|
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
21734
21822
|
/** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
|
|
21735
21823
|
queueTelegramUpdate(botId, updateId, payload, now) {
|
|
@@ -22335,11 +22423,14 @@ export class Store {
|
|
|
22335
22423
|
enqueueResultShots(source, now) {
|
|
22336
22424
|
if (source.run === null || source.taskRef === null || source.project === null || source.recipient !== null)
|
|
22337
22425
|
return;
|
|
22338
|
-
const ready = isLifecycleNotification(source) && source.kind === "run-finished";
|
|
22339
|
-
if (!ready && !/fail/.test(source.kind))
|
|
22340
|
-
return;
|
|
22341
22426
|
const run = this.getRun(source.run);
|
|
22342
|
-
if (run === null ||
|
|
22427
|
+
if (run === null || run.contestant !== null)
|
|
22428
|
+
return;
|
|
22429
|
+
// One path per role (review 826): a scout's report is its result, so only its "report ready" carries the
|
|
22430
|
+
// screenshots it saved; a build's are carried by its finished or failed message.
|
|
22431
|
+
const carries = run.role === "scout" ? source.kind === "report-ready"
|
|
22432
|
+
: ["builder", "repair"].includes(run.role) && ((isLifecycleNotification(source) && source.kind === "run-finished") || /fail/.test(source.kind));
|
|
22433
|
+
if (!carries)
|
|
22343
22434
|
return;
|
|
22344
22435
|
if (!this.artifactsFor(run.id).some(one => one.kind === "screenshot"))
|
|
22345
22436
|
return;
|
package/dist/teams-chat.js
CHANGED
|
@@ -207,7 +207,7 @@ export async function deliverTeamsPart(options) {
|
|
|
207
207
|
}
|
|
208
208
|
else if (!content.image) {
|
|
209
209
|
// A flow decision (v88): Approve / Edit / Send back, then the link.
|
|
210
|
-
const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
210
|
+
const flow = content.flow || content.choose || content.note ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
211
211
|
// A teammate's question (v93): its options, then "Answer in words".
|
|
212
212
|
const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
213
213
|
// The lead's question to its owner: its options, then "Something else".
|
package/dist/telegram-flow.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type FlowChoiceVisit, type FlowSendContent } from "./flow-send.js";
|
|
1
2
|
import { type FlowStage } from "./flows.js";
|
|
2
|
-
import type
|
|
3
|
-
import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowPrompt } from "./store.js";
|
|
3
|
+
import { type InlineButton } from "./telegram-mate.js";
|
|
4
|
+
import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowChoice, TelegramFlowPrompt } from "./store.js";
|
|
4
5
|
export declare const FLOW_DECIDE_KEY: RegExp;
|
|
5
6
|
type Waiting = {
|
|
6
7
|
card: FlowCardRow;
|
|
@@ -15,6 +16,19 @@ export declare function flowButtons(store: Store, binding: TelegramBinding, wait
|
|
|
15
16
|
keyboard: InlineButton[][];
|
|
16
17
|
tokens: string[];
|
|
17
18
|
};
|
|
19
|
+
/** What a "Send to me" or "Person chooses" visit sent, read back for its message. */
|
|
20
|
+
export declare function flowSentContent(store: Store, card: number, entry: number): FlowSendContent | null;
|
|
21
|
+
/** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
|
|
22
|
+
export declare function flowSendKeyboardRow(origin: string | null, content: FlowSendContent): InlineButton[];
|
|
23
|
+
/** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
|
|
24
|
+
export declare function flowChoiceButtons(store: Store, binding: TelegramBinding, visit: FlowChoiceVisit, content: FlowSendContent, now: Date): {
|
|
25
|
+
keyboard: InlineButton[][];
|
|
26
|
+
tokens: string[];
|
|
27
|
+
};
|
|
28
|
+
/** A tapped option, applied inside the update's transaction. */
|
|
29
|
+
export declare function applyFlowChoiceTap(store: Store, binding: TelegramBinding, choice: TelegramFlowChoice, message: {
|
|
30
|
+
text: string;
|
|
31
|
+
}, repos: readonly string[] | null, now: Date): FlowTapEffect[];
|
|
18
32
|
export type FlowTapEffect = {
|
|
19
33
|
kind: "ack";
|
|
20
34
|
text: string;
|
package/dist/telegram-flow.js
CHANGED
|
@@ -14,11 +14,17 @@
|
|
|
14
14
|
* Every button is one opaque token for one visit of one card, placed on the
|
|
15
15
|
* message it rides. A card that moved on answers so; a tap from anyone but
|
|
16
16
|
* the paired person and chat never reaches here (telegram.ts checks it).
|
|
17
|
+
*
|
|
18
|
+
* A "Person chooses" visit (flow-send.ts) has one button per option the flow
|
|
19
|
+
* names; a reply to that message instead of a tap is the note for where
|
|
20
|
+
* replies go. Both go through chooseFlowCard, for exactly that visit.
|
|
17
21
|
*/
|
|
18
22
|
import { randomBytes } from "node:crypto";
|
|
19
23
|
import { keptDraft } from "./flow-draft.js";
|
|
20
24
|
import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
25
|
+
import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
|
|
21
26
|
import { deciderOf } from "./flows.js";
|
|
27
|
+
import { phoneLinkButton } from "./telegram-mate.js";
|
|
22
28
|
export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
|
|
23
29
|
const DRAFT_LIMIT = 4000;
|
|
24
30
|
/** The card, if it is still at that visit, waiting at a decision. */
|
|
@@ -45,6 +51,43 @@ export function flowButtons(store, binding, waiting, now) {
|
|
|
45
51
|
.filter((one) => one !== null);
|
|
46
52
|
return { keyboard: [[{ text: "✅ Approve", callback_data: approve.token }], ...(second.length === 0 ? [] : [second])], tokens: all.map(one => one.token) };
|
|
47
53
|
}
|
|
54
|
+
/** What a "Send to me" or "Person chooses" visit sent, read back for its message. */
|
|
55
|
+
export function flowSentContent(store, card, entry) {
|
|
56
|
+
const kept = store.flowSend(card, entry);
|
|
57
|
+
return kept === null ? null : readFlowSend(kept.contentJson);
|
|
58
|
+
}
|
|
59
|
+
/** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
|
|
60
|
+
export function flowSendKeyboardRow(origin, content) {
|
|
61
|
+
const pages = origin === null ? [] : flowSendPaths(content).flatMap(one => phoneLinkButton(origin, one) ?? []);
|
|
62
|
+
const pull = content.links.flatMap(one => "url" in one ? [{ text: one.label, url: one.url }] : []);
|
|
63
|
+
return [...pages.slice(0, 1), ...pull, ...pages.slice(1)].slice(0, 3);
|
|
64
|
+
}
|
|
65
|
+
/** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
|
|
66
|
+
export function flowChoiceButtons(store, binding, visit, content, now) {
|
|
67
|
+
// Only the options the zone still offers as they were sent: a flow changed since sends no stale button.
|
|
68
|
+
const options = (content.options ?? []).filter(one => visit.stage.options?.[one.choice]?.label === one.label).map(one => ({ ...one, token: randomBytes(16).toString("hex") }));
|
|
69
|
+
store.createTelegramFlowChoices({ binding: binding.id, chatId: binding.chatId, card: visit.card.id, entry: visit.card.entry }, options, now);
|
|
70
|
+
return { keyboard: options.map(one => [{ text: one.label, callback_data: one.token }]), tokens: options.map(one => one.token) };
|
|
71
|
+
}
|
|
72
|
+
/** A tapped option, applied inside the update's transaction. */
|
|
73
|
+
export function applyFlowChoiceTap(store, binding, choice, message, repos, now) {
|
|
74
|
+
if (choice.consumedAt !== null || choice.expiresAt <= now.toISOString())
|
|
75
|
+
return [{ kind: "ack", text: "That was already chosen, or these buttons are too old." }];
|
|
76
|
+
const visit = flowChoiceAt(store, choice.card, choice.entry);
|
|
77
|
+
if (visit === null) {
|
|
78
|
+
store.retireFlowChoices(choice.card, choice.entry, now);
|
|
79
|
+
return [{ kind: "ack", text: "That card has moved on since; nothing was changed." }, { kind: "edit", text: `${message.text}\n\nThis card has moved on since; nothing was changed.`.slice(0, 4000) }];
|
|
80
|
+
}
|
|
81
|
+
if (visit.person !== binding.approver)
|
|
82
|
+
return [{ kind: "ack", text: `Only ${visit.person} chooses here.` }];
|
|
83
|
+
if (repos === null)
|
|
84
|
+
return [{ kind: "ack", text: "Couldn't check your projects just now. Try again in a moment." }];
|
|
85
|
+
const chosen = chooseFlowCard(store, { card: choice.card, entry: choice.entry, choice: choice.choice, label: choice.label, note: null, actor: binding.approver, where: "Telegram", repos }, now);
|
|
86
|
+
if (!chosen.ok)
|
|
87
|
+
return [{ kind: "ack", text: chosen.message.slice(0, 190) }];
|
|
88
|
+
store.retireFlowChoices(choice.card, choice.entry, now);
|
|
89
|
+
return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ You chose “${choice.label}”. ${chosen.said}`.slice(0, 4000) }];
|
|
90
|
+
}
|
|
48
91
|
/** A tapped flow button, applied inside the update's transaction. */
|
|
49
92
|
export function applyFlowTap(store, binding, action, message, repos, now) {
|
|
50
93
|
if (action.consumedAt !== null || action.expiresAt <= now.toISOString())
|
|
@@ -78,6 +121,21 @@ const titleOf = (waiting, id) => flowDefinitionOf(waiting.flow)?.stages.find(one
|
|
|
78
121
|
export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
79
122
|
if (prompt.binding !== binding.id)
|
|
80
123
|
return [];
|
|
124
|
+
// A reply to a choice's message (flow-send.ts): the note for where replies go.
|
|
125
|
+
const choosing = flowChoiceAt(store, prompt.card, prompt.entry);
|
|
126
|
+
if (choosing !== null) {
|
|
127
|
+
if (choosing.person !== binding.approver)
|
|
128
|
+
return [{ kind: "say", text: `Only ${choosing.person} chooses here.` }];
|
|
129
|
+
if (text.trim() === "")
|
|
130
|
+
return [{ kind: "say", text: "Say what you'd change." }];
|
|
131
|
+
if (repos === null)
|
|
132
|
+
return [{ kind: "say", text: "Couldn't check your projects just now. Reply again in a moment." }];
|
|
133
|
+
const chosen = chooseFlowCard(store, { card: prompt.card, entry: prompt.entry, choice: null, note: text.trim(), actor: binding.approver, where: "Telegram", repos }, now);
|
|
134
|
+
if (!chosen.ok)
|
|
135
|
+
return [{ kind: "say", text: chosen.message }];
|
|
136
|
+
store.retireFlowChoices(prompt.card, prompt.entry, now);
|
|
137
|
+
return [{ kind: "say", text: `↩️ ${chosen.said}` }];
|
|
138
|
+
}
|
|
81
139
|
const waiting = flowDecisionAt(store, prompt.card, prompt.entry);
|
|
82
140
|
if (waiting === null) {
|
|
83
141
|
store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
|
package/dist/telegram.js
CHANGED
|
@@ -27,14 +27,15 @@ 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 { resultShotsFor } from "./result-shots.js";
|
|
30
|
+
import { isShotsKind, resultShotsFor } from "./result-shots.js";
|
|
31
31
|
import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
32
32
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
33
33
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
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 { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
37
|
+
import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowDecisionAt, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
|
|
38
|
+
import { flowChoiceAt, FLOW_CHOOSE_KEY, FLOW_SEND_KEY } from "./flow-send.js";
|
|
38
39
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
39
40
|
import { focusContextFor, taskInCeiling } from "./chat-channel.js";
|
|
40
41
|
import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, dropDecideTokens, hasLiveDecideTokens, linksFor, placeDecidePrompt, decideOffer, decideTargetOf, isDecideToken, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, placeDecideTokens, recordChatMerge, retireDecideTokens } from "./chat-decide.js";
|
|
@@ -658,7 +659,7 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
|
|
|
658
659
|
// Routine progress facts are not a problem to fix: a first pairing
|
|
659
660
|
// starts from now and settles them as history. Anything else pending
|
|
660
661
|
// is named, once per pass, as before.
|
|
661
|
-
if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && row.kind
|
|
662
|
+
if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && !isShotsKind(row.kind)))
|
|
662
663
|
report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
|
|
663
664
|
return;
|
|
664
665
|
}
|
|
@@ -1058,7 +1059,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
1058
1059
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
1059
1060
|
};
|
|
1060
1061
|
let outcome;
|
|
1061
|
-
if (rows.length === 1 && row.kind
|
|
1062
|
+
if (rows.length === 1 && isShotsKind(row.kind))
|
|
1062
1063
|
outcome = await shotsSender(row);
|
|
1063
1064
|
else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
|
|
1064
1065
|
outcome = await leadSayOutcome(row);
|
|
@@ -1107,7 +1108,7 @@ function isUrgent(notification) {
|
|
|
1107
1108
|
/** A routine fact the digest may carry. Screenshots are never urgent (they follow their result message, which the
|
|
1108
1109
|
* task's order fence holds them behind) and never a digest line: they go as their own photos once it has gone. */
|
|
1109
1110
|
function joinsDigest(notification) {
|
|
1110
|
-
return !isUrgent(notification) && notification.kind
|
|
1111
|
+
return !isUrgent(notification) && !isShotsKind(notification.kind);
|
|
1111
1112
|
}
|
|
1112
1113
|
/** The digest text: a header with the count and the window, then one
|
|
1113
1114
|
* fact per entry — its subject, then its body's first line, indented.
|
|
@@ -1193,7 +1194,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1193
1194
|
const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
|
|
1194
1195
|
const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1195
1196
|
const fallback = offer === null ? decideFallbackLink(target) : null;
|
|
1196
|
-
|
|
1197
|
+
let button = fallback !== null ? (() => { try {
|
|
1197
1198
|
return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
|
|
1198
1199
|
}
|
|
1199
1200
|
catch {
|
|
@@ -1204,10 +1205,23 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1204
1205
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
1205
1206
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
1206
1207
|
const flowKeys = waiting === null ? null : flowButtons(store, binding, waiting, clock());
|
|
1208
|
+
// A flow's "Send to me" or "Person chooses" (flow-send.ts): its links as buttons, and a choice's options above them.
|
|
1209
|
+
const sentVisit = FLOW_SEND_KEY.exec(notification.dedupeKey) ?? FLOW_CHOOSE_KEY.exec(notification.dedupeKey);
|
|
1210
|
+
const sent = sentVisit === null ? null : flowSentContent(store, Number(sentVisit[1]), Number(sentVisit[2]));
|
|
1211
|
+
const choosing = sent === null || !FLOW_CHOOSE_KEY.test(notification.dedupeKey) || notification.recipient !== binding.approver ? null : flowChoiceAt(store, Number(sentVisit[1]), Number(sentVisit[2]));
|
|
1212
|
+
const choiceKeys = choosing === null || sent === null ? null : flowChoiceButtons(store, binding, choosing, sent, clock());
|
|
1213
|
+
const sentRow = sent === null ? null : (() => { try {
|
|
1214
|
+
return flowSendKeyboardRow(phoneOrigin?.() ?? null, sent);
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
return [];
|
|
1218
|
+
} })();
|
|
1219
|
+
if (sentRow !== null)
|
|
1220
|
+
button = sentRow.length === 0 ? null : sentRow;
|
|
1207
1221
|
// A teammate's question (v93): its options and "Answer in words", for the person it asks.
|
|
1208
|
-
const asked = flowKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
|
|
1222
|
+
const asked = flowKeys === null && choiceKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
|
|
1209
1223
|
const questionKeys = asked === null ? null : telegramQuestionButtons(store, binding, asked, clock());
|
|
1210
|
-
const keys = flowKeys ?? questionKeys;
|
|
1224
|
+
const keys = flowKeys ?? choiceKeys ?? questionKeys;
|
|
1211
1225
|
let last = null;
|
|
1212
1226
|
for (const [index, part] of parts.entries()) {
|
|
1213
1227
|
const final = index === parts.length - 1;
|
|
@@ -1223,6 +1237,12 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1223
1237
|
}
|
|
1224
1238
|
if (flowKeys !== null && last !== null)
|
|
1225
1239
|
store.placeTelegramFlowActions(flowKeys.tokens, last);
|
|
1240
|
+
if (choiceKeys !== null && last !== null) {
|
|
1241
|
+
store.placeTelegramFlowChoices(choiceKeys.tokens, last);
|
|
1242
|
+
// A reply to this message, instead of a tap, is the person's note (applyFlowReply).
|
|
1243
|
+
if (sent?.reply === true)
|
|
1244
|
+
store.recordTelegramFlowPrompt({ chatId: binding.chatId, messageId: last, binding: binding.id, card: choosing.card.id, entry: choosing.card.entry, mode: "send-back" }, clock(), 7 * 24);
|
|
1245
|
+
}
|
|
1226
1246
|
if (questionKeys !== null && last !== null)
|
|
1227
1247
|
store.placeTelegramQuestionActions(questionKeys.tokens, last);
|
|
1228
1248
|
if (decided !== null && last !== null)
|
|
@@ -1472,7 +1492,7 @@ async function enrolledForMessage(context, update) {
|
|
|
1472
1492
|
async function projectsForTap(context, update) {
|
|
1473
1493
|
const callback = update.callback_query;
|
|
1474
1494
|
// Flow decision buttons (v86) work with or without the lead's conversation on this phone.
|
|
1475
|
-
const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
|
|
1495
|
+
const flowTap = callback !== undefined && (context.store.getTelegramFlowAction(callback.data ?? "") !== null || context.store.getTelegramFlowChoice(callback.data ?? "") !== null);
|
|
1476
1496
|
// So do a result's, plan's or pull request's own buttons (chat-decide.ts).
|
|
1477
1497
|
const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
|
|
1478
1498
|
if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
|
|
@@ -1481,7 +1501,7 @@ async function projectsForTap(context, update) {
|
|
|
1481
1501
|
if (binding === null || callback.from === undefined ||
|
|
1482
1502
|
callback.message?.chat === undefined ||
|
|
1483
1503
|
// Proposal cards and task picks read the chat's project ceiling.
|
|
1484
|
-
(context.store.getTelegramProposalAction(callback.data ?? "") === null &&
|
|
1504
|
+
(context.store.getTelegramProposalAction(callback.data ?? "") === null && !flowTap && !decideTap && !(callback.data ?? "").startsWith("pick:")))
|
|
1485
1505
|
return null;
|
|
1486
1506
|
const chat = callback.message.chat;
|
|
1487
1507
|
const chatId = String(chat.id);
|
|
@@ -2138,6 +2158,22 @@ function applyCallback(context, update, effects) {
|
|
|
2138
2158
|
}
|
|
2139
2159
|
return;
|
|
2140
2160
|
}
|
|
2161
|
+
// A "Person chooses" option (flow-send.ts): the card goes where it leads, or is ignored.
|
|
2162
|
+
const choiceAction = store.getTelegramFlowChoice(token);
|
|
2163
|
+
if (choiceAction !== null) {
|
|
2164
|
+
if (choiceAction.binding !== binding.id || choiceAction.chatId !== tapChat || (choiceAction.messageId !== null && choiceAction.messageId !== String(message.message_id))) {
|
|
2165
|
+
report.ignored++;
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
|
|
2169
|
+
for (const effect of applyFlowChoiceTap(store, binding, choiceAction, { text: message.text ?? "" }, repos, clock())) {
|
|
2170
|
+
if (effect.kind === "ack")
|
|
2171
|
+
ack(effect.text);
|
|
2172
|
+
else if (effect.kind === "edit")
|
|
2173
|
+
editText(effect.text);
|
|
2174
|
+
}
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2141
2177
|
// The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
|
|
2142
2178
|
const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
|
|
2143
2179
|
if (askTap !== null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.22",
|
|
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"
|