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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chat-ask.js +8 -8
  3. package/dist/chat-core.d.ts +171 -0
  4. package/dist/chat-core.js +288 -0
  5. package/dist/chat-decide.d.ts +10 -8
  6. package/dist/chat-decide.js +26 -21
  7. package/dist/chat-delivery-state.d.ts +51 -75
  8. package/dist/chat-delivery-state.js +140 -170
  9. package/dist/chat-delivery.d.ts +21 -2
  10. package/dist/chat-delivery.js +313 -194
  11. package/dist/chat-flow.d.ts +29 -1
  12. package/dist/chat-flow.js +24 -50
  13. package/dist/chat-messages.d.ts +319 -0
  14. package/dist/chat-messages.js +841 -0
  15. package/dist/chat-migration.d.ts +30 -0
  16. package/dist/chat-migration.js +802 -0
  17. package/dist/chat-quiet.d.ts +3 -0
  18. package/dist/chat-quiet.js +5 -0
  19. package/dist/contracts/chat-content.d.ts +71 -2
  20. package/dist/contracts/chat-content.js +22 -3
  21. package/dist/contracts/chat-tables.d.ts +11 -4
  22. package/dist/contracts/chat-tables.js +165 -87
  23. package/dist/contracts/discord-callback.js +2 -1
  24. package/dist/contracts/slack-callback.js +3 -2
  25. package/dist/contracts/store-json.d.ts +1 -5
  26. package/dist/contracts/store-json.js +1 -4
  27. package/dist/contracts/teams-callback.js +2 -1
  28. package/dist/discord-chat.js +19 -38
  29. package/dist/discord-settings.js +4 -13
  30. package/dist/discord.js +4 -14
  31. package/dist/export.js +4 -4
  32. package/dist/mate-tools.js +3 -2
  33. package/dist/modes.d.ts +13 -1
  34. package/dist/modes.js +17 -1
  35. package/dist/notification-rows.d.ts +30 -0
  36. package/dist/notification-rows.js +102 -0
  37. package/dist/operate.js +5 -2
  38. package/dist/plane-review.js +5 -11
  39. package/dist/project-delete.js +21 -26
  40. package/dist/result-shots.js +2 -2
  41. package/dist/retention.js +1 -1
  42. package/dist/server/settings.js +5 -4
  43. package/dist/slack-api.d.ts +5 -1
  44. package/dist/slack-api.js +2 -2
  45. package/dist/slack-chat.d.ts +9 -4
  46. package/dist/slack-chat.js +29 -52
  47. package/dist/slack-settings.js +6 -15
  48. package/dist/slack.js +6 -16
  49. package/dist/store.d.ts +97 -364
  50. package/dist/store.js +207 -1531
  51. package/dist/teammate-question.js +12 -12
  52. package/dist/teams-chat.js +12 -9
  53. package/dist/teams-settings.js +2 -2
  54. package/dist/teams.js +4 -5
  55. package/dist/telegram-flow.js +5 -5
  56. package/dist/telegram.js +4 -4
  57. package/dist/toolroll-update.d.ts +10 -0
  58. package/dist/toolroll-update.js +50 -3
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
  61. package/dist/slack-state.d.ts +0 -13
  62. 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 { chatSchema, chatTables } from "./contracts/chat-tables.js";
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
- export const SCHEMA_VERSION = 115;
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
- if (preflight !== null && Math.abs(preflight) >= 72) {
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 (preflight !== null && Math.abs(preflight) >= 62) {
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 (preflight !== null && Math.abs(preflight) >= 63) {
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 (preflight !== null && Math.abs(preflight) >= 67) {
3734
- for (const table of chatTables("slack"))
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 (preflight !== null && Math.abs(preflight) >= 68) {
3739
- for (const table of chatTables("discord"))
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(chatSchema("slack"));
3762
- db.exec(chatSchema("discord"));
3763
- db.exec(chatSchema("teams"));
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
- addColumn(db, "telegram_action", "note_digest", "TEXT");
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
- addColumn(db, "bridge_lease", "push_url", "TEXT");
4959
- addColumn(db, "bridge_lease", "push_at", "TEXT");
4960
- addColumn(db, "bridge_lease", "push_problem", "TEXT");
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
- const bindings = this.db
9125
- .prepare("SELECT id FROM telegram_binding WHERE approver = ? AND revoked_at IS NULL")
9126
- .all(approver)
9127
- .map(row => Number(row["id"]));
9128
- this.db
9129
- .prepare("UPDATE telegram_binding SET revoked_at = ?, revoked_by = ? WHERE approver = ? AND revoked_at IS NULL")
9130
- .run(stamp, by, approver);
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
- /** Mint a pairing code's record. The code itself was shown once; this is its hash. */
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.db
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
- * The conditional UPDATE is the consumption: exactly one caller ever sees
15783
- * `changes = 1`, however many pollers race. A replay of the very update
15784
- * that paired (Telegram redelivers) recognizes the finished binding
15785
- * instead of failing. Success invalidates every other outstanding code —
15786
- * a code that was minted and superseded must not still open a door — and
15787
- * the partial unique index enforces one live binding per bot.
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 stamp = now.toISOString();
15792
- const { changes } = this.db
15793
- .prepare(`UPDATE telegram_pairing
15794
- SET consumed_at = ?, consumed_chat = ?, consumed_user = ?, consumed_update = ?
15795
- WHERE code_hash = ? AND consumed_at IS NULL AND expires_at > ?`)
15796
- .run(stamp, attempt.chatId, attempt.userId, attempt.updateId, attempt.codeHash, stamp);
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: false };
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 row = this.db.prepare("SELECT * FROM telegram_team_chat WHERE bot_id = ? AND chat_id = ? AND revoked_at IS NULL").get(botId, chatId);
15869
- return row === undefined ? null : readTelegramTeamChat(row);
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.db.prepare("SELECT * FROM telegram_team_chat WHERE bot_id = ? AND revoked_at IS NULL ORDER BY id").all(botId).map(readTelegramTeamChat);
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
- return this.transact(() => this.savepoint(() => {
15883
- // Check the expected collision before revoking the current selection.
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
- const { changes } = this.db.prepare("UPDATE telegram_team_chat SET revoked_at = ?, revoked_by = ? WHERE bot_id = ? AND chat_id = ? AND revoked_at IS NULL").run(now.toISOString(), by, botId, chatId);
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
- const { changes } = this.db.prepare("UPDATE telegram_team_chat SET cursor = ? WHERE id = ? AND cursor < ? AND revoked_at IS NULL").run(messageId, id, messageId);
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
- * approver's credential generation still matches. A rotation that
15914
- * somehow missed the sweep reads as no binding at all, which is the
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.db
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 row = this.db
15936
- .prepare(`SELECT telegram_binding.* FROM telegram_binding
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 mine = this.liveTelegramBindings(botId).filter(binding => binding.approver === by);
15955
- if (mine.length === 0)
15956
- return false;
15957
- const stamp = now.toISOString();
15958
- for (const live of mine) {
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(action, now) {
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
- /** Which decision an outbound Telegram message carried. Recorded after the
15977
- * send returns its id; a send whose record was lost routes nothing. */
15978
- recordTelegramDecisionMessage(binding, chatId, messageId, decision, now) {
15979
- this.db
15980
- .prepare(`INSERT OR IGNORE INTO telegram_decision_message (binding, chat_id, message_id, decision, created_at)
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(at, actions, now, days = 7) {
16032
- const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
16033
- const insert = this.db.prepare("INSERT INTO telegram_flow_action (token, binding, card, entry, action, chat_id, message_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)");
16034
- for (const one of actions)
16035
- insert.run(one.token, at.binding, at.card, at.entry, one.action, at.chatId, stamp, expires);
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(at, choices, now, days = 7) {
16085
- const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
16086
- const insert = this.db.prepare("INSERT INTO telegram_flow_choice (token, binding, card, entry, choice, label, chat_id, message_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
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 Telegram and in every other chat app, whichever place it was made 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 telegram_flow_choice SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
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(at, choices, now, days = 7) {
16121
- const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
16122
- const insert = this.db.prepare("INSERT INTO telegram_question_action (token, binding, question, choice, chat_id, message_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)");
16123
- for (const one of choices)
16124
- insert.run(one.token, at.binding, at.question, one.choice, at.chatId, stamp, expires);
16125
- }
16126
- getTelegramQuestionAction(token) {
16127
- if (!/^[0-9a-f]{32}$/.test(token))
16128
- return null;
16129
- const row = this.db.prepare("SELECT * FROM telegram_question_action WHERE token = ?").get(token);
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
- * Kill every live confirm/cancel challenge on a decision. Cancel means
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
- return this.transact(() => {
16194
- const stamp = now.toISOString();
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.db
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
- /** A Telegram message this bot sent that showed one task (v79): a reply to
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
- /** Forward only, and only under the live generation. A stale poller moves nothing. */
19574
- /** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
19575
- queueTelegramUpdate(botId, updateId, payload, now) {
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.db.prepare("UPDATE bridge_lease SET push_url = ?, push_problem = ?, push_at = ? WHERE bot_id = ?").run(state.url, state.problem, now.toISOString(), botId);
18856
+ this.telegramChat().setPush(botId, state, now);
19591
18857
  }
19592
18858
  telegramPush(botId) {
19593
- const row = this.db.prepare("SELECT push_url, push_problem, push_at FROM bridge_lease WHERE bot_id = ?").get(botId);
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
- const { changes } = this.db
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
- /** Persist one ordinary message before its update is acknowledged. The caller's transaction also marks the update applied. */
19612
- enqueueTelegramConversation(row, now) {
19613
- const inserted = this.db
19614
- .prepare(`INSERT INTO telegram_conversation (binding, bot_id, chat_id, user_id, approver, approver_generation, update_id, message_id, reply_to,
19615
- request, text, context, task_id, source_run, state, created_at)
19616
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?)`)
19617
- .run(row.binding.id, row.binding.botId, row.binding.chatId, row.binding.userId, row.binding.approver, row.binding.approverGeneration, row.updateId, row.messageId, row.replyTo, row.request, row.text, row.context, row.taskId, row.sourceRun, now.toISOString());
19618
- return Number(inserted.lastInsertRowid);
19619
- }
19620
- /** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
19621
- telegramConversationWaitingOn(binding, messageId) {
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
- * Persist the whole outbound half of a turn — every reply part and every
19733
- * card, with the cards' tokens already minted — in one transaction, BEFORE
19734
- * any send. Only the claim holder may, and only once: a row that already
19735
- * has parts keeps them (a retry resumes, never re-plans). The turn's
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(action, now) {
19862
- this.db
19863
- .prepare(`INSERT INTO telegram_proposal_action (token, binding, proposal, phase, chat_id, message_id, created_at, expires_at)
19864
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
19865
- .run(action.token, action.binding, action.proposal, action.phase, action.chatId, action.messageId ?? null, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString());
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(binding) {
19894
- return `telegram:${binding.botId}:${binding.chatId}:${binding.id}:${binding.approverGeneration}`;
19895
- }
19896
- /** A progress card belongs to one builder result, including its root review.
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 String(this.db.prepare("SELECT next_attempt_at FROM telegram_retry WHERE bot_id = ?").get(botId)?.["next_attempt_at"] ?? "");
18896
+ return this.telegramChat().retryAt(botId);
19967
18897
  }
19968
18898
  deferTelegram(botId, until) {
19969
- this.db.prepare(`INSERT INTO telegram_retry (bot_id, next_attempt_at) VALUES (?, ?)
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
- /** The digest cadence: null everyMs = off. */
20060
- telegramDigest() {
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(now) {
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 telegram_binding b ON d.destination = 'telegram:' || b.bot_id || ':' || b.chat_id || ':' || b.id || ':' || b.approver_generation
20439
- JOIN approver a ON a.name = b.approver AND a.generation = b.approver_generation
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.revoked_at IS NULL AND a.revoked_at IS NULL)
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 telegram_binding b ON d.destination = 'telegram:' || b.bot_id || ':' || b.chat_id || ':' || b.id || ':' || b.approver_generation
20467
- JOIN approver a ON a.name = b.approver AND a.generation = b.approver_generation
20468
- WHERE b.revoked_at IS NULL AND a.revoked_at IS NULL
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
- /** v83: a notification for another person, settled for this destination without sending. */
20834
- export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
20835
- /** A met promise the lead made on another chat: reported there, settled here without sending. */
20836
- export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
20837
- /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
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
- function readTelegramTeamChat(row) {
21029
- return {
21030
- id: Number(row["id"]),
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
- function readTelegramProposalAction(row) {
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
- token: String(row["token"]),
21115
- binding: Number(row["binding"]),
21116
- proposal: Number(row["proposal"]),
21117
- phase: String(row["phase"]),
21118
- chatId: String(row["chat_id"]),
21119
- messageId: row["message_id"] === null ? null : String(row["message_id"]),
21120
- createdAt: String(row["created_at"]),
21121
- expiresAt: row["expires_at"] === null ? null : String(row["expires_at"]),
21122
- consumedAt: row["consumed_at"] === null ? null : String(row["consumed_at"]),
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) {