toolroll 0.9.19 → 0.9.21
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 +24 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +43 -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/flow-triggers.d.ts +7 -0
- package/dist/flow-triggers.js +17 -5
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +22 -6
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/invoke.d.ts +3 -0
- package/dist/invoke.js +1 -0
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/plane-review.d.ts +15 -0
- package/dist/plane-review.js +62 -20
- 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 +14 -0
- package/dist/provider.js +8 -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-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +160 -0
- package/dist/scout-report.js +171 -1
- package/dist/scout.d.ts +42 -0
- package/dist/scout.js +380 -69
- 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/serve.js
CHANGED
|
@@ -50,7 +50,8 @@ import { saveScript } from "./flow-scripts.js";
|
|
|
50
50
|
import { flowInsights } from "./flow-insights.js";
|
|
51
51
|
import { FORM_PATH, flowFormPage, receiveFlowForm, shareFlowButton, stopSharingFlowButton } from "./flow-triggers.js";
|
|
52
52
|
import { addFlowTriggerTo, checkFlowTriggerNow, HOOK_PATH, pressFlowButton, receiveFlowHook, removeFlowTrigger, renewFlowHook, removeLinearKey, saveHooksBase, saveLinearKey, saveLinearSigningSecret } from "./flow-triggers.js";
|
|
53
|
-
import { addCardToFlow, advanceFlows, cancelFlowCard, decideFlowCard, FLOW_HREF, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
|
|
53
|
+
import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, FLOW_HREF, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
|
|
54
|
+
import { chooseFlowCard } from "./flow-send.js";
|
|
54
55
|
import { FLOW_TEMPLATES, validateFlowDefinition } from "./flows.js";
|
|
55
56
|
import { TOOL_CATALOG, addToolTo, catalogTool, discoverTools, projectToolsOf, removeToolFrom, secretsSetFor, setToolSecret, splitCommandLine, testToolOf } from "./project-tools.js";
|
|
56
57
|
import { changeLearning, learningView } from "./project-learning.js";
|
|
@@ -167,7 +168,7 @@ import { join } from "node:path";
|
|
|
167
168
|
import { TEMPLATES, templateByName } from "./templates.js";
|
|
168
169
|
import { starterRecipes, savedRecipes, findRecipe, importRecipe, exportRecipe, createWorkflowPreview, workflowPreview, launchWorkflow, saveWorkflowRecipe, RecipeError, prepareRecipeRun } from "./recipes.js";
|
|
169
170
|
import { recipeFromForm, recipeLibraryHtml, recipeEditorHtml, workflowPreviewHtml, recipeScript, RECIPE_CSS, recipeDefinitionPreviewHtml, recipeRunHtml, recipeAnswersFromForm } from "./recipe-ui.js";
|
|
170
|
-
import { EVIDENCE_CAPS, readVerifiedArtifact, readVerifiedReport, readVerifiedProofForRun, storeEvidence, writeEvidenceFile, scanForSecrets } from "./evidence.js";
|
|
171
|
+
import { EVIDENCE_CAPS, readVerifiedArtifact, readVerifiedReport, readVerifiedProofForRun, reportShotsOf, storeEvidence, writeEvidenceFile, scanForSecrets } from "./evidence.js";
|
|
171
172
|
import { GOAL_ASSESSMENT_PENDING, reviewConflict, manualReviewOnly, manualReviewCriterionOf, personCheckWords, plainReasonWords, dispatchStatusToken, passFraction, semanticCoverage, coverageWords, coverageStateWords } from "./proof.js";
|
|
172
173
|
import { WORK_VIEWS, REVIEW_TOKENS, RESULT_DECISION_SENTENCE, acceptWordsOf, buildProgressOf, cantAcceptYetOf, earlierAttemptsWords, evidenceProblemOf, lastErrorLineOf, missedRequirementOf, reportMismatchesOf, ACCEPT_NEEDS_REASON, MISMATCH_HEADLINE, parseWorkView, resultStatusOf, resultHeadlineOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
|
|
173
174
|
import { PRICED_BUILD_MODELS } from "./pricing.js";
|
|
@@ -6924,7 +6925,7 @@ export function createDecisionServer(options) {
|
|
|
6924
6925
|
const repo = body.get("repo") ?? "";
|
|
6925
6926
|
if (!projects.includes(repo))
|
|
6926
6927
|
return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
|
|
6927
|
-
const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
|
|
6928
|
+
const answers = { ...Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""])), ...(body.get("send-result") === "yes" ? { "send-result": "yes" } : {}) };
|
|
6928
6929
|
const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
|
|
6929
6930
|
if (body.get("intent") !== "create")
|
|
6930
6931
|
return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
|
|
@@ -6943,7 +6944,7 @@ export function createDecisionServer(options) {
|
|
|
6943
6944
|
return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
|
|
6944
6945
|
return redirect(response, `/flows/${used.flow}`);
|
|
6945
6946
|
}
|
|
6946
|
-
const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|cancel|comment|assign|watch)$/.exec(url.pathname);
|
|
6947
|
+
const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|choose|cancel|comment|assign|watch)$/.exec(url.pathname);
|
|
6947
6948
|
const triggerPost = /^\/flows\/([1-9][0-9]{0,9})\/triggers\/([1-9][0-9]{0,9})\/(pause|resume|remove|check|press|renew|secret|share|unshare)$/.exec(url.pathname);
|
|
6948
6949
|
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
|
|
6949
6950
|
const now = clock();
|
|
@@ -7143,6 +7144,10 @@ export function createDecisionServer(options) {
|
|
|
7143
7144
|
const owner = (body.get("owner") ?? "").trim();
|
|
7144
7145
|
if (owner !== "" && owner !== flow.owner && !(store.listApprovers().some(one => one.name === owner) && store.accountCanAccess(owner, flow.repo)))
|
|
7145
7146
|
return answer(400, { ok: false, said: `${owner} can't approve on this project, so they can't own this flow.` });
|
|
7147
|
+
// A build zone in another project files work there as the flow's owner: only in one they may file in.
|
|
7148
|
+
const elsewhere = crossProjectProblem(store, saved, { repo: flow.repo, owner: owner || flow.owner }, who.name);
|
|
7149
|
+
if (elsewhere !== null)
|
|
7150
|
+
return answer(400, { ok: false, said: elsewhere });
|
|
7146
7151
|
if (!store.saveFlow(flow.id, { name, definitionJson: JSON.stringify(saved), sawRevision: Number(body.get("revision")), by: who.name }, now))
|
|
7147
7152
|
return answer(409, { ok: false, said: "Someone else changed this flow. Reload to see their changes, then make yours again." });
|
|
7148
7153
|
if (owner !== "" && owner !== flow.owner)
|
|
@@ -7180,6 +7185,13 @@ export function createDecisionServer(options) {
|
|
|
7180
7185
|
const decided = decideFlowCard(store, { card: target.id, decision, note, actor: who.name, repos: projects, evidenceRoot, draft: body.get("draft") }, now);
|
|
7181
7186
|
return decided.ok ? settle(decided.said) : answer(409, { ok: false, said: decided.message });
|
|
7182
7187
|
}
|
|
7188
|
+
if (verb === "choose") {
|
|
7189
|
+
// "Person chooses": one of the zone's options (by its place and words), or a reply that becomes the note.
|
|
7190
|
+
const choice = /^[0-9]$/.test(body.get("choice") ?? "") ? Number(body.get("choice")) : null;
|
|
7191
|
+
const chosen = chooseFlowCard(store, { card: target.id, ...(/^[1-9][0-9]{0,9}$/.test(body.get("entry") ?? "") ? { entry: Number(body.get("entry")) } : {}), choice,
|
|
7192
|
+
...(choice !== null && body.get("label") !== null ? { label: body.get("label") } : {}), note: choice === null ? body.get("note") : null, actor: who.name, where: "Toolroll", repos: projects, evidenceRoot }, now);
|
|
7193
|
+
return chosen.ok ? settle(chosen.said) : answer(409, { ok: false, said: chosen.message });
|
|
7194
|
+
}
|
|
7183
7195
|
if (verb === "comment") {
|
|
7184
7196
|
const commented = commentOnFlowCard(store, target, who.name, body.get("body"), now);
|
|
7185
7197
|
return commented.ok ? settle(commented.said) : answer(400, { ok: false, said: commented.message });
|
|
@@ -13505,6 +13517,9 @@ ${THEME_DARK}
|
|
|
13505
13517
|
.receipt-shot { display: grid; gap: .35rem; color: var(--muted-foreground); font-size: .75rem; text-decoration: none; }
|
|
13506
13518
|
.receipt-shot img { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border: 1px solid var(--glass-border); border-radius: calc(var(--radius) - 3px); background: var(--muted); }
|
|
13507
13519
|
@media (hover: hover) and (pointer: fine) { .receipt-shot:hover { color: var(--foreground); } }
|
|
13520
|
+
.report-items ol { margin: .35rem 0 .75rem; padding-left: 1.25rem; display: grid; gap: .75rem; }
|
|
13521
|
+
.report-items li p { margin: .15rem 0; }
|
|
13522
|
+
.report-link { overflow-wrap: anywhere; }
|
|
13508
13523
|
.receipt-caveats, .receipt-coverage { margin-top: .8rem; padding: .7rem .8rem; border-left: 1px solid var(--border); border-radius: 0 calc(var(--radius) - 3px) calc(var(--radius) - 3px) 0; background: color-mix(in srgb, var(--muted) 62%, transparent); font-size: .78rem; }
|
|
13509
13524
|
.receipt-caveats ul, .receipt-coverage ul { margin: .3rem 0 0; padding-left: 1.15rem; }
|
|
13510
13525
|
/* Secondary receipt detail (concise pass, 2026-09-13): native
|
|
@@ -20556,6 +20571,7 @@ function taskBodyParts(data) {
|
|
|
20556
20571
|
`<div class="card report">`,
|
|
20557
20572
|
`<p><strong>${escape(data.report.report.title)}</strong> <span class="meta">the scout's report · <a href="/r/${data.report.run}">run ${data.report.run}</a></span></p>`,
|
|
20558
20573
|
`<p class="report-summary">${escape(data.report.report.summary)}</p>`,
|
|
20574
|
+
reportItemsHtml(data.report.report.items, data.report.shots, data.report.run),
|
|
20559
20575
|
`<pre class="recap plan-doc">${escape(data.report.report.report)}</pre>`,
|
|
20560
20576
|
...(data.report.report.followUps.length === 0
|
|
20561
20577
|
? []
|
|
@@ -22331,6 +22347,26 @@ function structuredHandoffView(artifacts, root) {
|
|
|
22331
22347
|
return null;
|
|
22332
22348
|
}
|
|
22333
22349
|
}
|
|
22350
|
+
/** What a scout found, each with its link and picture, then any screenshot no item shows. Every link is the cited
|
|
22351
|
+
* http(s) address the report parser admitted; every picture is served from verified evidence. */
|
|
22352
|
+
function reportItemsHtml(items, shots, runId) {
|
|
22353
|
+
const picture = (shot) => shot.artifactId === null
|
|
22354
|
+
? `<p class="meta">Screenshot unavailable: ${escape(shot.problem ?? "it can't be shown")}</p>`
|
|
22355
|
+
: `<a class="receipt-shot" href="/r/${runId}/evidence/${shot.artifactId}"><img src="/r/${runId}/evidence/${shot.artifactId}" alt="${escape(shot.caption)}" loading="lazy"><span>${escape(shot.caption)}</span></a>`;
|
|
22356
|
+
const link = (url) => `<a href="${escape(url)}" rel="noopener noreferrer nofollow" target="_blank" class="report-link">${escape(url)}</a>`;
|
|
22357
|
+
const shown = new Set(items.map(one => one.image).filter((one) => one !== null));
|
|
22358
|
+
const loose = shots.filter(one => !shown.has(one.file));
|
|
22359
|
+
if (items.length === 0 && loose.length === 0)
|
|
22360
|
+
return "";
|
|
22361
|
+
return `<div class="report-items" data-report-items="${items.length}">` +
|
|
22362
|
+
(items.length === 0 ? "" : `<p><strong>What it found</strong></p><ol>${items.map(item => {
|
|
22363
|
+
const shot = item.image === null ? undefined : shots.find(one => one.file === item.image);
|
|
22364
|
+
return `<li data-report-item><p><strong>${escape(item.title)}</strong></p><p class="meta">${escape(item.why)}</p><p class="meta">${link(item.url)}</p>` +
|
|
22365
|
+
(shot === undefined ? "" : `<div class="receipt-visuals">${picture(shot)}</div>`) + `</li>`;
|
|
22366
|
+
}).join("")}</ol>`) +
|
|
22367
|
+
(loose.length === 0 ? "" : `<p><strong>Screenshots</strong></p><div class="receipt-visuals" aria-label="the scout's screenshots">${loose.map(picture).join("")}</div>`) +
|
|
22368
|
+
`</div>`;
|
|
22369
|
+
}
|
|
22334
22370
|
function runReportView(artifacts, root) {
|
|
22335
22371
|
const artifact = [...artifacts].reverse().find(one => one.kind === "report");
|
|
22336
22372
|
if (artifact === undefined)
|
|
@@ -22347,7 +22383,8 @@ function runReportView(artifacts, root) {
|
|
|
22347
22383
|
const parsed = parseReport(read.content.toString("utf8"));
|
|
22348
22384
|
if (!parsed.ok)
|
|
22349
22385
|
return { ok: false, artifactId: artifact.id, problem: artifact.truncated ? "the report was shortened at storage and cannot be read; the missing part was never captured" : "the stored report is not a report this console can read" };
|
|
22350
|
-
return { ok: true, artifactId: artifact.id, title: parsed.report.title, summary: parsed.report.summary, document: parsed.report.report, followUps: parsed.report.followUps.length, truncated: artifact.truncated
|
|
22386
|
+
return { ok: true, artifactId: artifact.id, title: parsed.report.title, summary: parsed.report.summary, document: parsed.report.report, followUps: parsed.report.followUps.length, truncated: artifact.truncated,
|
|
22387
|
+
items: parsed.report.items, shots: reportShotsOf(artifacts, root, artifact.run, parsed.report.images) };
|
|
22351
22388
|
}
|
|
22352
22389
|
/** The shared facts (package 3), computed ONCE from the same verified
|
|
22353
22390
|
* records every surface reads: the head from the sealed diff summary when
|
|
@@ -23112,6 +23149,7 @@ function resultPanelParts(detail, o) {
|
|
|
23112
23149
|
// already the outcome line above (and, when shortened there, waits
|
|
23113
23150
|
// in "What the agent reported"), so the article never repeats it.
|
|
23114
23151
|
summaryParts.push(`<article class="result-report" data-result-report="ok"><h3>${escape(report.title)}</h3>` +
|
|
23152
|
+
reportItemsHtml(report.items, report.shots, runId) +
|
|
23115
23153
|
`<pre class="recap plan-doc">${escape(report.document)}</pre>` +
|
|
23116
23154
|
`<p class="meta"><a href="/r/${runId}/evidence/${report.artifactId}">${report.truncated ? "Download the stored part of the report (shortened at storage — not the full report)" : "Download the report"}</a>${report.followUps === 0 ? "" : ` · ${report.followUps} proposed follow-up${report.followUps === 1 ? "" : "s"} on <a href="${taskHref(detail.rootId ?? detail.taskId)}">the task</a>`}</p></article>`);
|
|
23117
23155
|
}
|
package/dist/slack-chat.js
CHANGED
|
@@ -76,7 +76,7 @@ export function receiveSlack(state, identity, type, raw, now) {
|
|
|
76
76
|
const action = object(actions[0]), container = object(body.container);
|
|
77
77
|
if (
|
|
78
78
|
// toolroll_* since the rename; standing_orders_* buttons on older messages still work.
|
|
79
|
-
!/^(?:toolroll|standing_orders)_(confirm|dismiss|yes|cancel|flow_approve|flow_edit|flow_send_back|question_choice|question_words)$/.test(String(action.action_id)) ||
|
|
79
|
+
!/^(?:toolroll|standing_orders)_(confirm|dismiss|yes|cancel|flow_approve|flow_edit|flow_send_back|flow_choose_[0-3]|flow_note_(?:yes|no)|question_choice|question_words)$/.test(String(action.action_id)) ||
|
|
80
80
|
typeof action.value !== "string" ||
|
|
81
81
|
!/^[a-f0-9]{32}$/.test(action.value) ||
|
|
82
82
|
!slackTs(action.action_ts) ||
|
|
@@ -370,11 +370,11 @@ export async function deliverSlackPart(options) {
|
|
|
370
370
|
else
|
|
371
371
|
buttons = [
|
|
372
372
|
// A flow decision (v88): Approve / Edit / Send back, then the link.
|
|
373
|
-
...(content.flow
|
|
373
|
+
...(content.flow || content.choose || content.note
|
|
374
374
|
? chatFlowButtons(state, row.id, now).map((one) => ({
|
|
375
375
|
type: "button",
|
|
376
376
|
text: { type: "plain_text", text: one.label },
|
|
377
|
-
action_id: `toolroll_flow_${one.
|
|
377
|
+
action_id: `toolroll_flow_${one.key}`,
|
|
378
378
|
value: one.token,
|
|
379
379
|
...(one.action === "approve" ? { style: "primary" } : {}),
|
|
380
380
|
}))
|
package/dist/store.d.ts
CHANGED
|
@@ -49,6 +49,11 @@ export type TaskActRow = {
|
|
|
49
49
|
export declare const MATE_ASK_SCHEMA = "\nCREATE TABLE IF NOT EXISTS mate_ask (\n turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,\n thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,\n question TEXT NOT NULL,\n options_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);\n";
|
|
50
50
|
/** 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
|
|
51
51
|
* what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
|
|
52
|
+
/** A flow's "Send to me" and "Person chooses" zones (no version bump: additive only). flow_send keeps what one visit sent its
|
|
53
|
+
* person (shown on the card, and read again by every chat app that delivers it); telegram_flow_choice is one opaque button
|
|
54
|
+
* per option for one visit of one card, placed on the message it rides. A reply to that message is the note
|
|
55
|
+
* (telegram_flow_prompt, mode send-back). The chat apps keep their buttons beside their messages (chat-delivery-state.ts). */
|
|
56
|
+
export declare const FLOW_SEND_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_send (\n card INTEGER NOT NULL REFERENCES flow_card(id),\n entry INTEGER NOT NULL,\n stage TEXT NOT NULL,\n person TEXT NOT NULL,\n content_json TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (card, entry)\n);\nCREATE TABLE IF NOT EXISTS telegram_flow_choice (\n token TEXT PRIMARY KEY,\n binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,\n card INTEGER NOT NULL REFERENCES flow_card(id),\n entry INTEGER NOT NULL,\n choice INTEGER NOT NULL,\n label TEXT NOT NULL,\n chat_id TEXT NOT NULL,\n message_id TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);\n";
|
|
52
57
|
export declare const CHAT_DECIDE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_decide_action (\n token TEXT PRIMARY KEY,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n message TEXT,\n act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),\n phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),\n task_id TEXT NOT NULL,\n run INTEGER,\n digest TEXT NOT NULL,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);\nCREATE TABLE IF NOT EXISTS chat_decide_prompt (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n task_id TEXT NOT NULL,\n run INTEGER NOT NULL,\n digest TEXT NOT NULL,\n message TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);\n";
|
|
53
58
|
export type MateAsk = {
|
|
54
59
|
turn: number;
|
|
@@ -1007,6 +1012,9 @@ export type NotificationPreference = {
|
|
|
1007
1012
|
};
|
|
1008
1013
|
/** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
|
|
1009
1014
|
export declare const RESULT_SHOTS_KIND = "result-screenshots";
|
|
1015
|
+
/** The screenshots a flow's "Send to me" or "Person chooses" visit sends its person (flow-send.ts): up to four, whatever
|
|
1016
|
+
* their result-screenshots choice, since the flow asked for them. */
|
|
1017
|
+
export declare const FLOW_SHOTS_KIND = "flow-screenshots";
|
|
1010
1018
|
/** Tasks filed this close together share one chat message. */
|
|
1011
1019
|
export declare const QUIET_GROUP_MS = 60000;
|
|
1012
1020
|
export declare function isDigestTime(value: string): boolean;
|
|
@@ -1463,6 +1471,28 @@ export type TelegramFlowPrompt = {
|
|
|
1463
1471
|
entry: number;
|
|
1464
1472
|
mode: "edit" | "send-back";
|
|
1465
1473
|
};
|
|
1474
|
+
/** One option's button on a "Person chooses" message (flow-send.ts). */
|
|
1475
|
+
export type TelegramFlowChoice = {
|
|
1476
|
+
token: string;
|
|
1477
|
+
binding: number;
|
|
1478
|
+
card: number;
|
|
1479
|
+
entry: number;
|
|
1480
|
+
choice: number;
|
|
1481
|
+
label: string;
|
|
1482
|
+
chatId: string;
|
|
1483
|
+
messageId: string | null;
|
|
1484
|
+
expiresAt: string;
|
|
1485
|
+
consumedAt: string | null;
|
|
1486
|
+
};
|
|
1487
|
+
/** What a "Send to me" or "Person chooses" visit sent its person (flow-send.ts). */
|
|
1488
|
+
export type FlowSendRow = {
|
|
1489
|
+
card: number;
|
|
1490
|
+
entry: number;
|
|
1491
|
+
stage: string;
|
|
1492
|
+
person: string;
|
|
1493
|
+
contentJson: string;
|
|
1494
|
+
createdAt: string;
|
|
1495
|
+
};
|
|
1466
1496
|
export type TelegramBinding = {
|
|
1467
1497
|
id: number;
|
|
1468
1498
|
botId: string;
|
|
@@ -5910,6 +5940,27 @@ export declare class Store {
|
|
|
5910
5940
|
recordTelegramFlowPrompt(prompt: TelegramFlowPrompt, now: Date, hours?: number): void;
|
|
5911
5941
|
/** The live prompt a message replies to, if any. */
|
|
5912
5942
|
telegramFlowPrompt(chatId: string, messageId: string, now: Date): TelegramFlowPrompt | null;
|
|
5943
|
+
/** What one visit sent: recorded once, the first time (a later pass finds it kept). */
|
|
5944
|
+
recordFlowSend(row: Omit<FlowSendRow, "createdAt">, now: Date): boolean;
|
|
5945
|
+
flowSend(card: number, entry: number): FlowSendRow | null;
|
|
5946
|
+
/** Every visit's send for a card, newest first: what the card shows it sent. */
|
|
5947
|
+
flowSends(card: number): FlowSendRow[];
|
|
5948
|
+
createTelegramFlowChoices(at: {
|
|
5949
|
+
binding: number;
|
|
5950
|
+
chatId: string;
|
|
5951
|
+
card: number;
|
|
5952
|
+
entry: number;
|
|
5953
|
+
}, choices: readonly {
|
|
5954
|
+
token: string;
|
|
5955
|
+
choice: number;
|
|
5956
|
+
label: string;
|
|
5957
|
+
}[], now: Date, days?: number): void;
|
|
5958
|
+
getTelegramFlowChoice(token: string): TelegramFlowChoice | null;
|
|
5959
|
+
placeTelegramFlowChoices(tokens: readonly string[], messageId: string): void;
|
|
5960
|
+
/** Retire every choice button for one visit of a card (and its reply prompt): it was chosen, or the card moved on. */
|
|
5961
|
+
/** A choice was made (or the card moved on): every option button, reply prompt and "Use this as your note?" for that
|
|
5962
|
+
* visit is spent, in Telegram and in every other chat app, whichever place it was made in. */
|
|
5963
|
+
retireFlowChoices(card: number, entry: number, now: Date): void;
|
|
5913
5964
|
createTelegramQuestionActions(at: {
|
|
5914
5965
|
binding: number;
|
|
5915
5966
|
chatId: string;
|
|
@@ -7305,6 +7356,8 @@ export declare class Store {
|
|
|
7305
7356
|
runningBuildsByRepo(now: Date): Map<string, number>;
|
|
7306
7357
|
/** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
|
|
7307
7358
|
recordProjectConcurrency(by: string, repo: string, before: number, after: number, now: Date): void;
|
|
7359
|
+
/** Review 827: a project's demo URL changed (`off` when it has none). */
|
|
7360
|
+
recordProjectDemo(by: string, repo: string, before: string | null, after: string | null, now: Date): void;
|
|
7308
7361
|
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
7309
7362
|
/** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
|
|
7310
7363
|
queueTelegramUpdate(botId: string, updateId: number, payload: string, now: Date): boolean;
|
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);
|