toolroll 0.9.53 → 0.9.54
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 +8 -0
- package/dist/chat-ask.js +8 -8
- package/dist/chat-core.d.ts +171 -0
- package/dist/chat-core.js +288 -0
- package/dist/chat-decide.d.ts +10 -8
- package/dist/chat-decide.js +26 -21
- package/dist/chat-delivery-state.d.ts +51 -75
- package/dist/chat-delivery-state.js +140 -170
- package/dist/chat-delivery.d.ts +21 -2
- package/dist/chat-delivery.js +313 -194
- package/dist/chat-flow.d.ts +29 -1
- package/dist/chat-flow.js +24 -50
- package/dist/chat-messages.d.ts +319 -0
- package/dist/chat-messages.js +841 -0
- package/dist/chat-migration.d.ts +30 -0
- package/dist/chat-migration.js +802 -0
- package/dist/chat-quiet.d.ts +3 -0
- package/dist/chat-quiet.js +5 -0
- package/dist/contracts/chat-content.d.ts +71 -2
- package/dist/contracts/chat-content.js +22 -3
- package/dist/contracts/chat-tables.d.ts +11 -4
- package/dist/contracts/chat-tables.js +165 -87
- package/dist/contracts/discord-callback.js +2 -1
- package/dist/contracts/slack-callback.js +3 -2
- package/dist/contracts/store-json.d.ts +1 -5
- package/dist/contracts/store-json.js +1 -4
- package/dist/contracts/teams-callback.js +2 -1
- package/dist/discord-chat.js +19 -38
- package/dist/discord-settings.js +4 -13
- package/dist/discord.js +4 -14
- package/dist/export.js +4 -4
- package/dist/mate-tools.js +3 -2
- package/dist/modes.d.ts +13 -1
- package/dist/modes.js +17 -1
- package/dist/notification-rows.d.ts +30 -0
- package/dist/notification-rows.js +102 -0
- package/dist/operate.js +5 -2
- package/dist/plane-review.js +5 -11
- package/dist/project-delete.js +21 -26
- package/dist/result-shots.js +2 -2
- package/dist/retention.js +1 -1
- package/dist/server/settings.js +5 -4
- package/dist/slack-api.d.ts +5 -1
- package/dist/slack-api.js +2 -2
- package/dist/slack-chat.d.ts +9 -4
- package/dist/slack-chat.js +29 -52
- package/dist/slack-settings.js +6 -15
- package/dist/slack.js +6 -16
- package/dist/store.d.ts +97 -364
- package/dist/store.js +207 -1531
- package/dist/teammate-question.js +12 -12
- package/dist/teams-chat.js +12 -9
- package/dist/teams-settings.js +2 -2
- package/dist/teams.js +4 -5
- package/dist/telegram-flow.js +5 -5
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update.d.ts +10 -0
- package/dist/toolroll-update.js +50 -3
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
- package/dist/slack-state.d.ts +0 -13
- package/dist/slack-state.js +0 -10
package/dist/store.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { reclaimDatabase } from "./database-reclaim.js";
|
|
2
|
-
import {
|
|
2
|
+
import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
|
|
3
|
+
import {} from "./chat-core.js";
|
|
4
|
+
import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET, TELEGRAM_UNSETTLED } from "./chat-messages.js";
|
|
5
|
+
import { isLifecycleNotification, isTelegramProgressNotification, LIFECYCLE_KEY_PREFIX, LIFECYCLE_KINDS, proposalTaskOf, readNotification } from "./notification-rows.js";
|
|
6
|
+
import { chatTablesShared, convertChatTables, LEGACY_CHAT_APPS, LEGACY_TELEGRAM_SCHEMA, legacyAppSchema, legacyAppTables, legacyChatProblem, legacyChatTablesPresent } from "./chat-migration.js";
|
|
3
7
|
import { ServerTelemetry } from "./server-telemetry.js";
|
|
4
8
|
import { instrumentDatabase, measureWriteWait } from "./sqlite-telemetry.js";
|
|
5
9
|
import { trackWorkspaceWrites } from "./workspace-revision.js";
|
|
@@ -345,20 +349,6 @@ CREATE TABLE IF NOT EXISTS flow_send (
|
|
|
345
349
|
created_at TEXT NOT NULL,
|
|
346
350
|
PRIMARY KEY (card, entry)
|
|
347
351
|
);
|
|
348
|
-
CREATE TABLE IF NOT EXISTS telegram_flow_choice (
|
|
349
|
-
token TEXT PRIMARY KEY,
|
|
350
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
351
|
-
card INTEGER NOT NULL REFERENCES flow_card(id),
|
|
352
|
-
entry INTEGER NOT NULL,
|
|
353
|
-
choice INTEGER NOT NULL,
|
|
354
|
-
label TEXT NOT NULL,
|
|
355
|
-
chat_id TEXT NOT NULL,
|
|
356
|
-
message_id TEXT,
|
|
357
|
-
created_at TEXT NOT NULL,
|
|
358
|
-
expires_at TEXT NOT NULL,
|
|
359
|
-
consumed_at TEXT
|
|
360
|
-
);
|
|
361
|
-
CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);
|
|
362
352
|
`;
|
|
363
353
|
/** An owner's lasting choice to approve plans and merges from their paired chat (chat-approval.ts), for all their
|
|
364
354
|
* projects (scope '*') or one (its path), with the limits they agreed to. Off unless a row says on; a project's own row
|
|
@@ -387,23 +377,6 @@ CREATE TABLE IF NOT EXISTS chat_approval_setting (
|
|
|
387
377
|
PRIMARY KEY (approver, scope)
|
|
388
378
|
);
|
|
389
379
|
`;
|
|
390
|
-
/** The second tap on a flow decision in Telegram (telegram-flow.ts): Approve arms Yes and Cancel, bound to the binding,
|
|
391
|
-
* chat, message and exact visit of the card; Yes decides it once. */
|
|
392
|
-
export const TELEGRAM_FLOW_CONFIRM_SCHEMA = `
|
|
393
|
-
CREATE TABLE IF NOT EXISTS telegram_flow_confirm (
|
|
394
|
-
token TEXT PRIMARY KEY,
|
|
395
|
-
binding INTEGER NOT NULL,
|
|
396
|
-
chat_id TEXT NOT NULL,
|
|
397
|
-
message_id TEXT NOT NULL,
|
|
398
|
-
card INTEGER NOT NULL,
|
|
399
|
-
entry INTEGER NOT NULL,
|
|
400
|
-
phase TEXT NOT NULL CHECK (phase IN ('yes','cancel')),
|
|
401
|
-
created_at TEXT NOT NULL,
|
|
402
|
-
expires_at TEXT NOT NULL,
|
|
403
|
-
consumed_at TEXT
|
|
404
|
-
);
|
|
405
|
-
CREATE INDEX IF NOT EXISTS telegram_flow_confirm_visit ON telegram_flow_confirm (card, entry);
|
|
406
|
-
`;
|
|
407
380
|
export const CHAT_DECIDE_SCHEMA = `
|
|
408
381
|
CREATE TABLE IF NOT EXISTS chat_decide_action (
|
|
409
382
|
token TEXT PRIMARY KEY,
|
|
@@ -587,7 +560,8 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
587
560
|
// notification's single-destination delivery columns move into legacy receipts, and the workspace revision triggers are gone
|
|
588
561
|
// (the write wrapper bumps the revision). Every later DDL change bumps the version.
|
|
589
562
|
// v115 removes contests, held sessions, fallback chains and routines (each routine becomes a scheduled flow).
|
|
590
|
-
|
|
563
|
+
// v116 keeps every chat app (Telegram, Slack, Discord, Teams) in one set of chat tables keyed by provider, moving each old table's rows.
|
|
564
|
+
export const SCHEMA_VERSION = 116;
|
|
591
565
|
/** v115: the tables migrateToV115 drops. */
|
|
592
566
|
export const V115_DROPPED_TABLES = Object.freeze([
|
|
593
567
|
"tournament_terms", "contest", "contestant",
|
|
@@ -622,9 +596,12 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
622
596
|
* the routine was approved and running). Its other changes add a column or
|
|
623
597
|
* settle rows in place; a database whose removed features still held a hold
|
|
624
598
|
* to lift or place changes that table's rows and is refused, so it takes the
|
|
625
|
-
* separate procedure.
|
|
599
|
+
* separate procedure. v116 is the next declared exception: every row of the 70 old
|
|
600
|
+
* per-app chat tables moves into the shared chat tables keyed by provider, in one
|
|
601
|
+
* transaction that checks each count before the old tables are dropped; the
|
|
602
|
+
* rehearsal checks each moved table's rows arrived (HISTORY_RULES).
|
|
626
603
|
*/
|
|
627
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110, 111, 112, 113, 114, 115]);
|
|
604
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110, 111, 112, 113, 114, 115, 116]);
|
|
628
605
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
629
606
|
export function updateSafeSchema(version) {
|
|
630
607
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -863,23 +840,6 @@ function readFlowTriggerRow(row) {
|
|
|
863
840
|
hookHash: optional("hook_hash"), cursor: optional("cursor"), nextAt: optional("next_at"), lastAt: optional("last_at"), lastOutcome: optional("last_outcome"),
|
|
864
841
|
failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]) };
|
|
865
842
|
}
|
|
866
|
-
/** The one task a card is about: a task its confirmation created (a new
|
|
867
|
-
* task, a revision), else the task it names (and, for feedback on a result,
|
|
868
|
-
* that result). Null for a card about no single task. A pointer for
|
|
869
|
-
* replies, never an authority. */
|
|
870
|
-
export function proposalTaskOf(proposal) {
|
|
871
|
-
if (proposal === null)
|
|
872
|
-
return null;
|
|
873
|
-
const named = typeof proposal.payload["task"] === "string" && proposal.payload["task"] !== "" ? proposal.payload["task"] : null;
|
|
874
|
-
const creates = proposal.kind === "task" || (proposal.kind === "review" && proposal.payload["operation"] === "revise");
|
|
875
|
-
const made = creates && proposal.state === "confirmed" ? proposal.outcome?.["taskId"] : undefined;
|
|
876
|
-
if (typeof made === "string" && made !== "" && made !== named)
|
|
877
|
-
return { task: made, run: null };
|
|
878
|
-
if (named === null)
|
|
879
|
-
return null;
|
|
880
|
-
const run = proposal.kind === "review" ? proposal.payload["run"] : null;
|
|
881
|
-
return { task: named, run: typeof run === "number" && Number.isSafeInteger(run) ? run : null };
|
|
882
|
-
}
|
|
883
843
|
/** `kind:name`, the unambiguous way a requirement names a capability. */
|
|
884
844
|
export function capabilityKey(capability) {
|
|
885
845
|
return `${capability.kind}:${capability.name}`;
|
|
@@ -894,56 +854,6 @@ export function parseCapabilityKey(key) {
|
|
|
894
854
|
return null;
|
|
895
855
|
return { kind: kind, name: key.slice(split + 1) };
|
|
896
856
|
}
|
|
897
|
-
/**
|
|
898
|
-
* Task lifecycle updates (Telegram task updates, 2026-09-16): the CLOSED
|
|
899
|
-
* vocabulary of routine progress facts the shared mutations record beside
|
|
900
|
-
* the change that made them true. None carries a push class — they are
|
|
901
|
-
* digest-eligible progress, never a page — and every one is project-bound
|
|
902
|
-
* through the task it names. A decision, incident, gap, stall, publication
|
|
903
|
-
* or merge page keeps its own producer; nothing here repeats one.
|
|
904
|
-
*/
|
|
905
|
-
export const LIFECYCLE_KINDS = [
|
|
906
|
-
"task-filed",
|
|
907
|
-
"scope-approved",
|
|
908
|
-
"approval-withdrawn",
|
|
909
|
-
"task-held",
|
|
910
|
-
"task-released",
|
|
911
|
-
"task-queued",
|
|
912
|
-
"task-requeued",
|
|
913
|
-
"task-cancelled",
|
|
914
|
-
"run-started",
|
|
915
|
-
"run-phase",
|
|
916
|
-
"check-progress",
|
|
917
|
-
"run-finished",
|
|
918
|
-
"run-stopping",
|
|
919
|
-
"run-stopped",
|
|
920
|
-
"run-resumed",
|
|
921
|
-
"review-requested",
|
|
922
|
-
"review-finished",
|
|
923
|
-
"acceptance-evidence",
|
|
924
|
-
"acceptance-ready",
|
|
925
|
-
];
|
|
926
|
-
/** Every lifecycle dedupe key starts here: `life:<kind>:<identity>:<ordinal>`. */
|
|
927
|
-
export const LIFECYCLE_KEY_PREFIX = "life:";
|
|
928
|
-
/** A progress fact, told apart by its durable key — never by display text. */
|
|
929
|
-
export function isLifecycleNotification(row) {
|
|
930
|
-
return row.dedupeKey.startsWith(LIFECYCLE_KEY_PREFIX);
|
|
931
|
-
}
|
|
932
|
-
/** Only known progress producers can repaint a card. Decisions and urgent
|
|
933
|
-
* incidents keep their own alerts; a routine retry updates its saved attempt. */
|
|
934
|
-
export function isTelegramProgressNotification(row) {
|
|
935
|
-
if (row.kind === "check-progress" && row.pushClass === "progress" && row.run !== null)
|
|
936
|
-
return isLifecycleNotification(row);
|
|
937
|
-
if (row.pushClass !== null)
|
|
938
|
-
return false;
|
|
939
|
-
if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
|
|
940
|
-
return true;
|
|
941
|
-
// The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
|
|
942
|
-
if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
|
|
943
|
-
return true;
|
|
944
|
-
return isLifecycleNotification(row) &&
|
|
945
|
-
["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
|
|
946
|
-
}
|
|
947
857
|
/** Display words for a lifecycle fact: one line, control-free, bounded, with
|
|
948
858
|
* paths and long hex hidden the way the phone scrub hides them. The inputs
|
|
949
859
|
* are already validated task text or identifiers; this is the belt. */
|
|
@@ -1825,19 +1735,6 @@ CREATE TABLE IF NOT EXISTS run_tool (
|
|
|
1825
1735
|
created_at TEXT NOT NULL
|
|
1826
1736
|
);
|
|
1827
1737
|
|
|
1828
|
-
-- v79: a Telegram message that showed one task (a /task status, a task
|
|
1829
|
-
-- picked from /tasks): a reply to it is about that task. Like chat_focus,
|
|
1830
|
-
-- a pointer and never an authority — the turn re-proves the task.
|
|
1831
|
-
CREATE TABLE IF NOT EXISTS telegram_task_message (
|
|
1832
|
-
binding INTEGER NOT NULL,
|
|
1833
|
-
chat_id TEXT NOT NULL,
|
|
1834
|
-
message_id TEXT NOT NULL,
|
|
1835
|
-
task_id TEXT NOT NULL,
|
|
1836
|
-
source_run INTEGER,
|
|
1837
|
-
created_at TEXT NOT NULL,
|
|
1838
|
-
PRIMARY KEY (binding, chat_id, message_id)
|
|
1839
|
-
);
|
|
1840
|
-
|
|
1841
1738
|
CREATE TABLE IF NOT EXISTS mate_proposal (
|
|
1842
1739
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1843
1740
|
thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,
|
|
@@ -2149,20 +2046,6 @@ CREATE TABLE IF NOT EXISTS incident (
|
|
|
2149
2046
|
-- gap nags once per occurrence, not once per cron firing and not forever.
|
|
2150
2047
|
${NOTIFICATION_DDL("notification")};
|
|
2151
2048
|
|
|
2152
|
-
-- Away mode (v34, mate arc §10): the Telegram bridge's digest cadence.
|
|
2153
|
-
-- One row. every_ms NULL = off (every fact pages as it lands); set, the
|
|
2154
|
-
-- bridge holds ROUTINE rows unclaimed until the window elapses and sends
|
|
2155
|
-
-- them as one message — decisions and attention-class facts still page
|
|
2156
|
-
-- singly. last_sent_at anchors the window; it never deletes a row.
|
|
2157
|
-
CREATE TABLE IF NOT EXISTS telegram_digest (
|
|
2158
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
2159
|
-
every_ms INTEGER,
|
|
2160
|
-
set_by TEXT,
|
|
2161
|
-
set_at TEXT,
|
|
2162
|
-
last_sent_at TEXT
|
|
2163
|
-
);
|
|
2164
|
-
INSERT OR IGNORE INTO telegram_digest (id, every_ms) VALUES (1, NULL);
|
|
2165
|
-
|
|
2166
2049
|
-- Permission to write to a tracker, one row per repository and backend.
|
|
2167
2050
|
-- Absence of a row is denial; there is no wildcard and no inheritance.
|
|
2168
2051
|
CREATE TABLE IF NOT EXISTS backend_grant (
|
|
@@ -2465,50 +2348,6 @@ CREATE TABLE IF NOT EXISTS approver (
|
|
|
2465
2348
|
generation INTEGER NOT NULL DEFAULT 1
|
|
2466
2349
|
);
|
|
2467
2350
|
|
|
2468
|
-
-- One Telegram chat speaking as one approver. Bindings are never deleted:
|
|
2469
|
-
-- revocation is a stamp, because "who could answer as whom, when" is an
|
|
2470
|
-
-- audit question a DELETE cannot answer. The partial unique index (v72) is
|
|
2471
|
-
-- the rule that one Telegram user has one live binding per bot: several
|
|
2472
|
-
-- teammates pair their own private chats with the same bot.
|
|
2473
|
-
CREATE TABLE IF NOT EXISTS telegram_binding (
|
|
2474
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2475
|
-
bot_id TEXT NOT NULL,
|
|
2476
|
-
chat_id TEXT NOT NULL,
|
|
2477
|
-
user_id TEXT NOT NULL,
|
|
2478
|
-
approver TEXT NOT NULL REFERENCES approver(name) ON DELETE RESTRICT,
|
|
2479
|
-
approver_generation INTEGER NOT NULL,
|
|
2480
|
-
paired_at TEXT NOT NULL,
|
|
2481
|
-
paired_by TEXT NOT NULL,
|
|
2482
|
-
pair_update_id INTEGER,
|
|
2483
|
-
revoked_at TEXT,
|
|
2484
|
-
revoked_by TEXT
|
|
2485
|
-
);
|
|
2486
|
-
CREATE UNIQUE INDEX IF NOT EXISTS telegram_binding_live_user
|
|
2487
|
-
ON telegram_binding (bot_id, user_id) WHERE revoked_at IS NULL;
|
|
2488
|
-
|
|
2489
|
-
-- v72: a Telegram chat's place in the shared team conversations. A group
|
|
2490
|
-
-- follows exactly one team conversation (and a conversation has at most one
|
|
2491
|
-
-- group); a private chat may select one conversation to talk in instead of
|
|
2492
|
-
-- its personal assistant. The cursor is the last conversation message this
|
|
2493
|
-
-- chat received. Rows are revoked, never deleted.
|
|
2494
|
-
CREATE TABLE IF NOT EXISTS telegram_team_chat (
|
|
2495
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2496
|
-
bot_id TEXT NOT NULL,
|
|
2497
|
-
chat_id TEXT NOT NULL,
|
|
2498
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2499
|
-
kind TEXT NOT NULL CHECK (kind IN ('group','private')),
|
|
2500
|
-
conversation TEXT NOT NULL REFERENCES team_conversation(id) ON DELETE RESTRICT,
|
|
2501
|
-
bound_by TEXT NOT NULL,
|
|
2502
|
-
bound_at TEXT NOT NULL,
|
|
2503
|
-
cursor INTEGER NOT NULL DEFAULT 0,
|
|
2504
|
-
revoked_at TEXT,
|
|
2505
|
-
revoked_by TEXT
|
|
2506
|
-
);
|
|
2507
|
-
CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_live
|
|
2508
|
-
ON telegram_team_chat (bot_id, chat_id) WHERE revoked_at IS NULL;
|
|
2509
|
-
CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_group
|
|
2510
|
-
ON telegram_team_chat (conversation) WHERE revoked_at IS NULL AND kind = 'group';
|
|
2511
|
-
|
|
2512
2351
|
-- v61: destination receipts around the existing outbox, independent of
|
|
2513
2352
|
-- push_delivery: where each notification went, per destination (v114
|
|
2514
2353
|
-- removed the old single-destination receipt). No second scheduler.
|
|
@@ -2526,255 +2365,6 @@ CREATE TABLE IF NOT EXISTS notification_delivery (
|
|
|
2526
2365
|
receipt TEXT,
|
|
2527
2366
|
PRIMARY KEY (notification, destination)
|
|
2528
2367
|
);
|
|
2529
|
-
CREATE TABLE IF NOT EXISTS telegram_outbound_message (
|
|
2530
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2531
|
-
bot_id TEXT NOT NULL,
|
|
2532
|
-
chat_id TEXT NOT NULL,
|
|
2533
|
-
message_id TEXT NOT NULL,
|
|
2534
|
-
notification INTEGER NOT NULL REFERENCES notification(id) ON DELETE RESTRICT,
|
|
2535
|
-
destination TEXT NOT NULL,
|
|
2536
|
-
project TEXT,
|
|
2537
|
-
task_ref INTEGER REFERENCES task_ref(id),
|
|
2538
|
-
task_id TEXT,
|
|
2539
|
-
source_run INTEGER REFERENCES run(id),
|
|
2540
|
-
created_at TEXT NOT NULL,
|
|
2541
|
-
PRIMARY KEY (binding, chat_id, message_id, notification)
|
|
2542
|
-
);
|
|
2543
|
-
CREATE TABLE IF NOT EXISTS telegram_retry (
|
|
2544
|
-
bot_id TEXT PRIMARY KEY,
|
|
2545
|
-
next_attempt_at TEXT NOT NULL
|
|
2546
|
-
);
|
|
2547
|
-
|
|
2548
|
-
-- One-time pairing codes, hashed like every other credential, consumed in
|
|
2549
|
-
-- one transaction with the binding they create.
|
|
2550
|
-
CREATE TABLE IF NOT EXISTS telegram_pairing (
|
|
2551
|
-
code_hash TEXT PRIMARY KEY,
|
|
2552
|
-
approver TEXT NOT NULL REFERENCES approver(name) ON DELETE RESTRICT,
|
|
2553
|
-
approver_generation INTEGER NOT NULL,
|
|
2554
|
-
created_at TEXT NOT NULL,
|
|
2555
|
-
created_by TEXT NOT NULL,
|
|
2556
|
-
expires_at TEXT NOT NULL,
|
|
2557
|
-
consumed_at TEXT,
|
|
2558
|
-
consumed_chat TEXT,
|
|
2559
|
-
consumed_user TEXT,
|
|
2560
|
-
consumed_update INTEGER
|
|
2561
|
-
);
|
|
2562
|
-
|
|
2563
|
-
-- Every Telegram update this installation has applied, exactly once. The
|
|
2564
|
-
-- PRIMARY KEY is the idempotency: a replayed batch re-applies nothing.
|
|
2565
|
-
CREATE TABLE IF NOT EXISTS telegram_update (
|
|
2566
|
-
update_id INTEGER PRIMARY KEY,
|
|
2567
|
-
applied_at TEXT NOT NULL,
|
|
2568
|
-
result TEXT NOT NULL
|
|
2569
|
-
);
|
|
2570
|
-
|
|
2571
|
-
-- Opaque one-tap actions. callback_data carries only the random token; what
|
|
2572
|
-
-- the tap MEANS — which binding, decision, option, and phase — lives here,
|
|
2573
|
-
-- where a stolen bot token cannot read or forge it. Confirm challenges are
|
|
2574
|
-
-- short-lived rows in the same table, consumed exactly once.
|
|
2575
|
-
CREATE TABLE IF NOT EXISTS telegram_action (
|
|
2576
|
-
token TEXT PRIMARY KEY,
|
|
2577
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2578
|
-
decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
|
|
2579
|
-
option_id TEXT NOT NULL,
|
|
2580
|
-
phase TEXT NOT NULL CHECK (phase IN ('choose','confirm','cancel')),
|
|
2581
|
-
chat_id TEXT NOT NULL,
|
|
2582
|
-
message_id TEXT,
|
|
2583
|
-
created_at TEXT NOT NULL,
|
|
2584
|
-
expires_at TEXT,
|
|
2585
|
-
consumed_at TEXT
|
|
2586
|
-
);
|
|
2587
|
-
CREATE INDEX IF NOT EXISTS telegram_action_by_decision ON telegram_action (decision);
|
|
2588
|
-
|
|
2589
|
-
-- v86: a flow card waiting at a "Person decides" zone reaches the decider on
|
|
2590
|
-
-- Telegram with Approve / Edit / Send back. Each button is one opaque token
|
|
2591
|
-
-- for one visit of one card (card, entry), placed on the message it rides so
|
|
2592
|
-
-- a tap on any other message proves itself stale. Edit and Send back ask
|
|
2593
|
-
-- for a reply to a prompt: telegram_flow_prompt names which prompt means
|
|
2594
|
-
-- what, so the reply becomes the new draft or the note.
|
|
2595
|
-
CREATE TABLE IF NOT EXISTS telegram_flow_action (
|
|
2596
|
-
token TEXT PRIMARY KEY,
|
|
2597
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2598
|
-
card INTEGER NOT NULL REFERENCES flow_card(id),
|
|
2599
|
-
entry INTEGER NOT NULL,
|
|
2600
|
-
action TEXT NOT NULL CHECK (action IN ('approve','edit','send-back')),
|
|
2601
|
-
chat_id TEXT NOT NULL,
|
|
2602
|
-
message_id TEXT,
|
|
2603
|
-
created_at TEXT NOT NULL,
|
|
2604
|
-
expires_at TEXT NOT NULL,
|
|
2605
|
-
consumed_at TEXT
|
|
2606
|
-
);
|
|
2607
|
-
CREATE INDEX IF NOT EXISTS telegram_flow_action_visit ON telegram_flow_action (card, entry);
|
|
2608
|
-
-- v93: a teammate's question on Telegram. One button per option (choice)
|
|
2609
|
-
-- and one to reply in words (choice NULL), placed on the message it rides;
|
|
2610
|
-
-- a reply to the prompt the Reply button sends is the answer.
|
|
2611
|
-
CREATE TABLE IF NOT EXISTS telegram_question_action (
|
|
2612
|
-
token TEXT PRIMARY KEY,
|
|
2613
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2614
|
-
question INTEGER NOT NULL REFERENCES teammate_question(id),
|
|
2615
|
-
choice TEXT,
|
|
2616
|
-
chat_id TEXT NOT NULL,
|
|
2617
|
-
message_id TEXT,
|
|
2618
|
-
created_at TEXT NOT NULL,
|
|
2619
|
-
expires_at TEXT NOT NULL,
|
|
2620
|
-
consumed_at TEXT
|
|
2621
|
-
);
|
|
2622
|
-
CREATE TABLE IF NOT EXISTS telegram_question_prompt (
|
|
2623
|
-
chat_id TEXT NOT NULL,
|
|
2624
|
-
message_id TEXT NOT NULL,
|
|
2625
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2626
|
-
question INTEGER NOT NULL REFERENCES teammate_question(id),
|
|
2627
|
-
created_at TEXT NOT NULL,
|
|
2628
|
-
expires_at TEXT NOT NULL,
|
|
2629
|
-
consumed_at TEXT,
|
|
2630
|
-
PRIMARY KEY (chat_id, message_id)
|
|
2631
|
-
);
|
|
2632
|
-
CREATE TABLE IF NOT EXISTS telegram_flow_prompt (
|
|
2633
|
-
chat_id TEXT NOT NULL,
|
|
2634
|
-
message_id TEXT NOT NULL,
|
|
2635
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2636
|
-
card INTEGER NOT NULL REFERENCES flow_card(id),
|
|
2637
|
-
entry INTEGER NOT NULL,
|
|
2638
|
-
mode TEXT NOT NULL CHECK (mode IN ('edit','send-back')),
|
|
2639
|
-
created_at TEXT NOT NULL,
|
|
2640
|
-
expires_at TEXT NOT NULL,
|
|
2641
|
-
consumed_at TEXT,
|
|
2642
|
-
PRIMARY KEY (chat_id, message_id)
|
|
2643
|
-
);
|
|
2644
|
-
|
|
2645
|
-
-- Which outbound Telegram message carries which decision (v10). A free-text
|
|
2646
|
-
-- reply is routed through the EXACT message it replies to — never "the
|
|
2647
|
-
-- latest decision", never "the only open one" (Codex free-text review,
|
|
2648
|
-
-- finding 1). Losing the send/record race fails closed: an unrecorded
|
|
2649
|
-
-- message routes nothing.
|
|
2650
|
-
CREATE TABLE IF NOT EXISTS telegram_decision_message (
|
|
2651
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE CASCADE,
|
|
2652
|
-
chat_id TEXT NOT NULL,
|
|
2653
|
-
message_id TEXT NOT NULL,
|
|
2654
|
-
decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
|
|
2655
|
-
created_at TEXT NOT NULL,
|
|
2656
|
-
PRIMARY KEY (binding, chat_id, message_id)
|
|
2657
|
-
);
|
|
2658
|
-
|
|
2659
|
-
-- The free-text draft (v10): an operator's note, held immutable and
|
|
2660
|
-
-- expiring until a TAP commits it with the choice. One live draft per
|
|
2661
|
-
-- (binding, decision); a newer valid reply SUPERSEDES, never edits.
|
|
2662
|
-
CREATE TABLE IF NOT EXISTS telegram_note_draft (
|
|
2663
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2664
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE CASCADE,
|
|
2665
|
-
decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
|
|
2666
|
-
update_id INTEGER NOT NULL,
|
|
2667
|
-
message_id TEXT NOT NULL,
|
|
2668
|
-
reply_to TEXT NOT NULL,
|
|
2669
|
-
note TEXT NOT NULL,
|
|
2670
|
-
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','armed','superseded','consumed','discarded')),
|
|
2671
|
-
created_at TEXT NOT NULL,
|
|
2672
|
-
expires_at TEXT NOT NULL
|
|
2673
|
-
);
|
|
2674
|
-
|
|
2675
|
-
CREATE UNIQUE INDEX IF NOT EXISTS telegram_note_draft_live
|
|
2676
|
-
ON telegram_note_draft (binding, decision) WHERE state IN ('pending','armed');
|
|
2677
|
-
|
|
2678
|
-
-- v62: the durable inbound conversation queue. An ordinary paired message
|
|
2679
|
-
-- becomes a row in the SAME transaction that marks its update applied, so
|
|
2680
|
-
-- the poll cursor never moves past text nobody holds. The row carries the
|
|
2681
|
-
-- exact binding, sender, update, message and the request identity the
|
|
2682
|
-
-- shared mate engine receipts it under; the async model turn runs OUTSIDE
|
|
2683
|
-
-- any transaction under a short claim, and a replay or restart finds the
|
|
2684
|
-
-- receipt instead of dispatching the provider again.
|
|
2685
|
-
CREATE TABLE IF NOT EXISTS telegram_conversation (
|
|
2686
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2687
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2688
|
-
bot_id TEXT NOT NULL,
|
|
2689
|
-
chat_id TEXT NOT NULL,
|
|
2690
|
-
user_id TEXT NOT NULL,
|
|
2691
|
-
approver TEXT NOT NULL,
|
|
2692
|
-
approver_generation INTEGER NOT NULL,
|
|
2693
|
-
update_id INTEGER NOT NULL UNIQUE,
|
|
2694
|
-
message_id TEXT NOT NULL,
|
|
2695
|
-
reply_to TEXT,
|
|
2696
|
-
request TEXT NOT NULL UNIQUE,
|
|
2697
|
-
text TEXT NOT NULL,
|
|
2698
|
-
context TEXT,
|
|
2699
|
-
task_id TEXT,
|
|
2700
|
-
source_run INTEGER,
|
|
2701
|
-
state TEXT NOT NULL CHECK (state IN ('queued','running','done','failed')),
|
|
2702
|
-
claim_owner TEXT,
|
|
2703
|
-
claim_expires_at TEXT,
|
|
2704
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2705
|
-
next_attempt_at TEXT,
|
|
2706
|
-
session INTEGER,
|
|
2707
|
-
turn INTEGER,
|
|
2708
|
-
outcome TEXT,
|
|
2709
|
-
reply_message_id TEXT,
|
|
2710
|
-
created_at TEXT NOT NULL,
|
|
2711
|
-
started_at TEXT,
|
|
2712
|
-
finished_at TEXT
|
|
2713
|
-
);
|
|
2714
|
-
CREATE INDEX IF NOT EXISTS telegram_conversation_queue ON telegram_conversation (bot_id, state, id);
|
|
2715
|
-
|
|
2716
|
-
-- v62: opaque one-tap tokens for the mate's proposal cards, the same shape
|
|
2717
|
-
-- as telegram_action for decisions: callback_data carries only the token,
|
|
2718
|
-
-- and what a tap MEANS (which binding, which proposal, which phase) lives
|
|
2719
|
-
-- here. Consumed exactly once; an irreversible answer arms a yes/cancel
|
|
2720
|
-
-- pair first, exactly as a decision button does.
|
|
2721
|
-
CREATE TABLE IF NOT EXISTS telegram_proposal_action (
|
|
2722
|
-
token TEXT PRIMARY KEY,
|
|
2723
|
-
binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
|
|
2724
|
-
proposal INTEGER NOT NULL REFERENCES mate_proposal(id) ON DELETE CASCADE,
|
|
2725
|
-
phase TEXT NOT NULL CHECK (phase IN ('confirm','dismiss','yes','cancel')),
|
|
2726
|
-
chat_id TEXT NOT NULL,
|
|
2727
|
-
message_id TEXT,
|
|
2728
|
-
created_at TEXT NOT NULL,
|
|
2729
|
-
expires_at TEXT,
|
|
2730
|
-
consumed_at TEXT
|
|
2731
|
-
);
|
|
2732
|
-
CREATE INDEX IF NOT EXISTS telegram_proposal_action_by_proposal ON telegram_proposal_action (proposal);
|
|
2733
|
-
|
|
2734
|
-
-- v63: the outbound half of a conversation, durable BEFORE any send. Once
|
|
2735
|
-
-- the engine's turn is answered (or recovered from its receipt), the exact
|
|
2736
|
-
-- reply text, split into Telegram-sized parts, and one card per pending
|
|
2737
|
-
-- proposal are written here in one transaction; the bridge then sends
|
|
2738
|
-
-- them in order under the row's claim, marking a part sent only when
|
|
2739
|
-
-- Telegram confirmed a message id. A crash, an outage, a rate limit or a
|
|
2740
|
-
-- restart resumes from the first unsent part — never another model call,
|
|
2741
|
-
-- proposal, task or revision. A card's buttons are minted with the part,
|
|
2742
|
-
-- so a resend carries the same tokens. A part whose network answer was
|
|
2743
|
-
-- lost is counted as uncertain: a resend may duplicate it, and the row
|
|
2744
|
-
-- says so instead of claiming an exactly-once Telegram cannot provide.
|
|
2745
|
-
-- v64: an 'image' part is one verified screenshot of one exact result,
|
|
2746
|
-
-- named by typed columns — task, run, artifact and the hash its record
|
|
2747
|
-
-- carried when the part was planned — never by JSON inside text or a
|
|
2748
|
-
-- keyboard, and never by bytes: every send re-reads the artifact from the
|
|
2749
|
-
-- evidence root and re-verifies it against these columns first. Its text
|
|
2750
|
-
-- is the short caption. A confirmed image message binds replies to that
|
|
2751
|
-
-- exact task and run, exactly as an outbox fact's message does.
|
|
2752
|
-
CREATE TABLE IF NOT EXISTS telegram_conversation_part (
|
|
2753
|
-
conversation INTEGER NOT NULL REFERENCES telegram_conversation(id) ON DELETE CASCADE,
|
|
2754
|
-
ordinal INTEGER NOT NULL,
|
|
2755
|
-
kind TEXT NOT NULL CHECK (kind IN ('reply','card','image')),
|
|
2756
|
-
text TEXT NOT NULL,
|
|
2757
|
-
reply_to TEXT,
|
|
2758
|
-
proposal INTEGER REFERENCES mate_proposal(id) ON DELETE SET NULL,
|
|
2759
|
-
keyboard_json TEXT,
|
|
2760
|
-
state TEXT NOT NULL CHECK (state IN ('pending','sent','dropped')),
|
|
2761
|
-
message_id TEXT,
|
|
2762
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2763
|
-
uncertain INTEGER NOT NULL DEFAULT 0,
|
|
2764
|
-
next_attempt_at TEXT,
|
|
2765
|
-
last_error TEXT,
|
|
2766
|
-
created_at TEXT NOT NULL,
|
|
2767
|
-
sent_at TEXT,
|
|
2768
|
-
task_id TEXT,
|
|
2769
|
-
source_run INTEGER REFERENCES run(id),
|
|
2770
|
-
artifact INTEGER,
|
|
2771
|
-
sha256 TEXT,
|
|
2772
|
-
PRIMARY KEY (conversation, ordinal),
|
|
2773
|
-
CHECK ((state = 'sent') = (message_id IS NOT NULL)),
|
|
2774
|
-
CHECK ((state = 'sent') = (sent_at IS NOT NULL)),
|
|
2775
|
-
CHECK ((kind = 'image') = (task_id IS NOT NULL AND source_run IS NOT NULL AND artifact IS NOT NULL AND sha256 IS NOT NULL))
|
|
2776
|
-
);
|
|
2777
|
-
|
|
2778
2368
|
-- v64: the screenshots one mate turn selected for an exact result, kept
|
|
2779
2369
|
-- under the turn so a channel that delivers files (Telegram) plans them
|
|
2780
2370
|
-- from the completed turn after a crash without another model call. Only
|
|
@@ -2798,28 +2388,6 @@ CREATE TABLE IF NOT EXISTS mate_turn_evidence (
|
|
|
2798
2388
|
UNIQUE (turn, artifact)
|
|
2799
2389
|
);
|
|
2800
2390
|
|
|
2801
|
-
-- The bridge's poll lease and cursor, per bot. One live poller at a time;
|
|
2802
|
-
-- the cursor only ever moves forward, and only under a live generation.
|
|
2803
|
-
CREATE TABLE IF NOT EXISTS bridge_lease (
|
|
2804
|
-
bot_id TEXT PRIMARY KEY,
|
|
2805
|
-
owner TEXT NOT NULL,
|
|
2806
|
-
generation INTEGER NOT NULL,
|
|
2807
|
-
cursor INTEGER NOT NULL DEFAULT 0,
|
|
2808
|
-
expires_at TEXT NOT NULL,
|
|
2809
|
-
heartbeat_at TEXT NOT NULL,
|
|
2810
|
-
push_url TEXT,
|
|
2811
|
-
push_at TEXT,
|
|
2812
|
-
push_problem TEXT
|
|
2813
|
-
);
|
|
2814
|
-
-- v98: updates Telegram pushed to /hooks/telegram, kept until the bridge
|
|
2815
|
-
-- applies them (in update order, through the same door as polled ones).
|
|
2816
|
-
CREATE TABLE IF NOT EXISTS telegram_inbox (
|
|
2817
|
-
update_id INTEGER PRIMARY KEY,
|
|
2818
|
-
bot_id TEXT NOT NULL,
|
|
2819
|
-
payload TEXT NOT NULL,
|
|
2820
|
-
received_at TEXT NOT NULL
|
|
2821
|
-
);
|
|
2822
|
-
|
|
2823
2391
|
-- Provider quota, keyed to what actually exhausts: one runner's credential
|
|
2824
2392
|
-- against one provider and scope — never the whole runner (§8 gate 3). A
|
|
2825
2393
|
-- free CPU slot against an exhausted quota is not capacity. 'half-open'
|
|
@@ -3655,7 +3223,14 @@ function initializeStore(db, file) {
|
|
|
3655
3223
|
if (!tableExists(db, table))
|
|
3656
3224
|
throw new Error(`${file}: team history is missing; refusing to recreate membership or queued work`);
|
|
3657
3225
|
}
|
|
3658
|
-
|
|
3226
|
+
// v116 moved every chat app onto the shared chat tables. Until then (or until an interrupted move finishes) the old
|
|
3227
|
+
// tables must be here, in a shape the move knows; after it, the shared ones must be.
|
|
3228
|
+
const chatsShared = chatTablesShared(db) && !legacyChatTablesPresent(db);
|
|
3229
|
+
if (preflight !== null && Math.abs(preflight) >= 116 && !chatsShared) {
|
|
3230
|
+
throw new Error(`${file}: chat history is missing; refusing to recreate pairings and receipts`);
|
|
3231
|
+
}
|
|
3232
|
+
const legacyChats = preflight !== null && !chatsShared;
|
|
3233
|
+
if (legacyChats && Math.abs(preflight ?? 0) >= 72) {
|
|
3659
3234
|
if (!tableExists(db, "telegram_team_chat"))
|
|
3660
3235
|
throw new Error(`${file}: Telegram team chat history is missing; refusing to recreate subscriptions`);
|
|
3661
3236
|
if (!hasColumn(db, "telegram_team_chat", "binding"))
|
|
@@ -3696,7 +3271,7 @@ function initializeStore(db, file) {
|
|
|
3696
3271
|
// the preflight and here has already moved the row, and nothing alters a
|
|
3697
3272
|
// file whose version it did not read.
|
|
3698
3273
|
if (preflight !== null && Math.abs(preflight) >= 61) {
|
|
3699
|
-
for (const table of ["notification_delivery", "telegram_outbound_message", "telegram_retry"]) {
|
|
3274
|
+
for (const table of legacyChats ? ["notification_delivery", "telegram_outbound_message", "telegram_retry"] : ["notification_delivery"]) {
|
|
3700
3275
|
if (!tableExists(db, table))
|
|
3701
3276
|
throw new Error(`${file}: Telegram delivery history is missing; refusing to recreate receipts`);
|
|
3702
3277
|
}
|
|
@@ -3705,18 +3280,18 @@ function initializeStore(db, file) {
|
|
|
3705
3280
|
throw new Error(`${file}: notification provenance is missing; refusing to recreate authority`);
|
|
3706
3281
|
}
|
|
3707
3282
|
}
|
|
3708
|
-
if (
|
|
3283
|
+
if (legacyChats && Math.abs(preflight ?? 0) >= 62) {
|
|
3709
3284
|
for (const table of ["telegram_conversation", "telegram_proposal_action"]) {
|
|
3710
3285
|
if (!tableExists(db, table))
|
|
3711
3286
|
throw new Error(`${file}: Telegram conversation history is missing; refusing to recreate it`);
|
|
3712
3287
|
}
|
|
3713
3288
|
}
|
|
3714
|
-
if (
|
|
3289
|
+
if (legacyChats && Math.abs(preflight ?? 0) >= 63) {
|
|
3715
3290
|
if (!tableExists(db, "telegram_conversation_part"))
|
|
3716
3291
|
throw new Error(`${file}: Telegram reply history is missing; refusing to recreate it`);
|
|
3717
3292
|
}
|
|
3718
3293
|
if (preflight !== null && Math.abs(preflight) >= 64) {
|
|
3719
|
-
if (!tableExists(db, "mate_turn_evidence") || !hasColumn(db, "telegram_conversation_part", "artifact"))
|
|
3294
|
+
if (!tableExists(db, "mate_turn_evidence") || (legacyChats && !hasColumn(db, "telegram_conversation_part", "artifact")))
|
|
3720
3295
|
throw new Error(`${file}: Telegram image history is missing; refusing to recreate it`);
|
|
3721
3296
|
}
|
|
3722
3297
|
if (preflight !== null && Math.abs(preflight) >= 65) {
|
|
@@ -3730,16 +3305,22 @@ function initializeStore(db, file) {
|
|
|
3730
3305
|
if (typeof ddl !== "string" || canonicalDdl(ddl) !== canonicalDdl(MATE_PROPOSAL_V66_DDL("mate_proposal")))
|
|
3731
3306
|
throw new Error(`${file}: shared action history has an unknown shape; refusing to recreate it`);
|
|
3732
3307
|
}
|
|
3733
|
-
if (
|
|
3734
|
-
for (const table of
|
|
3308
|
+
if (legacyChats && Math.abs(preflight ?? 0) >= 67) {
|
|
3309
|
+
for (const table of legacyAppTables("slack").slice(0, 7))
|
|
3735
3310
|
if (!tableExists(db, table))
|
|
3736
3311
|
throw new Error(`${file}: Slack history is missing; refusing to recreate receipts`);
|
|
3737
3312
|
}
|
|
3738
|
-
if (
|
|
3739
|
-
for (const table of
|
|
3313
|
+
if (legacyChats && Math.abs(preflight ?? 0) >= 68) {
|
|
3314
|
+
for (const table of legacyAppTables("discord").slice(0, 7))
|
|
3740
3315
|
if (!tableExists(db, table))
|
|
3741
3316
|
throw new Error(`${file}: Discord history is missing; refusing to recreate receipts`);
|
|
3742
3317
|
}
|
|
3318
|
+
// The old chat tables the v116 move will read: each in a shape it can name, before anything is changed.
|
|
3319
|
+
if (legacyChats) {
|
|
3320
|
+
const problem = legacyChatProblem(db);
|
|
3321
|
+
if (problem !== null)
|
|
3322
|
+
throw new Error(`${file}: ${problem}; refusing to move chat history it cannot name`);
|
|
3323
|
+
}
|
|
3743
3324
|
// A current file is this build's exact shape: the checks above read, the connection gets its settings, and no DDL
|
|
3744
3325
|
// runs. Everything below runs once, for a fresh file or on the way up from an older version.
|
|
3745
3326
|
if (preflight === SCHEMA_VERSION) {
|
|
@@ -3758,9 +3339,14 @@ function initializeStore(db, file) {
|
|
|
3758
3339
|
db.exec(MEMORY_SCHEMA);
|
|
3759
3340
|
db.exec(MODELS_SCHEMA);
|
|
3760
3341
|
db.exec(SKILLS_SCHEMA);
|
|
3761
|
-
db.exec(
|
|
3762
|
-
|
|
3763
|
-
|
|
3342
|
+
db.exec(CHAT_SCHEMA);
|
|
3343
|
+
// An older database keeps its old chat tables until v116 moves them (convertChatTables, after migrateToV115):
|
|
3344
|
+
// the ones its version should have had are created as earlier builds did, so the older steps find them.
|
|
3345
|
+
if (legacyChats) {
|
|
3346
|
+
db.exec(LEGACY_TELEGRAM_SCHEMA);
|
|
3347
|
+
for (const app of LEGACY_CHAT_APPS)
|
|
3348
|
+
db.exec(legacyAppSchema(app));
|
|
3349
|
+
}
|
|
3764
3350
|
db.exec(TEAM_SCHEMA);
|
|
3765
3351
|
db.exec(SSO_SCHEMA);
|
|
3766
3352
|
db.exec(CREDENTIALS_SCHEMA);
|
|
@@ -3783,7 +3369,6 @@ function initializeStore(db, file) {
|
|
|
3783
3369
|
db.exec(CHAT_APPROVAL_SCHEMA);
|
|
3784
3370
|
// What a running agent did last (no version bump: additive only; absent means no word yet).
|
|
3785
3371
|
db.exec(RUN_ACTIVITY_SCHEMA);
|
|
3786
|
-
db.exec(TELEGRAM_FLOW_CONFIRM_SCHEMA);
|
|
3787
3372
|
// A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
|
|
3788
3373
|
db.exec(FLOW_SEND_SCHEMA);
|
|
3789
3374
|
db.exec(SPEND_SCHEMA);
|
|
@@ -3933,6 +3518,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
3933
3518
|
migrateToV114(db);
|
|
3934
3519
|
// v115: the removed features' tables go, after every older step has had its say.
|
|
3935
3520
|
migrateToV115(db);
|
|
3521
|
+
// v116: every chat app onto the shared chat tables, after v115 and every older step gave the old tables their last shape.
|
|
3522
|
+
convertChatTables(db);
|
|
3936
3523
|
if (preflight !== null) {
|
|
3937
3524
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed.
|
|
3938
3525
|
reclaimDatabase(db, "migration");
|
|
@@ -4282,7 +3869,8 @@ function migrate(db, origin) {
|
|
|
4282
3869
|
// v10 (free-text answers): two new tables via the fresh SCHEMA, plus the
|
|
4283
3870
|
// digest that binds an irreversible confirmation to the EXACT note it
|
|
4284
3871
|
// confirmed (Codex free-text review, finding 3).
|
|
4285
|
-
|
|
3872
|
+
if (tableExists(db, "telegram_action"))
|
|
3873
|
+
addColumn(db, "telegram_action", "note_digest", "TEXT");
|
|
4286
3874
|
// v11 (M5 worktree setup): additive — the worktree_setup table arrives
|
|
4287
3875
|
// through the fresh SCHEMA's IF NOT EXISTS, and existing worktree rows
|
|
4288
3876
|
// gain the setup cache column.
|
|
@@ -4955,9 +4543,11 @@ function migrate(db, origin) {
|
|
|
4955
4543
|
addColumn(db, "task_ref", "filed_by", "TEXT");
|
|
4956
4544
|
addColumn(db, "task_ref", "filed_by_kind", "TEXT");
|
|
4957
4545
|
// v98: where Telegram pushes this bot's updates, as the bridge last found it.
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4546
|
+
if (tableExists(db, "bridge_lease")) {
|
|
4547
|
+
addColumn(db, "bridge_lease", "push_url", "TEXT");
|
|
4548
|
+
addColumn(db, "bridge_lease", "push_at", "TEXT");
|
|
4549
|
+
addColumn(db, "bridge_lease", "push_problem", "TEXT");
|
|
4550
|
+
}
|
|
4961
4551
|
// v96: a teammate's desk flow.
|
|
4962
4552
|
addColumn(db, "teammate", "desk_flow", "INTEGER REFERENCES flow(id)");
|
|
4963
4553
|
// v97: its weekly report, and undoing its tool calls.
|
|
@@ -9121,21 +8711,14 @@ export class Store {
|
|
|
9121
8711
|
this.failLiveMateTurnsFor(approver, by === "credential-rotation" ? "rotated" : "revoked", now);
|
|
9122
8712
|
this.endMateSessionsFor(approver, by, now);
|
|
9123
8713
|
this.closeMateThreadsFor(approver, now);
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
.run(stamp,
|
|
9131
|
-
for (const binding of bindings) {
|
|
9132
|
-
this.db
|
|
9133
|
-
.prepare("UPDATE telegram_action SET consumed_at = ? WHERE binding = ? AND consumed_at IS NULL")
|
|
9134
|
-
.run(stamp, binding);
|
|
8714
|
+
// Every chat app's pairing is derived authority: each of this person's ends, with everything its chat could
|
|
8715
|
+
// still do, and no code they minted still opens a door.
|
|
8716
|
+
for (const provider of CHAT_PROVIDERS) {
|
|
8717
|
+
const chat = new ChatMessages(this, provider);
|
|
8718
|
+
for (const row of chat.prepare("SELECT id FROM chat_binding WHERE provider = :provider AND approver = ? AND revoked IS NULL").all(approver))
|
|
8719
|
+
chat.revokeBinding({ id: Number(row["id"]) }, now, by);
|
|
8720
|
+
chat.prepare("UPDATE chat_pair SET consumed = ? WHERE provider = :provider AND approver = ? AND consumed IS NULL").run(stamp, approver);
|
|
9135
8721
|
}
|
|
9136
|
-
this.db
|
|
9137
|
-
.prepare("DELETE FROM telegram_pairing WHERE approver = ? AND consumed_at IS NULL")
|
|
9138
|
-
.run(approver);
|
|
9139
8722
|
// Push enrollments are derived authority too (arc 3 finding 6): the
|
|
9140
8723
|
// rotation retires every live subscription of the old credential and
|
|
9141
8724
|
// terminally settles its delivery pairs — claimed ones included, so an
|
|
@@ -13898,6 +13481,11 @@ export class Store {
|
|
|
13898
13481
|
notificationsAfter(id, limit = 100) {
|
|
13899
13482
|
return this.db.prepare("SELECT * FROM notification WHERE id>? ORDER BY id LIMIT ?").all(id, Math.max(1, Math.min(100, limit))).map(readNotification);
|
|
13900
13483
|
}
|
|
13484
|
+
/** What landed before a pairing (and at or before `through`) and still wants that person (WANTED_BEFORE_PAIRING). */
|
|
13485
|
+
notificationsWantedBefore(created, through, limit = 100) {
|
|
13486
|
+
return this.db.prepare(`SELECT * FROM notification WHERE id<=? AND created_at<? AND resolved_at IS NULL AND ${WANTED_BEFORE_PAIRING} ORDER BY id LIMIT ?`)
|
|
13487
|
+
.all(through, created, Math.max(1, Math.min(100, limit))).map(readNotification);
|
|
13488
|
+
}
|
|
13901
13489
|
listNotifications(only = "pending") {
|
|
13902
13490
|
// Pending means unresolved: a decision answered is a fact that stopped
|
|
13903
13491
|
// wanting a person. Delivery state lives per destination
|
|
@@ -15765,166 +15353,74 @@ export class Store {
|
|
|
15765
15353
|
this.bumpWake();
|
|
15766
15354
|
return true;
|
|
15767
15355
|
}
|
|
15768
|
-
// ---- telegram
|
|
15769
|
-
/**
|
|
15356
|
+
// ---- telegram: its pairings, rooms and buttons on the shared chat core (chat-core.ts) -----------------------------
|
|
15357
|
+
/** The shared chat core, bound to Telegram: the same pairing, rooms and worker rules every chat app keeps. */
|
|
15358
|
+
telegramChat() {
|
|
15359
|
+
return new ChatMessages(this, "telegram");
|
|
15360
|
+
}
|
|
15361
|
+
/** Mint a pairing code's record. The code itself was shown once; this is its hash. A Telegram code works on its bot. */
|
|
15770
15362
|
createTelegramPairing(pairing, now) {
|
|
15771
15363
|
const generation = this.approverGeneration(pairing.approver);
|
|
15772
15364
|
if (generation === null)
|
|
15773
15365
|
throw new Error(`no approver named ${pairing.approver}`);
|
|
15774
|
-
this.
|
|
15775
|
-
.prepare(`INSERT INTO telegram_pairing (code_hash, approver, approver_generation, created_at, created_by, expires_at)
|
|
15776
|
-
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
15777
|
-
.run(pairing.codeHash, pairing.approver, generation, now.toISOString(), pairing.by, new Date(now.getTime() + pairing.ttlMs).toISOString());
|
|
15366
|
+
this.telegramChat().savePairing({ hash: pairing.codeHash, installation: null, approver: pairing.approver, generation, by: pairing.by }, now, pairing.ttlMs);
|
|
15778
15367
|
}
|
|
15779
15368
|
/**
|
|
15780
|
-
* Consume a pairing code and create the binding, as one transaction
|
|
15369
|
+
* Consume a pairing code and create the binding, as one transaction, by the rules every chat app shares
|
|
15370
|
+
* (ChatCore.consumePairing): a replay of the very update that paired finds its finished binding.
|
|
15781
15371
|
*
|
|
15782
|
-
*
|
|
15783
|
-
*
|
|
15784
|
-
* that
|
|
15785
|
-
*
|
|
15786
|
-
*
|
|
15787
|
-
* the
|
|
15372
|
+
* A person's FIRST pairing starts from now (Telegram task updates): every ROUTINE fact already in the outbox is
|
|
15373
|
+
* history for this destination — settled here, in the pairing's own transaction, with a receipt that says it was
|
|
15374
|
+
* skipped, never sent as a backlog to a phone that just arrived. The skip is explicit: no delivered_at, no attempt,
|
|
15375
|
+
* only the `skipped:` receipt — a message no phone received is never reported as delivered (see TELEGRAM_UNSETTLED).
|
|
15376
|
+
* An open decision or an attention-class fact still wants a person and stays pending. A re-pairing after a
|
|
15377
|
+
* revocation keeps the older promise instead: what no phone ever received still waits for whenever pairing happens.
|
|
15788
15378
|
*/
|
|
15789
15379
|
consumeTelegramPairing(attempt, now) {
|
|
15790
15380
|
return this.transact(() => {
|
|
15791
|
-
const
|
|
15792
|
-
|
|
15793
|
-
|
|
15794
|
-
|
|
15795
|
-
|
|
15796
|
-
|
|
15797
|
-
if (Number(changes) === 0) {
|
|
15798
|
-
// The same update replayed after a crash-between-apply-and-ack:
|
|
15799
|
-
// the code is consumed by exactly this chat and user, and the
|
|
15800
|
-
// binding it made is live. That is a completed pairing, not a foe.
|
|
15801
|
-
const consumed = this.db
|
|
15802
|
-
.prepare(`SELECT 1 AS hit FROM telegram_pairing
|
|
15803
|
-
WHERE code_hash = ? AND consumed_chat = ? AND consumed_user = ? AND consumed_update = ?`)
|
|
15804
|
-
.get(attempt.codeHash, attempt.chatId, attempt.userId, attempt.updateId);
|
|
15805
|
-
if (consumed !== undefined) {
|
|
15806
|
-
const live = this.liveTelegramBindingFor(attempt.botId, attempt.userId);
|
|
15807
|
-
if (live !== null && live.chatId === attempt.chatId) {
|
|
15808
|
-
return { ok: true, binding: live, replay: true };
|
|
15809
|
-
}
|
|
15810
|
-
}
|
|
15811
|
-
return { ok: false, reason: "unknown-code" };
|
|
15812
|
-
}
|
|
15813
|
-
const pairing = this.db
|
|
15814
|
-
.prepare("SELECT approver, approver_generation FROM telegram_pairing WHERE code_hash = ?")
|
|
15815
|
-
.get(attempt.codeHash);
|
|
15816
|
-
// The code was minted under a generation; the binding is only valid
|
|
15817
|
-
// if that generation still stands — a rotation between mint and
|
|
15818
|
-
// consumption strands the code.
|
|
15819
|
-
const current = this.approverGeneration(String(pairing["approver"]));
|
|
15820
|
-
if (current === null || current !== Number(pairing["approver_generation"])) {
|
|
15821
|
-
return { ok: false, reason: "unknown-code" };
|
|
15822
|
-
}
|
|
15823
|
-
try {
|
|
15824
|
-
this.db
|
|
15825
|
-
.prepare(`INSERT INTO telegram_binding (bot_id, chat_id, user_id, approver, approver_generation, paired_at, paired_by, pair_update_id)
|
|
15826
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
15827
|
-
.run(attempt.botId, attempt.chatId, attempt.userId, String(pairing["approver"]), Number(pairing["approver_generation"]), stamp, String(pairing["approver"]), attempt.updateId);
|
|
15828
|
-
}
|
|
15829
|
-
catch {
|
|
15830
|
-
// The partial unique index said no: this Telegram user already has a live binding.
|
|
15831
|
-
return { ok: false, reason: "already-bound" };
|
|
15832
|
-
}
|
|
15833
|
-
// Every other outstanding code for this person dies with this success;
|
|
15834
|
-
// a teammate's pending code is theirs and stays.
|
|
15835
|
-
this.db.prepare("DELETE FROM telegram_pairing WHERE consumed_at IS NULL AND approver = ?").run(String(pairing["approver"]));
|
|
15836
|
-
const binding = this.liveTelegramBindingFor(attempt.botId, attempt.userId);
|
|
15837
|
-
if (binding === null)
|
|
15838
|
-
throw new Error("the binding vanished inside its own transaction");
|
|
15839
|
-
// A person's FIRST pairing starts from now (Telegram task updates): every
|
|
15840
|
-
// ROUTINE fact already in the outbox is history for this destination —
|
|
15841
|
-
// settled here, in the pairing's own transaction, with a receipt that
|
|
15842
|
-
// says it was skipped, never sent as a backlog to a phone that just
|
|
15843
|
-
// arrived. The skip is explicit: no delivered_at, no attempt, only the
|
|
15844
|
-
// `skipped:` receipt — a message no phone received is never reported
|
|
15845
|
-
// as delivered (see TELEGRAM_UNSETTLED). An open decision or an
|
|
15846
|
-
// attention-class fact still wants a person and stays pending. A
|
|
15847
|
-
// re-pairing after a revocation keeps the older promise instead: what
|
|
15848
|
-
// no phone ever received still waits for whenever pairing happens.
|
|
15849
|
-
// Existing destinations are untouched.
|
|
15850
|
-
const priorPairing = this.db
|
|
15851
|
-
.prepare("SELECT 1 AS hit FROM telegram_binding WHERE bot_id = ? AND user_id = ? AND id <> ? LIMIT 1")
|
|
15852
|
-
.get(attempt.botId, attempt.userId, binding.id);
|
|
15853
|
-
if (priorPairing === undefined) {
|
|
15381
|
+
const paired = this.telegramChat().consumePairing({ hash: attempt.codeHash, installation: attempt.botId, team: attempt.botId, app: attempt.botId,
|
|
15382
|
+
member: attempt.userId, channel: attempt.chatId, event: String(attempt.updateId) }, now);
|
|
15383
|
+
if (!paired.ok)
|
|
15384
|
+
return paired;
|
|
15385
|
+
const binding = telegramBindingOf(paired.binding);
|
|
15386
|
+
if (paired.first) {
|
|
15854
15387
|
this.db
|
|
15855
15388
|
.prepare(`INSERT OR IGNORE INTO notification_delivery (notification, destination, receipt)
|
|
15856
15389
|
SELECT id, ?, '${TELEGRAM_SKIPPED_RECEIPT}' FROM notification
|
|
15857
|
-
WHERE resolved_at IS NULL
|
|
15858
|
-
AND dedupe_key NOT LIKE 'decision:%'
|
|
15859
|
-
AND (push_class IS NULL OR push_class <> 'attention')`)
|
|
15390
|
+
WHERE resolved_at IS NULL AND NOT ${WANTED_BEFORE_PAIRING}`)
|
|
15860
15391
|
.run(this.telegramDestination(binding));
|
|
15861
15392
|
}
|
|
15862
|
-
return { ok: true, binding, replay:
|
|
15393
|
+
return { ok: true, binding, replay: paired.replay };
|
|
15863
15394
|
});
|
|
15864
15395
|
}
|
|
15865
15396
|
// ---- team chats (v72) -------------------------------------------------------
|
|
15866
15397
|
/** The live team-conversation row for one chat, group or private. */
|
|
15867
15398
|
telegramTeamChat(botId, chatId) {
|
|
15868
|
-
const
|
|
15869
|
-
return
|
|
15399
|
+
const room = this.telegramChat().room(botId, chatId);
|
|
15400
|
+
return room === null ? null : telegramTeamChatOf(room, botId);
|
|
15870
15401
|
}
|
|
15871
15402
|
listTelegramTeamChats(botId) {
|
|
15872
|
-
return this.
|
|
15403
|
+
return this.telegramChat().rooms(botId).map(room => telegramTeamChatOf(room, botId));
|
|
15873
15404
|
}
|
|
15874
|
-
/**
|
|
15875
|
-
* Point a chat at a team conversation: an earlier choice for the same chat
|
|
15876
|
-
* is revoked in the same transaction, and delivery starts from now — the
|
|
15877
|
-
* conversation's history is read in the console, never replayed into a
|
|
15878
|
-
* chat that just arrived. A group already following another conversation
|
|
15879
|
-
* elsewhere, or a conversation already followed by another group, refuses.
|
|
15880
|
-
*/
|
|
15405
|
+
/** Point a chat at a team conversation (ChatCore.bindRoom: the same rule in every chat app). */
|
|
15881
15406
|
bindTelegramTeamChat(args, now) {
|
|
15882
|
-
|
|
15883
|
-
|
|
15884
|
-
// The IMMEDIATE transaction excludes another group winning between
|
|
15885
|
-
// this check and insertion; unexpected failures roll back the switch.
|
|
15886
|
-
if (args.kind === "group") {
|
|
15887
|
-
const group = this.db.prepare("SELECT bot_id, chat_id FROM telegram_team_chat WHERE conversation = ? AND kind = 'group' AND revoked_at IS NULL").get(args.conversation);
|
|
15888
|
-
if (group !== undefined && (group["bot_id"] !== args.botId || group["chat_id"] !== args.chatId))
|
|
15889
|
-
return { ok: false, reason: "group-taken" };
|
|
15890
|
-
}
|
|
15891
|
-
const stamp = now.toISOString();
|
|
15892
|
-
this.db.prepare("UPDATE telegram_team_chat SET revoked_at = ?, revoked_by = ? WHERE bot_id = ? AND chat_id = ? AND revoked_at IS NULL").run(stamp, args.by, args.botId, args.chatId);
|
|
15893
|
-
const thread = this.db.prepare("SELECT thread FROM team_conversation WHERE id = ?").get(args.conversation);
|
|
15894
|
-
if (thread === undefined)
|
|
15895
|
-
throw new Error("no such team conversation");
|
|
15896
|
-
const cursor = Number(this.db.prepare("SELECT COALESCE(MAX(id), 0) AS n FROM mate_message WHERE thread = ?").get(Number(thread["thread"]))?.["n"] ?? 0);
|
|
15897
|
-
this.db.prepare("INSERT INTO telegram_team_chat (bot_id, chat_id, binding, kind, conversation, bound_by, bound_at, cursor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
15898
|
-
.run(args.botId, args.chatId, args.binding, args.kind, args.conversation, args.by, stamp, cursor);
|
|
15899
|
-
return { ok: true, chat: this.telegramTeamChat(args.botId, args.chatId) };
|
|
15900
|
-
}));
|
|
15407
|
+
const bound = this.telegramChat().bindRoom({ installation: args.botId, chat: args.chatId, kind: args.kind, conversation: args.conversation, by: args.by, binding: args.binding }, now);
|
|
15408
|
+
return bound.ok ? { ok: true, chat: telegramTeamChatOf(bound.room, args.botId) } : bound;
|
|
15901
15409
|
}
|
|
15902
15410
|
unbindTelegramTeamChat(botId, chatId, by, now) {
|
|
15903
|
-
|
|
15904
|
-
return Number(changes) > 0;
|
|
15411
|
+
return this.telegramChat().unbindRoom(botId, chatId, by, now);
|
|
15905
15412
|
}
|
|
15906
15413
|
/** Forward only: a cursor never moves back, and a revoked row keeps its last position. */
|
|
15907
15414
|
advanceTelegramTeamCursor(id, messageId) {
|
|
15908
|
-
|
|
15909
|
-
return Number(changes) > 0;
|
|
15415
|
+
return this.telegramChat().advanceRoomCursor(id, messageId);
|
|
15910
15416
|
}
|
|
15911
15417
|
/**
|
|
15912
|
-
* Every live binding for a bot, oldest first — and only while each
|
|
15913
|
-
*
|
|
15914
|
-
*
|
|
15915
|
-
* failure direction that fails closed. Several teammates may each hold
|
|
15916
|
-
* one (v72); nothing here ranks them.
|
|
15418
|
+
* Every live binding for a bot, oldest first — and only while each approver's credential generation still matches.
|
|
15419
|
+
* A rotation that somehow missed the sweep reads as no binding at all, which is the failure direction that fails
|
|
15420
|
+
* closed. Several teammates may each hold one (v72); nothing here ranks them.
|
|
15917
15421
|
*/
|
|
15918
15422
|
liveTelegramBindings(botId) {
|
|
15919
|
-
return this.
|
|
15920
|
-
.prepare(`SELECT telegram_binding.* FROM telegram_binding
|
|
15921
|
-
JOIN approver ON approver.name = telegram_binding.approver
|
|
15922
|
-
AND approver.generation = telegram_binding.approver_generation
|
|
15923
|
-
AND approver.revoked_at IS NULL
|
|
15924
|
-
WHERE bot_id = ? AND telegram_binding.revoked_at IS NULL
|
|
15925
|
-
ORDER BY telegram_binding.id`)
|
|
15926
|
-
.all(botId)
|
|
15927
|
-
.map(readTelegramBinding);
|
|
15423
|
+
return this.telegramChat().liveBindings(botId).map(telegramBindingOf);
|
|
15928
15424
|
}
|
|
15929
15425
|
/** The live binding one Telegram user holds with a bot, if any. */
|
|
15930
15426
|
liveTelegramBindingFor(botId, userId) {
|
|
@@ -15932,14 +15428,8 @@ export class Store {
|
|
|
15932
15428
|
}
|
|
15933
15429
|
/** The exact binding, if it is still live under its approver's current generation. */
|
|
15934
15430
|
liveTelegramBindingById(id) {
|
|
15935
|
-
const
|
|
15936
|
-
|
|
15937
|
-
JOIN approver ON approver.name = telegram_binding.approver
|
|
15938
|
-
AND approver.generation = telegram_binding.approver_generation
|
|
15939
|
-
AND approver.revoked_at IS NULL
|
|
15940
|
-
WHERE telegram_binding.id = ? AND telegram_binding.revoked_at IS NULL`)
|
|
15941
|
-
.get(id);
|
|
15942
|
-
return row === undefined ? null : readTelegramBinding(row);
|
|
15431
|
+
const chat = this.telegramChat(), binding = chat.bindingById(id);
|
|
15432
|
+
return binding !== null && chat.live(binding) ? telegramBindingOf(binding) : null;
|
|
15943
15433
|
}
|
|
15944
15434
|
/** The oldest live binding for a bot — the single-person reading kept for
|
|
15945
15435
|
* status lines and fixtures; delivery and inbound handling read
|
|
@@ -15951,121 +15441,27 @@ export class Store {
|
|
|
15951
15441
|
* those chats could still do. Teammates' bindings are untouched. */
|
|
15952
15442
|
unpairTelegram(botId, by, now) {
|
|
15953
15443
|
return this.transact(() => {
|
|
15954
|
-
const
|
|
15955
|
-
|
|
15956
|
-
|
|
15957
|
-
|
|
15958
|
-
|
|
15959
|
-
this.db
|
|
15960
|
-
.prepare("UPDATE telegram_binding SET revoked_at = ?, revoked_by = ? WHERE id = ?")
|
|
15961
|
-
.run(stamp, by, live.id);
|
|
15962
|
-
this.db
|
|
15963
|
-
.prepare("UPDATE telegram_action SET consumed_at = ? WHERE binding = ? AND consumed_at IS NULL")
|
|
15964
|
-
.run(stamp, live.id);
|
|
15965
|
-
}
|
|
15966
|
-
return true;
|
|
15444
|
+
const chat = this.telegramChat();
|
|
15445
|
+
const mine = chat.liveBindings(botId).filter(binding => binding.approver === by);
|
|
15446
|
+
for (const live of mine)
|
|
15447
|
+
chat.revokeBinding(live, now, by);
|
|
15448
|
+
return mine.length > 0;
|
|
15967
15449
|
});
|
|
15968
15450
|
}
|
|
15969
|
-
createTelegramAction(
|
|
15970
|
-
this.db
|
|
15971
|
-
.prepare(`INSERT INTO telegram_action (token, binding, decision, option_id, phase, chat_id, message_id, created_at, expires_at, note_digest)
|
|
15972
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
15973
|
-
.run(action.token, action.binding, action.decision, action.optionId, action.phase, action.chatId, action.messageId ?? null, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString(), action.noteDigest ?? null);
|
|
15974
|
-
}
|
|
15451
|
+
createTelegramAction(...args) { return this.telegramChat().createDecisionAction(...args); }
|
|
15975
15452
|
// ---- free-text drafts (v10) ---------------------------------------------
|
|
15976
|
-
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
15980
|
-
|
|
15981
|
-
VALUES (?, ?, ?, ?, ?)`)
|
|
15982
|
-
.run(binding, chatId, messageId, decision, now.toISOString());
|
|
15983
|
-
}
|
|
15984
|
-
/** The decision behind an exact replied-to message, or nothing. Never "the latest". */
|
|
15985
|
-
decisionForTelegramMessage(binding, chatId, messageId) {
|
|
15986
|
-
const row = this.db
|
|
15987
|
-
.prepare("SELECT decision FROM telegram_decision_message WHERE binding = ? AND chat_id = ? AND message_id = ?")
|
|
15988
|
-
.get(binding, chatId, messageId);
|
|
15989
|
-
return row === undefined ? null : Number(row["decision"]);
|
|
15990
|
-
}
|
|
15991
|
-
/** The one live, unexpired draft for a decision, if any. */
|
|
15992
|
-
liveNoteDraft(binding, decision, now) {
|
|
15993
|
-
const row = this.db
|
|
15994
|
-
.prepare(`SELECT id, note, update_id, state FROM telegram_note_draft
|
|
15995
|
-
WHERE binding = ? AND decision = ? AND state IN ('pending','armed') AND expires_at > ?`)
|
|
15996
|
-
.get(binding, decision, now.toISOString());
|
|
15997
|
-
return row === undefined
|
|
15998
|
-
? null
|
|
15999
|
-
: { id: Number(row["id"]), note: String(row["note"]), updateId: Number(row["update_id"]), state: String(row["state"]) };
|
|
16000
|
-
}
|
|
16001
|
-
/**
|
|
16002
|
-
* Persist a validated note as the live draft. A newer update SUPERSEDES
|
|
16003
|
-
* the old draft — drafts are immutable, and only a GREATER update_id may
|
|
16004
|
-
* replace one, so out-of-order delivery cannot resurrect an older note
|
|
16005
|
-
* (Codex free-text review, state machine). Returns false when an older
|
|
16006
|
-
* or equal update tried.
|
|
16007
|
-
*/
|
|
16008
|
-
saveNoteDraft(draft, now, ttlMs = 10 * 60_000) {
|
|
16009
|
-
return this.transact(() => {
|
|
16010
|
-
const live = this.db
|
|
16011
|
-
.prepare(`SELECT id, update_id FROM telegram_note_draft
|
|
16012
|
-
WHERE binding = ? AND decision = ? AND state IN ('pending','armed')`)
|
|
16013
|
-
.get(draft.binding, draft.decision);
|
|
16014
|
-
if (live !== undefined) {
|
|
16015
|
-
if (Number(live["update_id"]) >= draft.updateId)
|
|
16016
|
-
return false;
|
|
16017
|
-
this.db.prepare("UPDATE telegram_note_draft SET state = 'superseded' WHERE id = ?").run(live["id"]);
|
|
16018
|
-
}
|
|
16019
|
-
this.db
|
|
16020
|
-
.prepare(`INSERT INTO telegram_note_draft (binding, decision, update_id, message_id, reply_to, note, state, created_at, expires_at)
|
|
16021
|
-
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
|
|
16022
|
-
.run(draft.binding, draft.decision, draft.updateId, draft.messageId, draft.replyTo, draft.note, now.toISOString(), new Date(now.getTime() + ttlMs).toISOString());
|
|
16023
|
-
return true;
|
|
16024
|
-
});
|
|
16025
|
-
}
|
|
16026
|
-
/** Move a draft between states; the caller owns the transaction story. */
|
|
16027
|
-
setNoteDraftState(id, state) {
|
|
16028
|
-
this.db.prepare("UPDATE telegram_note_draft SET state = ? WHERE id = ?").run(state, id);
|
|
16029
|
-
}
|
|
15453
|
+
recordTelegramDecisionMessage(...args) { return this.telegramChat().recordDecisionMessage(...args); }
|
|
15454
|
+
decisionForTelegramMessage(...args) { return this.telegramChat().decisionForMessage(...args); }
|
|
15455
|
+
liveNoteDraft(...args) { return this.telegramChat().liveNoteDraft(...args); }
|
|
15456
|
+
saveNoteDraft(...args) { return this.telegramChat().saveNoteDraft(...args); }
|
|
15457
|
+
setNoteDraftState(...args) { return this.telegramChat().setNoteDraftState(...args); }
|
|
16030
15458
|
// ---- flow decisions on Telegram (v86) ------------------------------------------
|
|
16031
|
-
createTelegramFlowActions(
|
|
16032
|
-
|
|
16033
|
-
|
|
16034
|
-
|
|
16035
|
-
|
|
16036
|
-
}
|
|
16037
|
-
getTelegramFlowAction(token) {
|
|
16038
|
-
if (!/^[0-9a-f]{32}$/.test(token))
|
|
16039
|
-
return null;
|
|
16040
|
-
const row = this.db.prepare("SELECT * FROM telegram_flow_action WHERE token = ?").get(token);
|
|
16041
|
-
if (row === undefined)
|
|
16042
|
-
return null;
|
|
16043
|
-
return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), action: String(row["action"]),
|
|
16044
|
-
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"]) };
|
|
16045
|
-
}
|
|
16046
|
-
placeTelegramFlowActions(tokens, messageId) {
|
|
16047
|
-
const place = this.db.prepare("UPDATE telegram_flow_action SET message_id = ? WHERE token = ?");
|
|
16048
|
-
for (const token of tokens)
|
|
16049
|
-
place.run(messageId, token);
|
|
16050
|
-
}
|
|
16051
|
-
/** Retire every button (and prompt) for one visit of a card: it was decided, or its draft replaced. */
|
|
16052
|
-
retireTelegramFlowVisit(card, entry, now) {
|
|
16053
|
-
const stamp = now.toISOString();
|
|
16054
|
-
this.db.prepare("UPDATE telegram_flow_action SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
16055
|
-
this.db.prepare("UPDATE telegram_flow_prompt SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
16056
|
-
this.db.prepare("UPDATE telegram_flow_confirm SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
16057
|
-
}
|
|
16058
|
-
recordTelegramFlowPrompt(prompt, now, hours = 24) {
|
|
16059
|
-
this.db.prepare("INSERT OR REPLACE INTO telegram_flow_prompt (chat_id, message_id, binding, card, entry, mode, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)")
|
|
16060
|
-
.run(prompt.chatId, prompt.messageId, prompt.binding, prompt.card, prompt.entry, prompt.mode, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
|
|
16061
|
-
}
|
|
16062
|
-
/** The live prompt a message replies to, if any. */
|
|
16063
|
-
telegramFlowPrompt(chatId, messageId, now) {
|
|
16064
|
-
const row = this.db.prepare("SELECT * FROM telegram_flow_prompt WHERE chat_id = ? AND message_id = ? AND consumed_at IS NULL AND expires_at > ?").get(chatId, messageId, now.toISOString());
|
|
16065
|
-
if (row === undefined)
|
|
16066
|
-
return null;
|
|
16067
|
-
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"]) };
|
|
16068
|
-
}
|
|
15459
|
+
createTelegramFlowActions(...args) { return this.telegramChat().createFlowActions(...args); }
|
|
15460
|
+
getTelegramFlowAction(...args) { return this.telegramChat().flowAction(...args); }
|
|
15461
|
+
placeTelegramFlowActions(...args) { return this.telegramChat().placeFlowActions(...args); }
|
|
15462
|
+
retireTelegramFlowVisit(...args) { return this.telegramChat().retireFlowVisit(...args); }
|
|
15463
|
+
recordTelegramFlowPrompt(...args) { return this.telegramChat().recordFlowPrompt(...args); }
|
|
15464
|
+
telegramFlowPrompt(...args) { return this.telegramChat().flowPrompt(...args); }
|
|
16069
15465
|
// ---- "Send to me" and "Person chooses" (flow-send.ts) ------------------------------------------
|
|
16070
15466
|
/** What one visit sent: recorded once, the first time (a later pass finds it kept). */
|
|
16071
15467
|
recordFlowSend(row, now) {
|
|
@@ -16081,148 +15477,41 @@ export class Store {
|
|
|
16081
15477
|
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"]),
|
|
16082
15478
|
person: String(row["person"]), contentJson: String(row["content_json"]), createdAt: String(row["created_at"]) }));
|
|
16083
15479
|
}
|
|
16084
|
-
createTelegramFlowChoices(
|
|
16085
|
-
|
|
16086
|
-
|
|
16087
|
-
for (const one of choices)
|
|
16088
|
-
insert.run(one.token, at.binding, at.card, at.entry, one.choice, one.label, at.chatId, stamp, expires);
|
|
16089
|
-
}
|
|
16090
|
-
getTelegramFlowChoice(token) {
|
|
16091
|
-
if (!/^[0-9a-f]{32}$/.test(token))
|
|
16092
|
-
return null;
|
|
16093
|
-
const row = this.db.prepare("SELECT * FROM telegram_flow_choice WHERE token = ?").get(token);
|
|
16094
|
-
if (row === undefined)
|
|
16095
|
-
return null;
|
|
16096
|
-
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"]),
|
|
16097
|
-
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"]) };
|
|
16098
|
-
}
|
|
16099
|
-
placeTelegramFlowChoices(tokens, messageId) {
|
|
16100
|
-
const place = this.db.prepare("UPDATE telegram_flow_choice SET message_id = ? WHERE token = ?");
|
|
16101
|
-
for (const token of tokens)
|
|
16102
|
-
place.run(messageId, token);
|
|
16103
|
-
}
|
|
16104
|
-
/** Retire every choice button for one visit of a card (and its reply prompt): it was chosen, or the card moved on. */
|
|
15480
|
+
createTelegramFlowChoices(...args) { return this.telegramChat().createFlowChoices(...args); }
|
|
15481
|
+
getTelegramFlowChoice(...args) { return this.telegramChat().flowChoice(...args); }
|
|
15482
|
+
placeTelegramFlowChoices(...args) { return this.telegramChat().placeFlowChoices(...args); }
|
|
16105
15483
|
/** A choice was made (or the card moved on): every option button, reply prompt and "Use this as your note?" for that
|
|
16106
|
-
* visit is spent, in
|
|
15484
|
+
* visit is spent, in every chat app, whichever place it was made in. */
|
|
16107
15485
|
retireFlowChoices(card, entry, now) {
|
|
16108
15486
|
const stamp = now.toISOString();
|
|
16109
|
-
this.db.prepare("UPDATE
|
|
15487
|
+
this.db.prepare("UPDATE chat_flow_choice SET consumed = ? WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
|
|
15488
|
+
this.db.prepare("UPDATE chat_flow_note SET consumed = ?, words = NULL WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
|
|
16110
15489
|
this.retireTelegramFlowVisit(card, entry, now);
|
|
16111
|
-
// A chat app's flow tables exist only once that app's chat state created them; an install without it has none.
|
|
16112
|
-
const names = ["slack", "discord", "teams"].flatMap(app => ["flow_choice", "flow_note", "flow_prompt", "flow_action"].map(table => `${app}_${table}`));
|
|
16113
|
-
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"])));
|
|
16114
|
-
for (const app of ["slack", "discord", "teams"])
|
|
16115
|
-
for (const table of ["flow_choice", "flow_note", "flow_prompt", "flow_action"])
|
|
16116
|
-
if (present.has(`${app}_${table}`))
|
|
16117
|
-
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);
|
|
16118
15490
|
}
|
|
16119
15491
|
// ---- v93: a teammate's question on Telegram ------------------------------------------
|
|
16120
|
-
createTelegramQuestionActions(
|
|
16121
|
-
|
|
16122
|
-
|
|
16123
|
-
|
|
16124
|
-
|
|
16125
|
-
}
|
|
16126
|
-
|
|
16127
|
-
|
|
16128
|
-
|
|
16129
|
-
|
|
16130
|
-
if (row === undefined)
|
|
16131
|
-
return null;
|
|
16132
|
-
return { token: String(row["token"]), binding: Number(row["binding"]), question: Number(row["question"]), choice: row["choice"] === null ? null : String(row["choice"]),
|
|
16133
|
-
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"]) };
|
|
16134
|
-
}
|
|
16135
|
-
placeTelegramQuestionActions(tokens, messageId) {
|
|
16136
|
-
const place = this.db.prepare("UPDATE telegram_question_action SET message_id = ? WHERE token = ?");
|
|
16137
|
-
for (const token of tokens)
|
|
16138
|
-
place.run(messageId, token);
|
|
16139
|
-
}
|
|
16140
|
-
/** Retire every button and prompt for a question: it was answered (anywhere). */
|
|
16141
|
-
retireTelegramQuestion(question, now) {
|
|
16142
|
-
const stamp = now.toISOString();
|
|
16143
|
-
this.db.prepare("UPDATE telegram_question_action SET consumed_at = ? WHERE question = ? AND consumed_at IS NULL").run(stamp, question);
|
|
16144
|
-
this.db.prepare("UPDATE telegram_question_prompt SET consumed_at = ? WHERE question = ? AND consumed_at IS NULL").run(stamp, question);
|
|
16145
|
-
}
|
|
16146
|
-
recordTelegramQuestionPrompt(prompt, now, hours = 24) {
|
|
16147
|
-
this.db.prepare("INSERT OR REPLACE INTO telegram_question_prompt (chat_id, message_id, binding, question, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?, ?, NULL)")
|
|
16148
|
-
.run(prompt.chatId, prompt.messageId, prompt.binding, prompt.question, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
|
|
16149
|
-
}
|
|
16150
|
-
telegramQuestionPrompt(chatId, messageId, now) {
|
|
16151
|
-
const row = this.db.prepare("SELECT * FROM telegram_question_prompt WHERE chat_id = ? AND message_id = ? AND consumed_at IS NULL AND expires_at > ?").get(chatId, messageId, now.toISOString());
|
|
16152
|
-
return row === undefined ? null : { chatId: String(row["chat_id"]), messageId: String(row["message_id"]), binding: Number(row["binding"]), question: Number(row["question"]) };
|
|
16153
|
-
}
|
|
16154
|
-
getTelegramAction(token) {
|
|
16155
|
-
const row = this.db.prepare("SELECT * FROM telegram_action WHERE token = ?").get(token);
|
|
16156
|
-
return row === undefined ? null : readTelegramAction(row);
|
|
16157
|
-
}
|
|
15492
|
+
createTelegramQuestionActions(...args) { return this.telegramChat().createQuestionActions(...args); }
|
|
15493
|
+
getTelegramQuestionAction(...args) { return this.telegramChat().questionAction(...args); }
|
|
15494
|
+
placeTelegramQuestionActions(...args) { return this.telegramChat().placeQuestionActions(...args); }
|
|
15495
|
+
retireTelegramQuestion(...args) { return this.telegramChat().retireQuestion(...args); }
|
|
15496
|
+
recordTelegramQuestionPrompt(...args) { return this.telegramChat().recordQuestionPrompt(...args); }
|
|
15497
|
+
telegramQuestionPrompt(...args) { return this.telegramChat().questionPrompt(...args); }
|
|
15498
|
+
getTelegramAction(...args) { return this.telegramChat().decisionAction(...args); }
|
|
15499
|
+
consumeTelegramChallenges(...args) { return this.telegramChat().consumeChallenges(...args); }
|
|
15500
|
+
consumeTelegramAction(...args) { return this.telegramChat().consumeDecisionAction(...args); }
|
|
15501
|
+
placeTelegramActions(...args) { return this.telegramChat().placeDecisionActions(...args); }
|
|
16158
15502
|
/**
|
|
16159
|
-
*
|
|
16160
|
-
* cancelled: a confirm that survived its own cancellation would be an
|
|
16161
|
-
* irreversible choice still armed after the person said stop.
|
|
16162
|
-
*/
|
|
16163
|
-
consumeTelegramChallenges(decision, now) {
|
|
16164
|
-
this.db
|
|
16165
|
-
.prepare(`UPDATE telegram_action SET consumed_at = ?
|
|
16166
|
-
WHERE decision = ? AND phase IN ('confirm','cancel') AND consumed_at IS NULL`)
|
|
16167
|
-
.run(now.toISOString(), decision);
|
|
16168
|
-
}
|
|
16169
|
-
/** Consume once. False means somebody already did, or it expired — either way, no. */
|
|
16170
|
-
consumeTelegramAction(token, now) {
|
|
16171
|
-
const stamp = now.toISOString();
|
|
16172
|
-
const { changes } = this.db
|
|
16173
|
-
.prepare(`UPDATE telegram_action SET consumed_at = ?
|
|
16174
|
-
WHERE token = ? AND consumed_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
|
|
16175
|
-
.run(stamp, token, stamp);
|
|
16176
|
-
return Number(changes) > 0;
|
|
16177
|
-
}
|
|
16178
|
-
/** Stamp the message a choose-action's keyboard actually landed on. */
|
|
16179
|
-
placeTelegramActions(tokens, messageId) {
|
|
16180
|
-
for (const token of tokens) {
|
|
16181
|
-
this.db
|
|
16182
|
-
.prepare("UPDATE telegram_action SET message_id = ? WHERE token = ?")
|
|
16183
|
-
.run(messageId, token);
|
|
16184
|
-
}
|
|
16185
|
-
}
|
|
16186
|
-
/**
|
|
16187
|
-
* Take or renew the bridge's poll lease. One live poller per bot: cron
|
|
15503
|
+
* Take or renew the bridge's poll lease (ChatCore.acquire). One live poller per bot: cron
|
|
16188
15504
|
* overlapping a follower gets `bridge-busy`, an expired holder is taken
|
|
16189
15505
|
* over at the next generation, and the cursor rides the lease so a stale
|
|
16190
15506
|
* generation can neither poll nor move it.
|
|
16191
15507
|
*/
|
|
16192
15508
|
acquireBridgeLease(botId, owner, ttlMs, now) {
|
|
16193
|
-
|
|
16194
|
-
|
|
16195
|
-
const expires = new Date(now.getTime() + ttlMs).toISOString();
|
|
16196
|
-
const row = this.db.prepare("SELECT * FROM bridge_lease WHERE bot_id = ?").get(botId);
|
|
16197
|
-
if (row === undefined) {
|
|
16198
|
-
this.db
|
|
16199
|
-
.prepare(`INSERT INTO bridge_lease (bot_id, owner, generation, cursor, expires_at, heartbeat_at)
|
|
16200
|
-
VALUES (?, ?, 1, 0, ?, ?)`)
|
|
16201
|
-
.run(botId, owner, expires, stamp);
|
|
16202
|
-
return { ok: true, generation: 1, cursor: 0 };
|
|
16203
|
-
}
|
|
16204
|
-
const holder = String(row["owner"]);
|
|
16205
|
-
const live = String(row["expires_at"]) > stamp;
|
|
16206
|
-
if (live && holder !== owner) {
|
|
16207
|
-
return {
|
|
16208
|
-
ok: false,
|
|
16209
|
-
reason: "bridge-busy",
|
|
16210
|
-
holder,
|
|
16211
|
-
until: String(row["expires_at"]),
|
|
16212
|
-
};
|
|
16213
|
-
}
|
|
16214
|
-
const generation = live && holder === owner ? Number(row["generation"]) : Number(row["generation"]) + 1;
|
|
16215
|
-
this.db
|
|
16216
|
-
.prepare("UPDATE bridge_lease SET owner = ?, generation = ?, expires_at = ?, heartbeat_at = ? WHERE bot_id = ?")
|
|
16217
|
-
.run(owner, generation, expires, stamp, botId);
|
|
16218
|
-
return { ok: true, generation, cursor: Number(row["cursor"]) };
|
|
16219
|
-
});
|
|
15509
|
+
const lease = this.telegramChat().acquire(botId, owner, ttlMs, now);
|
|
15510
|
+
return lease.ok ? lease : { ok: false, reason: "bridge-busy", holder: lease.holder, until: lease.until };
|
|
16220
15511
|
}
|
|
16221
15512
|
/** Hand the poll back at the end of a pass, so the next cron firing is not told busy. */
|
|
16222
15513
|
releaseBridgeLease(botId, owner, now) {
|
|
16223
|
-
this.
|
|
16224
|
-
.prepare("UPDATE bridge_lease SET expires_at = ? WHERE bot_id = ? AND owner = ?")
|
|
16225
|
-
.run(now.toISOString(), botId, owner);
|
|
15514
|
+
this.telegramChat().release(botId, owner, now);
|
|
16226
15515
|
}
|
|
16227
15516
|
// ---- publication ---------------------------------------------------------
|
|
16228
15517
|
/** Grant publication for a repo. Replaces any live grant — one at a time, the newest word wins. */
|
|
@@ -17518,18 +16807,7 @@ export class Store {
|
|
|
17518
16807
|
return null;
|
|
17519
16808
|
}
|
|
17520
16809
|
}
|
|
17521
|
-
|
|
17522
|
-
* it is about that task (and that result, when it showed one). A message
|
|
17523
|
-
* edited to show something else forgets it. */
|
|
17524
|
-
recordTelegramTaskMessage(binding, messageId, taskId, run, now) {
|
|
17525
|
-
if (taskId === null) {
|
|
17526
|
-
this.db.prepare("DELETE FROM telegram_task_message WHERE binding = ? AND chat_id = ? AND message_id = ?").run(binding.id, binding.chatId, messageId);
|
|
17527
|
-
return;
|
|
17528
|
-
}
|
|
17529
|
-
this.db.prepare(`INSERT INTO telegram_task_message (binding, chat_id, message_id, task_id, source_run, created_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
17530
|
-
ON CONFLICT (binding, chat_id, message_id) DO UPDATE SET task_id = excluded.task_id, source_run = excluded.source_run, created_at = excluded.created_at`)
|
|
17531
|
-
.run(binding.id, binding.chatId, messageId, taskId, run, now.toISOString());
|
|
17532
|
-
}
|
|
16810
|
+
recordTelegramTaskMessage(...args) { return this.telegramChat().recordTaskMessage(...args); }
|
|
17533
16811
|
/** The approver's live personal threads that have messages, most recent
|
|
17534
16812
|
* first — the chat list. Team conversations list themselves. */
|
|
17535
16813
|
listMateThreads(approver, limit = 30) {
|
|
@@ -19570,509 +18848,64 @@ export class Store {
|
|
|
19570
18848
|
recordProjectDemo(by, repo, before, after, now) {
|
|
19571
18849
|
this.recordPolicy(by, repo, "demo URL", before ?? "off", after ?? "off", now);
|
|
19572
18850
|
}
|
|
19573
|
-
|
|
19574
|
-
|
|
19575
|
-
|
|
19576
|
-
if (this.db.prepare("SELECT 1 AS hit FROM telegram_update WHERE update_id = ?").get(updateId) !== undefined)
|
|
19577
|
-
return false;
|
|
19578
|
-
return Number(this.db.prepare("INSERT OR IGNORE INTO telegram_inbox (update_id, bot_id, payload, received_at) VALUES (?, ?, ?, ?)").run(updateId, botId, payload, now.toISOString()).changes) === 1;
|
|
19579
|
-
}
|
|
19580
|
-
/** v98: pushed updates waiting for the bridge, oldest first. */
|
|
19581
|
-
telegramInbox(botId, limit) {
|
|
19582
|
-
return this.db.prepare("SELECT update_id, payload FROM telegram_inbox WHERE bot_id = ? ORDER BY update_id LIMIT ?").all(botId, limit)
|
|
19583
|
-
.map(row => ({ updateId: Number(row["update_id"]), payload: String(row["payload"]) }));
|
|
19584
|
-
}
|
|
19585
|
-
dropTelegramInbox(updateId) {
|
|
19586
|
-
this.db.prepare("DELETE FROM telegram_inbox WHERE update_id = ?").run(updateId);
|
|
19587
|
-
}
|
|
18851
|
+
queueTelegramUpdate(...args) { return this.telegramChat().queueUpdate(...args); }
|
|
18852
|
+
telegramInbox(...args) { return this.telegramChat().inbox(...args); }
|
|
18853
|
+
dropTelegramInbox(...args) { return this.telegramChat().dropInbox(...args); }
|
|
19588
18854
|
/** v98: where Telegram pushes this bot's updates (null: the bridge asks for them), and what's wrong, if anything. */
|
|
19589
18855
|
setTelegramPush(botId, state, now) {
|
|
19590
|
-
this.
|
|
18856
|
+
this.telegramChat().setPush(botId, state, now);
|
|
19591
18857
|
}
|
|
19592
18858
|
telegramPush(botId) {
|
|
19593
|
-
|
|
19594
|
-
return row === undefined ? null : { url: row["push_url"] === null ? null : String(row["push_url"]), problem: row["push_problem"] === null ? null : String(row["push_problem"]), at: row["push_at"] === null ? null : String(row["push_at"]) };
|
|
18859
|
+
return this.telegramChat().push(botId);
|
|
19595
18860
|
}
|
|
18861
|
+
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
19596
18862
|
advanceBridgeCursor(botId, owner, generation, cursor, now) {
|
|
19597
|
-
|
|
19598
|
-
.prepare(`UPDATE bridge_lease SET cursor = ?, heartbeat_at = ?
|
|
19599
|
-
WHERE bot_id = ? AND owner = ? AND generation = ? AND cursor < ?`)
|
|
19600
|
-
.run(cursor, now.toISOString(), botId, owner, generation, cursor);
|
|
19601
|
-
return Number(changes) > 0;
|
|
19602
|
-
}
|
|
19603
|
-
/** True if this update has not been applied before. The PRIMARY KEY is the idempotency. */
|
|
19604
|
-
markTelegramUpdateApplied(updateId, result, now) {
|
|
19605
|
-
const { changes } = this.db
|
|
19606
|
-
.prepare("INSERT OR IGNORE INTO telegram_update (update_id, applied_at, result) VALUES (?, ?, ?)")
|
|
19607
|
-
.run(updateId, now.toISOString(), result);
|
|
19608
|
-
return Number(changes) > 0;
|
|
18863
|
+
return this.telegramChat().advanceCursor(botId, owner, generation, cursor, now);
|
|
19609
18864
|
}
|
|
18865
|
+
markTelegramUpdateApplied(...args) { return this.telegramChat().markUpdateApplied(...args); }
|
|
19610
18866
|
// ---- the conversation queue (v62) ------------------------------------------
|
|
19611
|
-
|
|
19612
|
-
|
|
19613
|
-
|
|
19614
|
-
|
|
19615
|
-
|
|
19616
|
-
|
|
19617
|
-
|
|
19618
|
-
|
|
19619
|
-
}
|
|
19620
|
-
|
|
19621
|
-
|
|
19622
|
-
return this.db.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE binding = ? AND message_id = ? AND state IN ('queued', 'running') LIMIT 1").get(binding, messageId) !== undefined;
|
|
19623
|
-
}
|
|
19624
|
-
getTelegramConversation(id) {
|
|
19625
|
-
const row = this.db.prepare("SELECT * FROM telegram_conversation WHERE id = ?").get(id);
|
|
19626
|
-
return row === undefined ? null : readTelegramConversation(row);
|
|
19627
|
-
}
|
|
19628
|
-
listTelegramConversations(botId) {
|
|
19629
|
-
return this.db.prepare("SELECT * FROM telegram_conversation WHERE bot_id = ? ORDER BY id").all(botId).map(readTelegramConversation);
|
|
19630
|
-
}
|
|
19631
|
-
/**
|
|
19632
|
-
* Claim the oldest message that still needs a turn: queued, or running
|
|
19633
|
-
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
19634
|
-
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
19635
|
-
*/
|
|
19636
|
-
claimTelegramConversation(botId, owner, ttlMs, now, only) {
|
|
19637
|
-
return this.transact(() => {
|
|
19638
|
-
const stamp = now.toISOString();
|
|
19639
|
-
// "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
|
|
19640
|
-
const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
|
|
19641
|
-
const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
|
|
19642
|
-
const row = this.db
|
|
19643
|
-
.prepare(`SELECT * FROM telegram_conversation
|
|
19644
|
-
WHERE bot_id = ? AND state IN ('queued','running')
|
|
19645
|
-
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
19646
|
-
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
19647
|
-
${which}
|
|
19648
|
-
ORDER BY id LIMIT 1`)
|
|
19649
|
-
.get(botId, stamp, stamp);
|
|
19650
|
-
if (row === undefined)
|
|
19651
|
-
return null;
|
|
19652
|
-
this.db
|
|
19653
|
-
.prepare(`UPDATE telegram_conversation SET state = 'running', claim_owner = ?, claim_expires_at = ?, attempts = attempts + 1,
|
|
19654
|
-
started_at = COALESCE(started_at, ?), next_attempt_at = NULL WHERE id = ?`)
|
|
19655
|
-
.run(owner, new Date(now.getTime() + ttlMs).toISOString(), stamp, Number(row["id"]));
|
|
19656
|
-
return this.getTelegramConversation(Number(row["id"]));
|
|
19657
|
-
});
|
|
19658
|
-
}
|
|
19659
|
-
/** Extend a held claim; false means it lapsed and somebody else may hold the row. */
|
|
19660
|
-
renewTelegramConversation(id, owner, ttlMs, now) {
|
|
19661
|
-
const { changes } = this.db
|
|
19662
|
-
.prepare("UPDATE telegram_conversation SET claim_expires_at = ? WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'")
|
|
19663
|
-
.run(new Date(now.getTime() + ttlMs).toISOString(), id, owner, now.toISOString());
|
|
19664
|
-
return Number(changes) === 1;
|
|
19665
|
-
}
|
|
19666
|
-
/**
|
|
19667
|
-
* Bind the session a turn is about to be dispatched under, BEFORE the
|
|
19668
|
-
* dispatch: the engine receipts the request inside that session, so a
|
|
19669
|
-
* later attempt — after a crash, even after the session was ended and
|
|
19670
|
-
* replaced from the console — reads the receipt where it was written
|
|
19671
|
-
* instead of resolving today's session and finding nothing there.
|
|
19672
|
-
*/
|
|
19673
|
-
bindTelegramConversationSession(id, owner, session) {
|
|
19674
|
-
const { changes } = this.db
|
|
19675
|
-
.prepare("UPDATE telegram_conversation SET session = ? WHERE id = ? AND claim_owner = ? AND state = 'running'")
|
|
19676
|
-
.run(session, id, owner);
|
|
19677
|
-
return Number(changes) === 1;
|
|
19678
|
-
}
|
|
19679
|
-
/** Bind the admitted turn to its row as soon as the engine's receipt names it. */
|
|
19680
|
-
bindTelegramConversationTurn(id, owner, session, turn) {
|
|
19681
|
-
const { changes } = this.db
|
|
19682
|
-
.prepare("UPDATE telegram_conversation SET session = ?, turn = ? WHERE id = ? AND claim_owner = ?")
|
|
19683
|
-
.run(session, turn, id, owner);
|
|
19684
|
-
return Number(changes) === 1;
|
|
19685
|
-
}
|
|
19686
|
-
/**
|
|
19687
|
-
* Settle a claimed row: done or failed with its outcome word, or back to
|
|
19688
|
-
* queued for a later attempt (a busy engine). Only the claim holder may;
|
|
19689
|
-
* a lapsed claim settles nothing, so a reclaimer's outcome stands.
|
|
19690
|
-
*/
|
|
19691
|
-
finishTelegramConversation(id, owner, result, now) {
|
|
19692
|
-
const stamp = now.toISOString();
|
|
19693
|
-
const { changes } = result.state === "queued"
|
|
19694
|
-
? this.db
|
|
19695
|
-
.prepare(`UPDATE telegram_conversation SET state = 'queued', outcome = ?, next_attempt_at = ?, claim_owner = NULL, claim_expires_at = NULL
|
|
19696
|
-
WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'`)
|
|
19697
|
-
.run(result.outcome, result.nextAttemptAt, id, owner, stamp)
|
|
19698
|
-
: this.db
|
|
19699
|
-
.prepare(`UPDATE telegram_conversation SET state = ?, outcome = ?, reply_message_id = COALESCE(?, reply_message_id), finished_at = ?,
|
|
19700
|
-
claim_owner = NULL, claim_expires_at = NULL
|
|
19701
|
-
WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'`)
|
|
19702
|
-
.run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
|
|
19703
|
-
return Number(changes) === 1;
|
|
19704
|
-
}
|
|
19705
|
-
/**
|
|
19706
|
-
* Replies the assistant wrote that still have not reached the person
|
|
19707
|
-
* `since` ago or longer (status and the console show them with a Retry):
|
|
19708
|
-
* the message's row, whose it is, when the oldest unsent part was
|
|
19709
|
-
* written, and the last send error.
|
|
19710
|
-
*/
|
|
19711
|
-
unsentTelegramReplies(botId, approver, since) {
|
|
19712
|
-
return this.db
|
|
19713
|
-
.prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
|
|
19714
|
-
(SELECT q.last_error FROM telegram_conversation_part q WHERE q.conversation = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
|
|
19715
|
-
FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
|
|
19716
|
-
WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
|
|
19717
|
-
GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
|
|
19718
|
-
.all(botId, botId, approver, approver, since.toISOString())
|
|
19719
|
-
.map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
|
|
19720
|
-
}
|
|
19721
|
-
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
19722
|
-
retryTelegramReplies(approver, now) {
|
|
19723
|
-
const { changes } = this.db
|
|
19724
|
-
.prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
|
|
19725
|
-
WHERE state = 'queued' AND (? IS NULL OR approver = ?)
|
|
19726
|
-
AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
|
|
19727
|
-
.run(now.toISOString(), approver, approver);
|
|
19728
|
-
return Number(changes);
|
|
19729
|
-
}
|
|
18867
|
+
enqueueTelegramConversation(...args) { return this.telegramChat().enqueueMessage(...args); }
|
|
18868
|
+
telegramConversationWaitingOn(...args) { return this.telegramChat().messageWaitingOn(...args); }
|
|
18869
|
+
getTelegramConversation(...args) { return this.telegramChat().getMessage(...args); }
|
|
18870
|
+
listTelegramConversations(...args) { return this.telegramChat().listMessages(...args); }
|
|
18871
|
+
claimTelegramConversation(...args) { return this.telegramChat().claimMessage(...args); }
|
|
18872
|
+
renewTelegramConversation(...args) { return this.telegramChat().renewMessage(...args); }
|
|
18873
|
+
bindTelegramConversationSession(...args) { return this.telegramChat().bindMessageSession(...args); }
|
|
18874
|
+
bindTelegramConversationTurn(...args) { return this.telegramChat().bindMessageTurn(...args); }
|
|
18875
|
+
finishTelegramConversation(...args) { return this.telegramChat().finishMessage(...args); }
|
|
18876
|
+
unsentTelegramReplies(...args) { return this.telegramChat().unsentReplies(...args); }
|
|
18877
|
+
retryTelegramReplies(...args) { return this.telegramChat().retryReplies(...args); }
|
|
19730
18878
|
// ---- the outbound parts (v63) ------------------------------------------------
|
|
19731
|
-
|
|
19732
|
-
|
|
19733
|
-
|
|
19734
|
-
|
|
19735
|
-
|
|
19736
|
-
* identity rides the row too, so the parts answer to exactly that turn.
|
|
19737
|
-
*/
|
|
19738
|
-
planTelegramConversationParts(id, owner, turn, parts, now) {
|
|
19739
|
-
return this.transact(() => {
|
|
19740
|
-
const stamp = now.toISOString();
|
|
19741
|
-
const held = this.db
|
|
19742
|
-
.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'")
|
|
19743
|
-
.get(id, owner, stamp);
|
|
19744
|
-
if (held === undefined)
|
|
19745
|
-
return false;
|
|
19746
|
-
const existing = this.db.prepare("SELECT COUNT(*) AS n FROM telegram_conversation_part WHERE conversation = ?").get(id);
|
|
19747
|
-
if (Number(existing?.["n"] ?? 0) > 0)
|
|
19748
|
-
return false;
|
|
19749
|
-
this.db.prepare("UPDATE telegram_conversation SET session = ?, turn = ? WHERE id = ?").run(turn.session, turn.turn, id);
|
|
19750
|
-
const insert = this.db.prepare(`INSERT INTO telegram_conversation_part (conversation, ordinal, kind, text, reply_to, proposal, keyboard_json, state, created_at, task_id, source_run, artifact, sha256)
|
|
19751
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`);
|
|
19752
|
-
parts.forEach((part, ordinal) => {
|
|
19753
|
-
const image = part.kind === "image" ? part : null;
|
|
19754
|
-
insert.run(id, ordinal, part.kind, part.text, part.kind === "reply" ? part.replyTo ?? null : null, part.kind === "card" ? part.proposal : null, (part.kind === "card" || part.kind === "reply") && part.keyboard != null ? JSON.stringify(part.keyboard) : null, stamp, image?.taskId ?? null, image?.run ?? null, image?.artifact ?? null, image?.sha256 ?? null);
|
|
19755
|
-
});
|
|
19756
|
-
return true;
|
|
19757
|
-
});
|
|
19758
|
-
}
|
|
19759
|
-
listTelegramConversationParts(conversation) {
|
|
19760
|
-
return this.db.prepare("SELECT * FROM telegram_conversation_part WHERE conversation = ? ORDER BY ordinal").all(conversation).map(readTelegramConversationPart);
|
|
19761
|
-
}
|
|
19762
|
-
/**
|
|
19763
|
-
* Settle one send attempt under the row's claim. A confirmed message id
|
|
19764
|
-
* is the only success: the part is sent, and a reply part's id becomes
|
|
19765
|
-
* the row's reply id. Anything else leaves the part pending with its
|
|
19766
|
-
* attempt counted, its error named, its next attempt scheduled, and —
|
|
19767
|
-
* when the answer was lost rather than refused — its uncertainty counted.
|
|
19768
|
-
* A lapsed claim settles nothing, so a reclaimer's outcome stands.
|
|
19769
|
-
*/
|
|
19770
|
-
settleTelegramConversationPart(conversation, ordinal, owner, outcome, now) {
|
|
19771
|
-
return this.transact(() => {
|
|
19772
|
-
const stamp = now.toISOString();
|
|
19773
|
-
const held = this.db
|
|
19774
|
-
.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'")
|
|
19775
|
-
.get(conversation, owner, stamp);
|
|
19776
|
-
if (held === undefined)
|
|
19777
|
-
return false;
|
|
19778
|
-
const { changes } = outcome.ok
|
|
19779
|
-
? this.db
|
|
19780
|
-
.prepare(`UPDATE telegram_conversation_part SET state = 'sent', message_id = ?, sent_at = ?, attempts = attempts + 1, next_attempt_at = NULL, last_error = NULL
|
|
19781
|
-
WHERE conversation = ? AND ordinal = ? AND state = 'pending'`)
|
|
19782
|
-
.run(outcome.messageId, stamp, conversation, ordinal)
|
|
19783
|
-
: this.db
|
|
19784
|
-
.prepare(`UPDATE telegram_conversation_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_attempt_at = ?, last_error = ?
|
|
19785
|
-
WHERE conversation = ? AND ordinal = ? AND state = 'pending'`)
|
|
19786
|
-
.run(outcome.uncertain ? 1 : 0, outcome.retryAt, outcome.error, conversation, ordinal);
|
|
19787
|
-
if (Number(changes) !== 1)
|
|
19788
|
-
return false;
|
|
19789
|
-
if (outcome.ok) {
|
|
19790
|
-
const part = this.db.prepare("SELECT kind FROM telegram_conversation_part WHERE conversation = ? AND ordinal = ?").get(conversation, ordinal);
|
|
19791
|
-
if (part?.["kind"] === "reply")
|
|
19792
|
-
this.db.prepare("UPDATE telegram_conversation SET reply_message_id = ? WHERE id = ?").run(outcome.messageId, conversation);
|
|
19793
|
-
}
|
|
19794
|
-
return true;
|
|
19795
|
-
});
|
|
19796
|
-
}
|
|
19797
|
-
/** A card whose proposal no longer waits (confirmed or dismissed from another surface, or gone) is moot: dropped with the reason, never sent. */
|
|
19798
|
-
dropTelegramConversationPart(conversation, ordinal, owner, reason, now) {
|
|
19799
|
-
const stamp = now.toISOString();
|
|
19800
|
-
const held = this.db
|
|
19801
|
-
.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE id = ? AND claim_owner = ? AND claim_expires_at > ? AND state = 'running'")
|
|
19802
|
-
.get(conversation, owner, stamp);
|
|
19803
|
-
if (held === undefined)
|
|
19804
|
-
return false;
|
|
19805
|
-
const { changes } = this.db
|
|
19806
|
-
.prepare("UPDATE telegram_conversation_part SET state = 'dropped', last_error = ?, next_attempt_at = NULL WHERE conversation = ? AND ordinal = ? AND state = 'pending'")
|
|
19807
|
-
.run(reason, conversation, ordinal);
|
|
19808
|
-
return Number(changes) === 1;
|
|
19809
|
-
}
|
|
19810
|
-
/** The exact task/run bindings of one outbound message this bot sent: a
|
|
19811
|
-
* plain fact names one, a digest part may name several, (v64) a confirmed
|
|
19812
|
-
* result image names its exact task and run, the lead's reply names the
|
|
19813
|
-
* task its turn was about, a card names its task, and (v79) a status
|
|
19814
|
-
* message names the task it showed. */
|
|
19815
|
-
telegramMessageBindings(binding, messageId) {
|
|
19816
|
-
const facts = this.db
|
|
19817
|
-
.prepare(`SELECT DISTINCT task_id, task_ref, source_run, project FROM telegram_outbound_message
|
|
19818
|
-
WHERE binding = ? AND chat_id = ? AND message_id = ? ORDER BY notification`)
|
|
19819
|
-
.all(binding.id, binding.chatId, messageId)
|
|
19820
|
-
.map(row => ({
|
|
19821
|
-
taskId: row["task_id"] === null ? null : String(row["task_id"]),
|
|
19822
|
-
taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]),
|
|
19823
|
-
run: row["source_run"] === null ? null : (this.telegramProgressRun({ taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]), run: Number(row["source_run"]) })?.id ?? Number(row["source_run"])),
|
|
19824
|
-
project: row["project"] === null ? null : String(row["project"]),
|
|
19825
|
-
}));
|
|
19826
|
-
const images = this.db
|
|
19827
|
-
.prepare(`SELECT DISTINCT p.task_id, p.source_run FROM telegram_conversation_part p
|
|
19828
|
-
JOIN telegram_conversation c ON c.id = p.conversation
|
|
19829
|
-
WHERE c.binding = ? AND c.chat_id = ? AND p.message_id = ? AND p.kind = 'image' AND p.state = 'sent'
|
|
19830
|
-
ORDER BY p.conversation, p.ordinal`)
|
|
19831
|
-
.all(binding.id, binding.chatId, messageId)
|
|
19832
|
-
.map(row => {
|
|
19833
|
-
const taskId = String(row["task_id"]);
|
|
19834
|
-
const ref = this.lookupRef(taskId);
|
|
19835
|
-
return { taskId, taskRef: ref?.id ?? null, run: Number(row["source_run"]), project: ref?.repo ?? null };
|
|
19836
|
-
});
|
|
19837
|
-
// The lead's own messages: a reply from a turn that was about one task,
|
|
19838
|
-
// and a card, which names its task (or, once it filed one, the new task).
|
|
19839
|
-
const lead = this.db
|
|
19840
|
-
.prepare(`SELECT c.task_id, c.source_run, p.kind, p.proposal FROM telegram_conversation_part p
|
|
19841
|
-
JOIN telegram_conversation c ON c.id = p.conversation
|
|
19842
|
-
WHERE c.binding = ? AND c.chat_id = ? AND p.message_id = ? AND p.state = 'sent' AND p.kind IN ('reply', 'card')
|
|
19843
|
-
ORDER BY p.conversation, p.ordinal`)
|
|
19844
|
-
.all(binding.id, binding.chatId, messageId)
|
|
19845
|
-
.map(row => row["kind"] === "card"
|
|
19846
|
-
? proposalTaskOf(row["proposal"] === null ? null : this.getMateProposal(Number(row["proposal"])))
|
|
19847
|
-
: row["task_id"] === null ? null : { task: String(row["task_id"]), run: row["source_run"] === null ? null : Number(row["source_run"]) });
|
|
19848
|
-
const shown = this.db
|
|
19849
|
-
.prepare("SELECT task_id, source_run FROM telegram_task_message WHERE binding = ? AND chat_id = ? AND message_id = ?")
|
|
19850
|
-
.all(binding.id, binding.chatId, messageId)
|
|
19851
|
-
.map(row => ({ task: String(row["task_id"]), run: row["source_run"] === null ? null : Number(row["source_run"]) }));
|
|
19852
|
-
const pointed = [...lead, ...shown].flatMap(one => {
|
|
19853
|
-
if (one === null)
|
|
19854
|
-
return [];
|
|
19855
|
-
const ref = this.lookupRef(one.task);
|
|
19856
|
-
return [{ taskId: one.task, taskRef: ref?.id ?? null, run: one.run, project: ref?.repo ?? null }];
|
|
19857
|
-
});
|
|
19858
|
-
return [...facts, ...images, ...pointed].filter((one, index, all) => all.findIndex(fact => fact.taskId === one.taskId && fact.taskRef === one.taskRef && fact.run === one.run && fact.project === one.project) === index);
|
|
19859
|
-
}
|
|
18879
|
+
planTelegramConversationParts(...args) { return this.telegramChat().planReplyParts(...args); }
|
|
18880
|
+
listTelegramConversationParts(...args) { return this.telegramChat().replyParts(...args); }
|
|
18881
|
+
settleTelegramConversationPart(...args) { return this.telegramChat().settleReplyPart(...args); }
|
|
18882
|
+
dropTelegramConversationPart(...args) { return this.telegramChat().dropReplyPart(...args); }
|
|
18883
|
+
telegramMessageBindings(...args) { return this.telegramChat().messageBindings(...args); }
|
|
19860
18884
|
// ---- proposal-card tokens (v62) ---------------------------------------------
|
|
19861
|
-
createTelegramProposalAction(
|
|
19862
|
-
|
|
19863
|
-
|
|
19864
|
-
|
|
19865
|
-
|
|
19866
|
-
}
|
|
19867
|
-
getTelegramProposalAction(token) {
|
|
19868
|
-
const row = this.db.prepare("SELECT * FROM telegram_proposal_action WHERE token = ?").get(token);
|
|
19869
|
-
return row === undefined ? null : readTelegramProposalAction(row);
|
|
19870
|
-
}
|
|
19871
|
-
/** Consume once. False means somebody already did, or it expired. */
|
|
19872
|
-
consumeTelegramProposalAction(token, now) {
|
|
19873
|
-
const stamp = now.toISOString();
|
|
19874
|
-
const { changes } = this.db
|
|
19875
|
-
.prepare("UPDATE telegram_proposal_action SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL AND (expires_at IS NULL OR expires_at > ?)")
|
|
19876
|
-
.run(stamp, token, stamp);
|
|
19877
|
-
return Number(changes) > 0;
|
|
19878
|
-
}
|
|
19879
|
-
/** Kill every live token on a proposal — after it resolves, or when a challenge is cancelled. */
|
|
19880
|
-
consumeTelegramProposalActions(proposal, now, phases) {
|
|
19881
|
-
const filter = phases === undefined ? "" : ` AND phase IN (${phases.map(() => "?").join(",")})`;
|
|
19882
|
-
this.db
|
|
19883
|
-
.prepare(`UPDATE telegram_proposal_action SET consumed_at = ? WHERE proposal = ? AND consumed_at IS NULL${filter}`)
|
|
19884
|
-
.run(now.toISOString(), proposal, ...(phases ?? []));
|
|
19885
|
-
}
|
|
19886
|
-
/** Stamp the message a card's keyboard actually landed on. */
|
|
19887
|
-
placeTelegramProposalActions(tokens, messageId) {
|
|
19888
|
-
for (const token of tokens) {
|
|
19889
|
-
this.db.prepare("UPDATE telegram_proposal_action SET message_id = ? WHERE token = ?").run(messageId, token);
|
|
19890
|
-
}
|
|
19891
|
-
}
|
|
18885
|
+
createTelegramProposalAction(...args) { return this.telegramChat().createProposalAction(...args); }
|
|
18886
|
+
getTelegramProposalAction(...args) { return this.telegramChat().proposalAction(...args); }
|
|
18887
|
+
consumeTelegramProposalAction(...args) { return this.telegramChat().consumeProposalAction(...args); }
|
|
18888
|
+
consumeTelegramProposalActions(...args) { return this.telegramChat().consumeProposalActions(...args); }
|
|
18889
|
+
placeTelegramProposalActions(...args) { return this.telegramChat().placeProposalActions(...args); }
|
|
19892
18890
|
// ---- delivery claiming ---------------------------------------------------
|
|
19893
|
-
telegramDestination(
|
|
19894
|
-
|
|
19895
|
-
}
|
|
19896
|
-
|
|
19897
|
-
* Do not combine planners, correction children, or separate build attempts. */
|
|
19898
|
-
telegramProgressRun(row) {
|
|
19899
|
-
const source = row.run === null ? null : this.getRun(row.run);
|
|
19900
|
-
const result = source?.role === "reviewer" && source.reviewAttempt != null && source.parentRun !== null
|
|
19901
|
-
? this.getRun(source.parentRun) : source;
|
|
19902
|
-
return result?.role === "builder" && result.taskRef === row.taskRef && source?.taskRef === row.taskRef ? result : null;
|
|
19903
|
-
}
|
|
19904
|
-
/** Reuse a confirmed message in this exact destination. A mixed digest,
|
|
19905
|
-
* decision, image, or message naming another attempt is never editable. The
|
|
19906
|
-
* outbound history survives a restart even if the final receipt was lost. */
|
|
19907
|
-
telegramProgressMessage(binding, result) {
|
|
19908
|
-
const messages = this.db.prepare(`SELECT DISTINCT message_id FROM telegram_outbound_message
|
|
19909
|
-
WHERE destination = ? AND task_ref = ? ORDER BY CAST(message_id AS INTEGER) DESC`)
|
|
19910
|
-
.all(this.telegramDestination(binding), result.taskRef);
|
|
19911
|
-
for (const message of messages) {
|
|
19912
|
-
const rows = this.db.prepare(`SELECT n.* FROM telegram_outbound_message m JOIN notification n ON n.id = m.notification
|
|
19913
|
-
WHERE m.destination = ? AND m.message_id = ?`).all(this.telegramDestination(binding), String(message["message_id"]));
|
|
19914
|
-
if (rows.length > 0 && rows.every(raw => {
|
|
19915
|
-
const row = readNotification(raw);
|
|
19916
|
-
return isTelegramProgressNotification(row) && this.telegramProgressRun(row)?.id === result.id;
|
|
19917
|
-
}))
|
|
19918
|
-
return String(message["message_id"]);
|
|
19919
|
-
}
|
|
19920
|
-
return null;
|
|
19921
|
-
}
|
|
19922
|
-
/** The existing outbox, with a separate receipt for this exact pairing. */
|
|
19923
|
-
claimTelegramDeliveries(binding, owner, ttlMs, now, only = "all") {
|
|
19924
|
-
return this.transact(() => {
|
|
19925
|
-
const destination = this.telegramDestination(binding);
|
|
19926
|
-
this.db.prepare(`INSERT OR IGNORE INTO notification_delivery (notification, destination)
|
|
19927
|
-
SELECT id, ? FROM notification WHERE resolved_at IS NULL`).run(destination);
|
|
19928
|
-
// Someone else's notification is settled here as skipped, never sent —
|
|
19929
|
-
// and never left unsettled, where it would fence that task's later rows.
|
|
19930
|
-
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
|
|
19931
|
-
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
19932
|
-
AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
|
|
19933
|
-
// A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
|
|
19934
|
-
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
|
|
19935
|
-
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
19936
|
-
AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
|
|
19937
|
-
// Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
|
|
19938
|
-
for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
19939
|
-
WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
|
|
19940
|
-
const row = readNotification(raw);
|
|
19941
|
-
if (!this.pingAllowed(row, binding.approver)) {
|
|
19942
|
-
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
|
|
19943
|
-
}
|
|
19944
|
-
}
|
|
19945
|
-
if (this.telegramRetryAt(binding.botId) > now.toISOString())
|
|
19946
|
-
return [];
|
|
19947
|
-
const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
|
|
19948
|
-
JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
19949
|
-
WHERE n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}
|
|
19950
|
-
AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)
|
|
19951
|
-
AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
|
|
19952
|
-
AND (? = 'all' OR d.attempts > 0 OR n.dedupe_key LIKE 'decision:%' OR n.push_class = 'attention' OR n.kind IN ('acceptance-evidence', 'acceptance-ready')
|
|
19953
|
-
OR EXISTS (SELECT 1 FROM notification urgent
|
|
19954
|
-
WHERE urgent.task_ref = n.task_ref AND urgent.id > n.id AND urgent.resolved_at IS NULL
|
|
19955
|
-
AND (urgent.dedupe_key LIKE 'decision:%' OR urgent.push_class = 'attention' OR urgent.kind IN ('acceptance-evidence', 'acceptance-ready'))))
|
|
19956
|
-
ORDER BY n.id`).all(destination, now.toISOString(), now.toISOString(), only);
|
|
19957
|
-
return rows.map(row => {
|
|
19958
|
-
this.db.prepare(`UPDATE notification_delivery SET claim_owner = ?, claim_expires_at = ?,
|
|
19959
|
-
claim_generation = claim_generation + 1 WHERE notification = ? AND destination = ?`)
|
|
19960
|
-
.run(owner, new Date(now.getTime() + ttlMs).toISOString(), Number(row["id"]), destination);
|
|
19961
|
-
return { ...readNotification(row), destination, claimGeneration: Number(row["claim_generation"]) + 1 };
|
|
19962
|
-
});
|
|
19963
|
-
});
|
|
19964
|
-
}
|
|
18891
|
+
telegramDestination(...args) { return this.telegramChat().destination(...args); }
|
|
18892
|
+
telegramProgressRun(...args) { return this.telegramChat().progressRun(...args); }
|
|
18893
|
+
telegramProgressMessage(...args) { return this.telegramChat().progressMessage(...args); }
|
|
18894
|
+
claimTelegramDeliveries(...args) { return this.telegramChat().claimDeliveries(...args); }
|
|
19965
18895
|
telegramRetryAt(botId) {
|
|
19966
|
-
return
|
|
18896
|
+
return this.telegramChat().retryAt(botId);
|
|
19967
18897
|
}
|
|
19968
18898
|
deferTelegram(botId, until) {
|
|
19969
|
-
this.
|
|
19970
|
-
ON CONFLICT (bot_id) DO UPDATE SET next_attempt_at = MAX(next_attempt_at, excluded.next_attempt_at)`).run(botId, until);
|
|
19971
|
-
}
|
|
19972
|
-
/** Synchronous final fence, called AFTER each asynchronous enrollment read. */
|
|
19973
|
-
telegramDeliveryProblem(row, binding, owner, projects, now, batch = [row.id]) {
|
|
19974
|
-
const live = this.liveTelegramBindingById(binding.id);
|
|
19975
|
-
if (live === null || live.approverGeneration !== binding.approverGeneration ||
|
|
19976
|
-
this.accountOf(binding.approver)?.role !== "approver")
|
|
19977
|
-
return TELEGRAM_HOLD_REASONS.authority;
|
|
19978
|
-
if (row.destination !== this.telegramDestination(live))
|
|
19979
|
-
return TELEGRAM_HOLD_REASONS.destination;
|
|
19980
|
-
if (!this.telegramClaimHeld(row, owner, now))
|
|
19981
|
-
return TELEGRAM_HOLD_REASONS.claim;
|
|
19982
|
-
if (this.telegramRetryAt(binding.botId) > now.toISOString())
|
|
19983
|
-
return TELEGRAM_HOLD_REASONS.rateLimit;
|
|
19984
|
-
const current = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(row.id);
|
|
19985
|
-
if (current === undefined || current["resolved_at"] !== null)
|
|
19986
|
-
return TELEGRAM_HOLD_REASONS.resolved;
|
|
19987
|
-
if (row.scope === "unknown")
|
|
19988
|
-
return TELEGRAM_HOLD_REASONS.provenance;
|
|
19989
|
-
if (row.scope === "installation") {
|
|
19990
|
-
// One addressed to this person (their own budget, v105) reaches them; the rest of the installation's are an operator's.
|
|
19991
|
-
if (!this.isInstanceOperator(binding.approver) && current["recipient"] !== binding.approver)
|
|
19992
|
-
return TELEGRAM_HOLD_REASONS.installation;
|
|
19993
|
-
}
|
|
19994
|
-
else {
|
|
19995
|
-
if (row.project === null || !projects.includes(row.project) || !this.accountCanAccess(binding.approver, row.project))
|
|
19996
|
-
return TELEGRAM_HOLD_REASONS.project;
|
|
19997
|
-
if (row.scope === "task") {
|
|
19998
|
-
const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(row.taskRef);
|
|
19999
|
-
if (ref === undefined || ref["repo"] !== row.project || ref["external_id"] !== row.taskId ||
|
|
20000
|
-
(row.run !== null && this.getRun(row.run)?.taskRef !== row.taskRef))
|
|
20001
|
-
return TELEGRAM_HOLD_REASONS.task;
|
|
20002
|
-
}
|
|
20003
|
-
}
|
|
20004
|
-
if (row.taskRef !== null) {
|
|
20005
|
-
// Skipped history never fences what follows it: it is settled.
|
|
20006
|
-
const earlier = this.db.prepare(`SELECT n.id FROM notification n
|
|
20007
|
-
LEFT JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
20008
|
-
WHERE n.task_ref = ? AND n.id < ? AND n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}`)
|
|
20009
|
-
.all(row.destination, row.taskRef, row.id);
|
|
20010
|
-
if (earlier.some(one => !batch.includes(Number(one["id"]))))
|
|
20011
|
-
return TELEGRAM_HOLD_REASONS.order;
|
|
20012
|
-
}
|
|
20013
|
-
return null;
|
|
20014
|
-
}
|
|
20015
|
-
telegramClaimHeld(row, owner, now) {
|
|
20016
|
-
return this.db.prepare(`SELECT 1 FROM notification_delivery WHERE notification = ? AND destination = ?
|
|
20017
|
-
AND claim_owner = ? AND claim_generation = ? AND claim_expires_at > ? AND delivered_at IS NULL`)
|
|
20018
|
-
.get(row.id, row.destination, owner, row.claimGeneration, now.toISOString()) !== undefined;
|
|
20019
|
-
}
|
|
20020
|
-
/** A confirmed network message remains history even if authority changed
|
|
20021
|
-
* while it was in flight. It only binds the OLD destination, never new work. */
|
|
20022
|
-
recordTelegramMessage(row, binding, messageId, now) {
|
|
20023
|
-
if (row.destination !== this.telegramDestination(binding))
|
|
20024
|
-
throw new Error("Telegram message destination mismatch");
|
|
20025
|
-
this.db.prepare(`INSERT OR IGNORE INTO telegram_outbound_message
|
|
20026
|
-
(binding, bot_id, chat_id, message_id, notification, destination, project, task_ref, task_id, source_run, created_at)
|
|
20027
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
20028
|
-
.run(binding.id, binding.botId, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
|
|
20029
|
-
}
|
|
20030
|
-
/** The newest confirmed message this notification became at one destination, if any. */
|
|
20031
|
-
telegramMessageOf(notification, destination) {
|
|
20032
|
-
const row = this.db.prepare("SELECT message_id FROM telegram_outbound_message WHERE notification = ? AND destination = ? ORDER BY CAST(message_id AS INTEGER) DESC LIMIT 1").get(notification, destination);
|
|
20033
|
-
return row === undefined ? null : String(row["message_id"]);
|
|
20034
|
-
}
|
|
20035
|
-
finalizeTelegramDelivery(row, binding, owner, outcome, now) {
|
|
20036
|
-
return this.transact(() => {
|
|
20037
|
-
if (!this.telegramClaimHeld(row, owner, now) || row.destination !== this.telegramDestination(binding))
|
|
20038
|
-
return false;
|
|
20039
|
-
if (outcome.ok && (this.liveTelegramBindingById(binding.id) === null || this.accountOf(binding.approver)?.role !== "approver"))
|
|
20040
|
-
return false;
|
|
20041
|
-
// A `skipped:` receipt settles the row without a delivery: nothing
|
|
20042
|
-
// reached the phone, so delivered_at stays empty exactly as it does
|
|
20043
|
-
// for the pairing skip (TELEGRAM_UNSETTLED treats both as settled).
|
|
20044
|
-
const skipped = outcome.ok && outcome.receipt !== null && outcome.receipt.startsWith("skipped:");
|
|
20045
|
-
this.db.prepare(`UPDATE notification_delivery SET attempts = attempts + 1, last_attempt_at = ?,
|
|
20046
|
-
delivered_at = ?, receipt = ?, last_error = ?, next_attempt_at = ?, claim_owner = NULL, claim_expires_at = NULL
|
|
20047
|
-
WHERE notification = ? AND destination = ? AND claim_owner = ? AND claim_generation = ?`)
|
|
20048
|
-
.run(now.toISOString(), outcome.ok && !skipped ? now.toISOString() : null, outcome.ok ? outcome.receipt : null, outcome.ok ? null : outcome.error, outcome.ok ? null : outcome.retryAt ?? new Date(now.getTime() + 1_000).toISOString(), row.id, row.destination, owner, row.claimGeneration);
|
|
20049
|
-
return true;
|
|
20050
|
-
});
|
|
20051
|
-
}
|
|
20052
|
-
/** The notifications this pairing holds, each with its delivery state there. */
|
|
20053
|
-
telegramDeliveries(binding) {
|
|
20054
|
-
return this.db.prepare(`SELECT n.*, d.attempts, d.last_attempt_at, d.last_error, d.delivered_at, d.receipt
|
|
20055
|
-
FROM notification n JOIN notification_delivery d ON d.notification = n.id WHERE d.destination = ? ORDER BY n.id`)
|
|
20056
|
-
.all(this.telegramDestination(binding)).map(readNotificationReceipt);
|
|
18899
|
+
this.telegramChat().deferUntil(botId, until);
|
|
20057
18900
|
}
|
|
18901
|
+
telegramDeliveryProblem(...args) { return this.telegramChat().deliveryProblem(...args); }
|
|
18902
|
+
recordTelegramMessage(...args) { return this.telegramChat().recordMessage(...args); }
|
|
18903
|
+
telegramMessageOf(...args) { return this.telegramChat().messageOf(...args); }
|
|
18904
|
+
finalizeTelegramDelivery(...args) { return this.telegramChat().finalizeDelivery(...args); }
|
|
18905
|
+
telegramDeliveries(...args) { return this.telegramChat().deliveries(...args); }
|
|
20058
18906
|
// ---- the Telegram digest (v34, away mode) --------------------------------
|
|
20059
|
-
|
|
20060
|
-
|
|
20061
|
-
const row = this.db.prepare("SELECT every_ms, set_by, set_at, last_sent_at FROM telegram_digest WHERE id = 1").get();
|
|
20062
|
-
return {
|
|
20063
|
-
everyMs: row?.["every_ms"] === null || row?.["every_ms"] === undefined ? null : Number(row["every_ms"]),
|
|
20064
|
-
setBy: row?.["set_by"] === null || row?.["set_by"] === undefined ? null : String(row["set_by"]),
|
|
20065
|
-
setAt: row?.["set_at"] === null || row?.["set_at"] === undefined ? null : String(row["set_at"]),
|
|
20066
|
-
lastSentAt: row?.["last_sent_at"] === null || row?.["last_sent_at"] === undefined ? null : String(row["last_sent_at"]),
|
|
20067
|
-
};
|
|
20068
|
-
}
|
|
20069
|
-
/** Set (or clear, with null) the cadence. Turning it on starts the
|
|
20070
|
-
* window NOW — nothing held before the choice is dumped at once. */
|
|
20071
|
-
setTelegramDigest(everyMs, by, now) {
|
|
20072
|
-
this.db
|
|
20073
|
-
.prepare("UPDATE telegram_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE id = 1")
|
|
20074
|
-
.run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
|
|
20075
|
-
}
|
|
18907
|
+
telegramDigest(...args) { return this.telegramChat().digest(...args); }
|
|
18908
|
+
setTelegramDigest(...args) { return this.telegramChat().setDigest(...args); }
|
|
20076
18909
|
// ---- quiet chat: each person's notification choice and each task's one message ----
|
|
20077
18910
|
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
20078
18911
|
notificationPreference(account) {
|
|
@@ -20424,9 +19257,7 @@ export class Store {
|
|
|
20424
19257
|
setChatCardMessage(id, message, digest) {
|
|
20425
19258
|
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
20426
19259
|
}
|
|
20427
|
-
markTelegramDigestSent(
|
|
20428
|
-
this.db.prepare("UPDATE telegram_digest SET last_sent_at = ? WHERE id = 1").run(now.toISOString());
|
|
20429
|
-
}
|
|
19260
|
+
markTelegramDigestSent(...args) { return this.telegramChat().markDigestSent(...args); }
|
|
20430
19261
|
/** Routine rows waiting for the next digest: pending, and not urgent.
|
|
20431
19262
|
* A row the live pairing delivered, or skipped as pre-pairing history,
|
|
20432
19263
|
* is settled and not waiting. */
|
|
@@ -20435,10 +19266,10 @@ export class Store {
|
|
|
20435
19266
|
.prepare(`SELECT COUNT(*) AS n FROM notification
|
|
20436
19267
|
WHERE resolved_at IS NULL AND recipient IS NULL
|
|
20437
19268
|
AND NOT EXISTS (SELECT 1 FROM notification_delivery d
|
|
20438
|
-
JOIN
|
|
20439
|
-
JOIN approver a ON a.name = b.approver AND a.generation = b.
|
|
19269
|
+
JOIN chat_binding b ON b.provider = 'telegram' AND d.destination = 'telegram:' || b.installation || ':' || b.channel || ':' || b.id || ':' || b.generation
|
|
19270
|
+
JOIN approver a ON a.name = b.approver AND a.generation = b.generation
|
|
20440
19271
|
WHERE d.notification = notification.id AND NOT (${TELEGRAM_UNSETTLED})
|
|
20441
|
-
AND b.
|
|
19272
|
+
AND b.revoked IS NULL AND a.revoked_at IS NULL)
|
|
20442
19273
|
AND NOT (dedupe_key LIKE 'decision:%' OR COALESCE(push_class, '') = 'attention')`)
|
|
20443
19274
|
.get();
|
|
20444
19275
|
return Number(row?.["n"] ?? 0);
|
|
@@ -20463,9 +19294,9 @@ export class Store {
|
|
|
20463
19294
|
return this.db
|
|
20464
19295
|
.prepare(`WITH troubled AS (
|
|
20465
19296
|
SELECT d.notification AS id FROM notification_delivery d
|
|
20466
|
-
JOIN
|
|
20467
|
-
JOIN approver a ON a.name = b.approver AND a.generation = b.
|
|
20468
|
-
WHERE b.
|
|
19297
|
+
JOIN chat_binding b ON b.provider = 'telegram' AND d.destination = 'telegram:' || b.installation || ':' || b.channel || ':' || b.id || ':' || b.generation
|
|
19298
|
+
JOIN approver a ON a.name = b.approver AND a.generation = b.generation
|
|
19299
|
+
WHERE b.revoked IS NULL AND a.revoked_at IS NULL
|
|
20469
19300
|
AND ${TELEGRAM_UNSETTLED}
|
|
20470
19301
|
AND d.attempts > 0 AND d.last_error IS NOT NULL
|
|
20471
19302
|
AND d.last_error NOT IN (${holds.map(() => "?").join(", ")})
|
|
@@ -20830,73 +19661,11 @@ function readHold(row) {
|
|
|
20830
19661
|
* explicit skip, with no delivered timestamp and no attempt, so suppressed
|
|
20831
19662
|
* history is never reported as a send that happened. */
|
|
20832
19663
|
export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
|
|
20833
|
-
|
|
20834
|
-
export
|
|
20835
|
-
/**
|
|
20836
|
-
|
|
20837
|
-
|
|
20838
|
-
export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
20839
|
-
/** A destination receipt (`d` = notification_delivery) that still owes a
|
|
20840
|
-
* send: not delivered, and not explicitly skipped as pre-pairing history.
|
|
20841
|
-
* Every reader of Telegram receipts that means "undelivered" uses this, so
|
|
20842
|
-
* a skip settles the row without ever pretending to be a delivery. */
|
|
20843
|
-
const TELEGRAM_UNSETTLED = "d.delivered_at IS NULL AND (d.receipt IS NULL OR d.receipt NOT LIKE 'skipped:%')";
|
|
20844
|
-
/**
|
|
20845
|
-
* Why the store itself holds a Telegram delivery back: authority,
|
|
20846
|
-
* provenance, ordering, a switched-off channel, or a wait it imposed. A
|
|
20847
|
-
* row held for one of these is waiting on policy, not failing on the wire.
|
|
20848
|
-
* Every other error recorded on a receipt came from an actual send, and
|
|
20849
|
-
* THAT is delivery trouble (`pendingForAttention`). One table, so the
|
|
20850
|
-
* fences and the tally can never disagree about the words.
|
|
20851
|
-
*/
|
|
20852
|
-
export const TELEGRAM_HOLD_REASONS = Object.freeze({
|
|
20853
|
-
authority: "Telegram pairing or actor authorization changed",
|
|
20854
|
-
destination: "Telegram destination changed",
|
|
20855
|
-
claim: "Telegram delivery claim expired or changed",
|
|
20856
|
-
rateLimit: "Telegram rate limit is still active",
|
|
20857
|
-
resolved: "Notification no longer needs delivery",
|
|
20858
|
-
provenance: "Notification has no trusted project provenance",
|
|
20859
|
-
installation: "Installation notification requires instance access",
|
|
20860
|
-
project: "Notification project is not currently authorized and enrolled",
|
|
20861
|
-
task: "Notification task provenance changed",
|
|
20862
|
-
order: "Earlier task notification is still undelivered",
|
|
20863
|
-
disabled: "Telegram delivery is disabled",
|
|
20864
|
-
});
|
|
20865
|
-
function readNotification(row) {
|
|
20866
|
-
return {
|
|
20867
|
-
id: Number(row["id"]),
|
|
20868
|
-
recipient: row["recipient"] == null ? null : String(row["recipient"]),
|
|
20869
|
-
dedupeKey: String(row["dedupe_key"]),
|
|
20870
|
-
scope: row["provenance_scope"],
|
|
20871
|
-
project: row["project"] == null ? null : String(row["project"]),
|
|
20872
|
-
taskRef: row["task_ref"] == null ? null : Number(row["task_ref"]),
|
|
20873
|
-
taskId: row["task_id"] == null ? null : String(row["task_id"]),
|
|
20874
|
-
run: row["source_run"] == null ? null : Number(row["source_run"]),
|
|
20875
|
-
kind: String(row["kind"]),
|
|
20876
|
-
subject: String(row["subject"]),
|
|
20877
|
-
body: String(row["body"]),
|
|
20878
|
-
createdAt: String(row["created_at"]),
|
|
20879
|
-
resolvedAt: row["resolved_at"] === null || row["resolved_at"] === undefined
|
|
20880
|
-
? null
|
|
20881
|
-
: String(row["resolved_at"]),
|
|
20882
|
-
pushClass: (row["push_class"] === null || row["push_class"] === undefined) && String(row["kind"]) === "check-progress"
|
|
20883
|
-
? "progress"
|
|
20884
|
-
: row["push_class"] === null || row["push_class"] === undefined
|
|
20885
|
-
? null
|
|
20886
|
-
: String(row["push_class"]),
|
|
20887
|
-
link: row["link"] === null || row["link"] === undefined ? null : String(row["link"]),
|
|
20888
|
-
};
|
|
20889
|
-
}
|
|
20890
|
-
function readNotificationReceipt(row) {
|
|
20891
|
-
return {
|
|
20892
|
-
...readNotification(row),
|
|
20893
|
-
attempts: Number(row["attempts"]),
|
|
20894
|
-
lastAttemptAt: row["last_attempt_at"] == null ? null : String(row["last_attempt_at"]),
|
|
20895
|
-
lastError: row["last_error"] == null ? null : String(row["last_error"]),
|
|
20896
|
-
deliveredAt: row["delivered_at"] == null ? null : String(row["delivered_at"]),
|
|
20897
|
-
receipt: row["receipt"] == null ? null : String(row["receipt"]),
|
|
20898
|
-
};
|
|
20899
|
-
}
|
|
19664
|
+
export { TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET };
|
|
19665
|
+
export { isLifecycleNotification, isTelegramProgressNotification, LIFECYCLE_KEY_PREFIX, LIFECYCLE_KINDS, proposalTaskOf };
|
|
19666
|
+
/** What still wants a person who pairs after it landed, in every chat app (chat-quiet.ts wantedBeforePairing): an open
|
|
19667
|
+
* decision or an attention notice. Everything else from before a first pairing is history. */
|
|
19668
|
+
const WANTED_BEFORE_PAIRING = "(dedupe_key LIKE 'decision:%' OR COALESCE(push_class, '') = 'attention')";
|
|
20900
19669
|
function readPushSubscription(row) {
|
|
20901
19670
|
return {
|
|
20902
19671
|
id: Number(row["id"]),
|
|
@@ -21025,116 +19794,23 @@ function readRunCheckpoint(row) {
|
|
|
21025
19794
|
createdAt: String(row["created_at"]),
|
|
21026
19795
|
};
|
|
21027
19796
|
}
|
|
21028
|
-
|
|
21029
|
-
|
|
21030
|
-
|
|
21031
|
-
botId: String(row["bot_id"]),
|
|
21032
|
-
chatId: String(row["chat_id"]),
|
|
21033
|
-
binding: Number(row["binding"]),
|
|
21034
|
-
kind: row["kind"] === "group" ? "group" : "private",
|
|
21035
|
-
conversation: String(row["conversation"]),
|
|
21036
|
-
boundBy: String(row["bound_by"]),
|
|
21037
|
-
boundAt: String(row["bound_at"]),
|
|
21038
|
-
cursor: Number(row["cursor"]),
|
|
21039
|
-
};
|
|
21040
|
-
}
|
|
21041
|
-
function readTelegramBinding(row) {
|
|
21042
|
-
return {
|
|
21043
|
-
id: Number(row["id"]),
|
|
21044
|
-
botId: String(row["bot_id"]),
|
|
21045
|
-
chatId: String(row["chat_id"]),
|
|
21046
|
-
userId: String(row["user_id"]),
|
|
21047
|
-
approver: String(row["approver"]),
|
|
21048
|
-
approverGeneration: Number(row["approver_generation"]),
|
|
21049
|
-
pairedAt: String(row["paired_at"]),
|
|
21050
|
-
pairedBy: String(row["paired_by"]),
|
|
21051
|
-
revokedAt: row["revoked_at"] === null ? null : String(row["revoked_at"]),
|
|
21052
|
-
revokedBy: row["revoked_by"] === null ? null : String(row["revoked_by"]),
|
|
21053
|
-
};
|
|
21054
|
-
}
|
|
21055
|
-
function readTelegramConversation(row) {
|
|
21056
|
-
const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
|
|
21057
|
-
const int = (key) => (row[key] === null || row[key] === undefined ? null : Number(row[key]));
|
|
21058
|
-
return {
|
|
21059
|
-
id: Number(row["id"]),
|
|
21060
|
-
binding: Number(row["binding"]),
|
|
21061
|
-
botId: String(row["bot_id"]),
|
|
21062
|
-
chatId: String(row["chat_id"]),
|
|
21063
|
-
userId: String(row["user_id"]),
|
|
21064
|
-
approver: String(row["approver"]),
|
|
21065
|
-
approverGeneration: Number(row["approver_generation"]),
|
|
21066
|
-
updateId: Number(row["update_id"]),
|
|
21067
|
-
messageId: String(row["message_id"]),
|
|
21068
|
-
replyTo: text("reply_to"),
|
|
21069
|
-
request: String(row["request"]),
|
|
21070
|
-
text: String(row["text"]),
|
|
21071
|
-
context: text("context"),
|
|
21072
|
-
taskId: text("task_id"),
|
|
21073
|
-
sourceRun: int("source_run"),
|
|
21074
|
-
state: String(row["state"]),
|
|
21075
|
-
claimOwner: text("claim_owner"),
|
|
21076
|
-
claimExpiresAt: text("claim_expires_at"),
|
|
21077
|
-
attempts: Number(row["attempts"]),
|
|
21078
|
-
nextAttemptAt: text("next_attempt_at"),
|
|
21079
|
-
session: int("session"),
|
|
21080
|
-
turn: int("turn"),
|
|
21081
|
-
outcome: text("outcome"),
|
|
21082
|
-
replyMessageId: text("reply_message_id"),
|
|
21083
|
-
createdAt: String(row["created_at"]),
|
|
21084
|
-
startedAt: text("started_at"),
|
|
21085
|
-
finishedAt: text("finished_at"),
|
|
21086
|
-
};
|
|
21087
|
-
}
|
|
21088
|
-
function readTelegramConversationPart(row) {
|
|
21089
|
-
const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
|
|
21090
|
-
return {
|
|
21091
|
-
conversation: Number(row["conversation"]),
|
|
21092
|
-
ordinal: Number(row["ordinal"]),
|
|
21093
|
-
kind: String(row["kind"]),
|
|
21094
|
-
text: String(row["text"]),
|
|
21095
|
-
replyTo: text("reply_to"),
|
|
21096
|
-
proposal: row["proposal"] === null || row["proposal"] === undefined ? null : Number(row["proposal"]),
|
|
21097
|
-
keyboard: row["keyboard_json"] === null || row["keyboard_json"] === undefined ? null : parseStoreColumn("telegram_conversation_part.keyboard_json", row["keyboard_json"]),
|
|
21098
|
-
state: String(row["state"]),
|
|
21099
|
-
messageId: text("message_id"),
|
|
21100
|
-
taskId: text("task_id"),
|
|
21101
|
-
run: row["source_run"] === null || row["source_run"] === undefined ? null : Number(row["source_run"]),
|
|
21102
|
-
artifact: row["artifact"] === null || row["artifact"] === undefined ? null : Number(row["artifact"]),
|
|
21103
|
-
sha256: text("sha256"),
|
|
21104
|
-
attempts: Number(row["attempts"]),
|
|
21105
|
-
uncertain: Number(row["uncertain"]),
|
|
21106
|
-
nextAttemptAt: text("next_attempt_at"),
|
|
21107
|
-
lastError: text("last_error"),
|
|
21108
|
-
createdAt: String(row["created_at"]),
|
|
21109
|
-
sentAt: text("sent_at"),
|
|
21110
|
-
};
|
|
19797
|
+
/** A Telegram room (team chat), in the Telegram adapter's names. */
|
|
19798
|
+
function telegramTeamChatOf(room, botId) {
|
|
19799
|
+
return { id: room.id, botId, chatId: room.chat, binding: room.binding, kind: room.kind, conversation: room.conversation, boundBy: room.boundBy, boundAt: room.boundAt, cursor: room.cursor };
|
|
21111
19800
|
}
|
|
21112
|
-
|
|
19801
|
+
/** A Telegram pairing, in the Telegram adapter's names: the bot is the installation, the user the member, the private chat the channel. */
|
|
19802
|
+
export function telegramBindingOf(binding) {
|
|
21113
19803
|
return {
|
|
21114
|
-
|
|
21115
|
-
|
|
21116
|
-
|
|
21117
|
-
|
|
21118
|
-
|
|
21119
|
-
|
|
21120
|
-
|
|
21121
|
-
|
|
21122
|
-
|
|
21123
|
-
|
|
21124
|
-
}
|
|
21125
|
-
function readTelegramAction(row) {
|
|
21126
|
-
return {
|
|
21127
|
-
token: String(row["token"]),
|
|
21128
|
-
binding: Number(row["binding"]),
|
|
21129
|
-
decision: Number(row["decision"]),
|
|
21130
|
-
optionId: String(row["option_id"]),
|
|
21131
|
-
phase: String(row["phase"]),
|
|
21132
|
-
chatId: String(row["chat_id"]),
|
|
21133
|
-
messageId: row["message_id"] === null ? null : String(row["message_id"]),
|
|
21134
|
-
createdAt: String(row["created_at"]),
|
|
21135
|
-
expiresAt: row["expires_at"] === null ? null : String(row["expires_at"]),
|
|
21136
|
-
consumedAt: row["consumed_at"] === null ? null : String(row["consumed_at"]),
|
|
21137
|
-
noteDigest: row["note_digest"] === null || row["note_digest"] === undefined ? null : String(row["note_digest"]),
|
|
19804
|
+
id: binding.id,
|
|
19805
|
+
botId: binding.installation,
|
|
19806
|
+
chatId: binding.channel,
|
|
19807
|
+
userId: binding.member,
|
|
19808
|
+
approver: binding.approver,
|
|
19809
|
+
approverGeneration: binding.generation,
|
|
19810
|
+
pairedAt: binding.created,
|
|
19811
|
+
pairedBy: binding.created_by ?? binding.approver,
|
|
19812
|
+
revokedAt: binding.revoked,
|
|
19813
|
+
revokedBy: binding.revoked_by,
|
|
21138
19814
|
};
|
|
21139
19815
|
}
|
|
21140
19816
|
function readIncident(row) {
|