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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.54 — 2026-10-09
4
+
5
+ - **One chat core.** Telegram, Slack, Discord and Teams now share one chat
6
+ core, and 70 old chat tables are gone.
7
+ - **Nothing to redo.** Chat history and pairings carry over on upgrade.
8
+ - **Consent stays put.** An existing approval consent still covers only the
9
+ chat providers it named.
10
+
3
11
  ## 0.9.53 — 2026-10-08
4
12
 
5
13
  - **Fewer features to learn.** Contests, held sessions and fallback chains
package/dist/chat-ask.js CHANGED
@@ -7,14 +7,14 @@ export function chatAskText(ask) {
7
7
  /** The live buttons on one part, in the order they were minted: the options, then "Something else". None when the part
8
8
  * is not in its owner's own chat with Toolroll (a room), where a tap could not be their message. */
9
9
  export function chatAskButtons(state, part, now) {
10
- const row = state.prepare("SELECT p.payload,e.channel,b.channel AS own FROM chat_part p JOIN chat_event e ON e.id=p.event JOIN chat_binding b ON b.id=e.binding WHERE p.id=?").get(part);
10
+ const row = state.prepare("SELECT p.payload,e.channel,b.channel AS own FROM chat_part p JOIN chat_event e ON e.provider=p.provider AND e.id=p.event JOIN chat_binding b ON b.provider=e.provider AND b.id=e.binding WHERE p.provider=:provider AND p.id=?").get(part);
11
11
  if (row === undefined)
12
12
  return [];
13
13
  const content = partContent(String(row["payload"]));
14
14
  if ((content.channel ?? row["channel"]) !== row["own"])
15
15
  return [];
16
16
  const options = content.ask?.options ?? [];
17
- return state.prepare("SELECT token,choice FROM chat_ask_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
17
+ return state.prepare("SELECT token,choice FROM chat_ask_action WHERE provider=:provider AND part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
18
18
  .map(one => {
19
19
  const label = one.choice === null ? MATE_ASK_OTHER : options[Number(one.choice)] ?? null;
20
20
  return label === null ? null : { token: String(one.token), label: label.slice(0, 75), words: one.choice === null };
@@ -27,20 +27,20 @@ const EDIT_WINDOW_MS = 86_400_000;
27
27
  /** Show the question again with what happened, and without its buttons: one edit, under the part's own time. A question
28
28
  * too old to edit gets the line once as a new message instead. */
29
29
  function repaint(state, part, event, line, now) {
30
- const row = state.prepare("SELECT payload,created FROM chat_part WHERE id=?").get(part);
30
+ const row = state.prepare("SELECT payload,created FROM chat_part WHERE provider=:provider AND id=?").get(part);
31
31
  if (row === undefined || new Date(String(row["created"])).getTime() + EDIT_WINDOW_MS <= now.getTime()) {
32
32
  state.plan(event.id, [{ text: line }], now);
33
33
  return;
34
34
  }
35
35
  const before = partContent(String(row["payload"]));
36
36
  const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.channel === undefined ? {} : { channel: before.channel }) };
37
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(savedChatPart(content), part);
37
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?").run(savedChatPart(content), part);
38
38
  state.finish(event.id);
39
39
  }
40
40
  /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
41
41
  export function applyChatAskTap(options, event, binding, token, now) {
42
42
  const { store, state } = options;
43
- const action = state.prepare("SELECT a.*,p.message,e.binding AS owner,e.channel FROM chat_ask_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE a.token=?").get(token);
43
+ const action = state.prepare("SELECT a.token,a.part,a.turn,a.choice,a.expires,a.consumed,p.message,e.binding AS owner,e.channel FROM chat_ask_action a JOIN chat_part p ON p.provider=a.provider AND p.id=a.part JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE a.provider=:provider AND a.token=?").get(token);
44
44
  if (action === undefined)
45
45
  return false;
46
46
  const say = (text) => state.plan(event.id, [{ text }], now);
@@ -64,7 +64,7 @@ export function applyChatAskTap(options, event, binding, token, now) {
64
64
  return true;
65
65
  }
66
66
  if (found.state !== "open" || expired) {
67
- state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
67
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE provider=:provider AND part=? AND consumed IS NULL").run(now.toISOString(), part);
68
68
  // The question is shown again without its buttons, so a later tap has nothing to press.
69
69
  repaint(state, part, event, found.state === "answered" ? "This question was already answered." : "This question expired. If it still matters, send your answer as a message.", now);
70
70
  return true;
@@ -79,10 +79,10 @@ export function applyChatAskTap(options, event, binding, token, now) {
79
79
  say("That option is no longer there.");
80
80
  return true;
81
81
  }
82
- state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
82
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE provider=:provider AND part=? AND consumed IS NULL").run(now.toISOString(), part);
83
83
  // The option is the owner's next message, answered like one they typed.
84
84
  state.enqueue({
85
- id: chatHash(`${state.channel}:ask:${event.id}`).slice(0, 32), installation: event.installation, binding: binding.id, kind: "message",
85
+ id: chatHash(`${state.provider}:ask:${event.id}`).slice(0, 32), installation: event.installation, binding: binding.id, kind: "message",
86
86
  channel: event.channel, member: event.member, ts: event.ts, thread: event.thread,
87
87
  payload: { text: option, originalLength: option.length }, created: now.toISOString(),
88
88
  });
@@ -0,0 +1,171 @@
1
+ import type { ChatProvider } from "./contracts/chat-tables.js";
2
+ import type { Database, Statement, Store } from "./store.js";
3
+ export type { ChatProvider };
4
+ /** One person's chat in one app (chat_binding). */
5
+ export type ChatBinding = {
6
+ provider: ChatProvider;
7
+ id: number;
8
+ installation: string;
9
+ team: string;
10
+ app: string;
11
+ member: string;
12
+ channel: string;
13
+ approver: string;
14
+ generation: number;
15
+ created: string;
16
+ created_by: string | null;
17
+ pair_event: string | null;
18
+ revoked: string | null;
19
+ revoked_by: string | null;
20
+ };
21
+ export type ChatRoom = {
22
+ id: number;
23
+ installation: string;
24
+ chat: string;
25
+ kind: "group" | "private";
26
+ conversation: string;
27
+ boundBy: string;
28
+ boundAt: string;
29
+ binding: number;
30
+ cursor: number;
31
+ };
32
+ export type PairingResult = {
33
+ ok: true;
34
+ binding: ChatBinding;
35
+ replay: boolean;
36
+ first: boolean;
37
+ } | {
38
+ ok: false;
39
+ reason: "unknown-code" | "already-bound";
40
+ };
41
+ export type LeaseResult = {
42
+ ok: true;
43
+ generation: number;
44
+ cursor: number;
45
+ } | {
46
+ ok: false;
47
+ reason: "busy";
48
+ holder: string;
49
+ until: string;
50
+ };
51
+ export declare const chatHash: (text: string) => string;
52
+ export declare const PAIRING_CODE_TTL_MS: number;
53
+ /** Tables whose rows carry a per-provider number the core assigns. */
54
+ type Numbered = "chat_binding" | "chat_part" | "chat_room" | "chat_flow_prompt" | "chat_question_prompt" | "chat_note_draft";
55
+ export declare function readChatBinding(row: Record<string, unknown>): ChatBinding;
56
+ export declare class ChatCore {
57
+ readonly store: Store;
58
+ readonly provider: ChatProvider;
59
+ constructor(store: Store, provider: ChatProvider);
60
+ get db(): Database;
61
+ /** A statement bound to this provider. Its SQL must name `:provider`; one that doesn't is refused before it runs. */
62
+ prepare(sql: string): Statement;
63
+ /** The next number in this provider's run of a table (rows keep the numbers they were given). */
64
+ nextId(table: Numbered): number;
65
+ /** Every unrevoked binding of an installation, oldest first (liveness is checked per binding with `live`). */
66
+ bindings(installation: string): ChatBinding[];
67
+ /** The unrevoked binding one member holds, if any. */
68
+ bindingFor(installation: string, member: string): ChatBinding | null;
69
+ /** The exact binding, while unrevoked. */
70
+ bindingById(id: number): ChatBinding | null;
71
+ /** The oldest unrevoked binding: the single-person reading kept for status lines and fixtures. */
72
+ binding(installation: string): ChatBinding | null;
73
+ /** Unrevoked, and its person still an approver, unrevoked, at the generation it was paired under. */
74
+ live(binding: Pick<ChatBinding, "id" | "approver" | "generation">): boolean;
75
+ /** Every live binding of an installation, oldest first. */
76
+ liveBindings(installation: string): ChatBinding[];
77
+ /** Mint-time half of pairing: a fresh code replaces this person's outstanding ones (a teammate's stay theirs). */
78
+ savePairing(code: {
79
+ hash: string;
80
+ installation: string | null;
81
+ approver: string;
82
+ generation: number;
83
+ by: string;
84
+ }, now: Date, ttlMs?: number): void;
85
+ /**
86
+ * Consume a code and create the binding, in one transaction, by the same rules in every app: the code is unexpired,
87
+ * unconsumed and for this installation; its person is still an approver at the generation it was minted under; the
88
+ * member holds no other live pairing here, and the person no other live pairing on this installation. The
89
+ * conditional UPDATE is the consumption: exactly one caller wins however many race. The same event replayed (an app
90
+ * redelivering) finds its finished binding. Success retires the person's other outstanding codes. `first`: the
91
+ * member never paired with this installation before (a first pairing starts its notifications from now).
92
+ */
93
+ consumePairing(attempt: {
94
+ hash: string;
95
+ installation: string;
96
+ team: string;
97
+ app: string;
98
+ member: string;
99
+ channel: string;
100
+ event: string | null;
101
+ }, now: Date): PairingResult;
102
+ /** End one person's pairing and everything their chat could still do: its buttons, prompts, queued messages and unsent parts. */
103
+ revokeBinding(binding: Pick<ChatBinding, "id">, now?: Date, by?: string | null): boolean;
104
+ /** End every pairing of an installation (its app was disconnected), and every code still waiting for one. */
105
+ revoke(installation: string, now?: Date, by?: string | null): void;
106
+ room(installation: string, chat: string): ChatRoom | null;
107
+ rooms(installation: string): ChatRoom[];
108
+ /**
109
+ * Point a chat at a team conversation: an earlier choice for the same chat is revoked in the same transaction, and
110
+ * delivery starts from now (the conversation's history is read in the console, never replayed into a chat that
111
+ * just arrived). A conversation another group of this app already follows refuses.
112
+ */
113
+ bindRoom(args: {
114
+ installation: string;
115
+ chat: string;
116
+ kind: "group" | "private";
117
+ conversation: string;
118
+ by: string;
119
+ binding: number;
120
+ }, now: Date): {
121
+ ok: true;
122
+ room: ChatRoom;
123
+ } | {
124
+ ok: false;
125
+ reason: "group-taken";
126
+ };
127
+ unbindRoom(installation: string, chat: string, by: string, now: Date): boolean;
128
+ /** Forward only: a cursor never moves back, and a revoked room keeps its last position. */
129
+ advanceRoomCursor(id: number, messageId: number): boolean;
130
+ /** The shared rooms engine's view of this installation's rooms (chat-rooms.ts). */
131
+ roomBackend(installation: string, now: Date): import("./chat-rooms.js").RoomBackend;
132
+ /**
133
+ * Take or renew the installation's one worker lease. A holder renews at its generation; an expired holder is taken
134
+ * over at the next generation, and the poll cursor rides the lease, so a stale generation can neither poll nor move it.
135
+ */
136
+ acquire(installation: string, owner: string, ttlMs: number, now: Date): LeaseResult;
137
+ /** Hand the lease back (it ends now), so the next worker is not told busy for the rest of its time. */
138
+ release(installation: string, owner: string, now: Date): void;
139
+ owns(installation: string, owner: string, now?: Date): boolean;
140
+ /** The poll cursor moves forward only, and only under the generation that read it. */
141
+ advanceCursor(installation: string, owner: string, generation: number, cursor: number, now: Date): boolean;
142
+ /** When the app said to wait (a rate limit): "" when it hasn't. */
143
+ retryAt(installation: string): string;
144
+ /** Wait at least until then before the next send; a later wait already asked for stands. */
145
+ deferUntil(installation: string, until: string): void;
146
+ /** What is wrong with the connection right now (null: nothing), as the lease holder last saw it. */
147
+ setProblem(installation: string, owner: string, problem: string | null): void;
148
+ /** The worker reached the app. */
149
+ setConnected(installation: string, owner: string, now: Date): void;
150
+ /** The worker stopped: no holder, no connection. */
151
+ stop(installation: string, owner: string): void;
152
+ /** The connection as settings show it. */
153
+ runtime(installation: string): {
154
+ connected: string | null;
155
+ problem: string | null;
156
+ leaseUntil: string | null;
157
+ } | null;
158
+ /** Where the app pushes this installation's updates (null: the worker asks for them), and what's wrong, if anything. */
159
+ setPush(installation: string, push: {
160
+ url: string | null;
161
+ problem: string | null;
162
+ }, now: Date): void;
163
+ push(installation: string): {
164
+ url: string | null;
165
+ problem: string | null;
166
+ at: string | null;
167
+ } | null;
168
+ /** Small transport facts an app must remember per chat (a Teams service URL). Never credentials. */
169
+ meta(installation: string, key: string): string | null;
170
+ setMeta(installation: string, key: string, value: string, now: Date): void;
171
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The chat core every app shares (Telegram, Slack, Discord, Teams): who is paired, the one-time codes that pair
3
+ * them, the rooms they follow, one live worker per installation, and the small facts a transport remembers. One
4
+ * object is bound to one provider, and every query it runs names that provider (`:provider`), so an id or a member
5
+ * from one app never reaches another app's rows. Adapters keep only their transport: sign-in, receiving (webhooks or
6
+ * polling), formatting and sending.
7
+ *
8
+ * Imports the store's types only, so the store can build its Telegram methods on it.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ export const chatHash = (text) => createHash("sha256").update(text).digest("hex");
12
+ export const PAIRING_CODE_TTL_MS = 10 * 60_000;
13
+ const str = (value) => (value === null || value === undefined ? null : String(value));
14
+ export function readChatBinding(row) {
15
+ return {
16
+ provider: String(row["provider"]), id: Number(row["id"]), installation: String(row["installation"]),
17
+ team: String(row["team"]), app: String(row["app"]), member: String(row["member"]), channel: String(row["channel"]),
18
+ approver: String(row["approver"]), generation: Number(row["generation"]), created: String(row["created"]),
19
+ created_by: str(row["created_by"]), pair_event: str(row["pair_event"]), revoked: str(row["revoked"]), revoked_by: str(row["revoked_by"]),
20
+ };
21
+ }
22
+ function readRoom(row) {
23
+ return { id: Number(row["id"]), installation: String(row["installation"]), chat: String(row["chat"]), kind: row["kind"] === "group" ? "group" : "private",
24
+ conversation: String(row["conversation"]), boundBy: String(row["bound_by"]), boundAt: String(row["bound"]), binding: Number(row["binding"]), cursor: Number(row["cursor"]) };
25
+ }
26
+ export class ChatCore {
27
+ store;
28
+ provider;
29
+ constructor(store, provider) {
30
+ this.store = store;
31
+ this.provider = provider;
32
+ }
33
+ get db() {
34
+ return this.store.handle;
35
+ }
36
+ /** A statement bound to this provider. Its SQL must name `:provider`; one that doesn't is refused before it runs. */
37
+ prepare(sql) {
38
+ if (!/:provider\b/.test(sql))
39
+ throw new Error(`a chat query must name its provider: ${sql.slice(0, 80)}`);
40
+ const statement = this.db.prepare(sql), bound = { provider: this.provider };
41
+ return {
42
+ run: (...params) => statement.run(bound, ...params),
43
+ get: (...params) => statement.get(bound, ...params),
44
+ all: (...params) => statement.all(bound, ...params),
45
+ };
46
+ }
47
+ /** The next number in this provider's run of a table (rows keep the numbers they were given). */
48
+ nextId(table) {
49
+ return Number(this.prepare(`SELECT COALESCE(MAX(id), 0) + 1 AS n FROM ${table} WHERE provider = :provider`).get()?.["n"] ?? 1);
50
+ }
51
+ // ---- pairings ---------------------------------------------------------------------------------------------------
52
+ /** Every unrevoked binding of an installation, oldest first (liveness is checked per binding with `live`). */
53
+ bindings(installation) {
54
+ return this.prepare("SELECT * FROM chat_binding WHERE provider = :provider AND installation = ? AND revoked IS NULL ORDER BY id")
55
+ .all(installation).map(readChatBinding);
56
+ }
57
+ /** The unrevoked binding one member holds, if any. */
58
+ bindingFor(installation, member) {
59
+ const row = this.prepare("SELECT * FROM chat_binding WHERE provider = :provider AND installation = ? AND member = ? AND revoked IS NULL").get(installation, member);
60
+ return row === undefined ? null : readChatBinding(row);
61
+ }
62
+ /** The exact binding, while unrevoked. */
63
+ bindingById(id) {
64
+ const row = this.prepare("SELECT * FROM chat_binding WHERE provider = :provider AND id = ? AND revoked IS NULL").get(id);
65
+ return row === undefined ? null : readChatBinding(row);
66
+ }
67
+ /** The oldest unrevoked binding: the single-person reading kept for status lines and fixtures. */
68
+ binding(installation) {
69
+ return this.bindings(installation)[0] ?? null;
70
+ }
71
+ /** Unrevoked, and its person still an approver, unrevoked, at the generation it was paired under. */
72
+ live(binding) {
73
+ const current = this.bindingById(binding.id);
74
+ const account = this.store.accountOf(binding.approver);
75
+ return current !== null && current.approver === binding.approver && current.generation === binding.generation &&
76
+ account?.role === "approver" && account.revokedAt === null && account.generation === binding.generation;
77
+ }
78
+ /** Every live binding of an installation, oldest first. */
79
+ liveBindings(installation) {
80
+ return this.bindings(installation).filter(binding => this.live(binding));
81
+ }
82
+ /** Mint-time half of pairing: a fresh code replaces this person's outstanding ones (a teammate's stay theirs). */
83
+ savePairing(code, now, ttlMs = PAIRING_CODE_TTL_MS) {
84
+ this.store.transact(() => {
85
+ this.prepare("UPDATE chat_pair SET consumed = ? WHERE provider = :provider AND approver = ? AND consumed IS NULL").run(now.toISOString(), code.approver);
86
+ this.prepare("INSERT INTO chat_pair (provider, hash, installation, approver, generation, created, created_by, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?)")
87
+ .run(code.hash, code.installation, code.approver, code.generation, now.toISOString(), code.by, new Date(now.getTime() + ttlMs).toISOString());
88
+ });
89
+ }
90
+ /**
91
+ * Consume a code and create the binding, in one transaction, by the same rules in every app: the code is unexpired,
92
+ * unconsumed and for this installation; its person is still an approver at the generation it was minted under; the
93
+ * member holds no other live pairing here, and the person no other live pairing on this installation. The
94
+ * conditional UPDATE is the consumption: exactly one caller wins however many race. The same event replayed (an app
95
+ * redelivering) finds its finished binding. Success retires the person's other outstanding codes. `first`: the
96
+ * member never paired with this installation before (a first pairing starts its notifications from now).
97
+ */
98
+ consumePairing(attempt, now) {
99
+ return this.store.transact(() => {
100
+ const stamp = now.toISOString();
101
+ const code = this.prepare("SELECT * FROM chat_pair WHERE provider = :provider AND hash = ?").get(attempt.hash);
102
+ if (code === undefined || (code["installation"] !== null && code["installation"] !== attempt.installation))
103
+ return { ok: false, reason: "unknown-code" };
104
+ if (code["consumed"] !== null) {
105
+ const mine = attempt.event !== null && code["consumed_event"] === attempt.event && code["consumed_member"] === attempt.member && code["consumed_channel"] === attempt.channel;
106
+ const live = mine ? this.bindingFor(attempt.installation, attempt.member) : null;
107
+ return live !== null && live.channel === attempt.channel && this.live(live)
108
+ ? { ok: true, binding: live, replay: true, first: false }
109
+ : { ok: false, reason: "unknown-code" };
110
+ }
111
+ if (String(code["expires"]) <= stamp)
112
+ return { ok: false, reason: "unknown-code" };
113
+ const approver = String(code["approver"]), generation = Number(code["generation"]);
114
+ const account = this.store.accountOf(approver);
115
+ if (account?.role !== "approver" || account.revokedAt !== null || account.generation !== generation)
116
+ return { ok: false, reason: "unknown-code" };
117
+ if (this.bindingFor(attempt.installation, attempt.member) !== null)
118
+ return { ok: false, reason: "already-bound" };
119
+ if (this.bindings(attempt.installation).some(one => one.approver === approver && this.live(one)))
120
+ return { ok: false, reason: "already-bound" };
121
+ const consumed = this.prepare(`UPDATE chat_pair SET consumed = ?, consumed_channel = ?, consumed_member = ?, consumed_event = ?
122
+ WHERE provider = :provider AND hash = ? AND consumed IS NULL AND expires > ?`).run(stamp, attempt.channel, attempt.member, attempt.event, attempt.hash, stamp);
123
+ if (Number(consumed.changes) !== 1)
124
+ return { ok: false, reason: "unknown-code" };
125
+ this.prepare("UPDATE chat_pair SET consumed = ? WHERE provider = :provider AND approver = ? AND consumed IS NULL").run(stamp, approver);
126
+ const first = this.prepare("SELECT 1 AS hit FROM chat_binding WHERE provider = :provider AND installation = ? AND member = ? LIMIT 1").get(attempt.installation, attempt.member) === undefined;
127
+ const id = this.nextId("chat_binding");
128
+ this.prepare(`INSERT INTO chat_binding (provider, id, installation, team, app, member, channel, approver, generation, created, created_by, pair_event)
129
+ VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
130
+ .run(id, attempt.installation, attempt.team, attempt.app, attempt.member, attempt.channel, approver, generation, stamp, approver, attempt.event);
131
+ return { ok: true, binding: this.bindingById(id), replay: false, first };
132
+ });
133
+ }
134
+ /** End one person's pairing and everything their chat could still do: its buttons, prompts, queued messages and unsent parts. */
135
+ revokeBinding(binding, now = new Date(), by = null) {
136
+ return this.store.transact(() => {
137
+ const stamp = now.toISOString();
138
+ const revoked = Number(this.prepare("UPDATE chat_binding SET revoked = ?, revoked_by = ? WHERE provider = :provider AND id = ? AND revoked IS NULL").run(stamp, by, binding.id).changes) > 0;
139
+ this.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND binding = ? AND consumed IS NULL").run(stamp, binding.id);
140
+ for (const table of ["chat_flow_action", "chat_flow_choice", "chat_question_action"])
141
+ this.prepare(`UPDATE ${table} SET consumed = ? WHERE provider = :provider AND binding = ? AND consumed IS NULL`).run(stamp, binding.id);
142
+ for (const table of ["chat_flow_prompt", "chat_question_prompt"])
143
+ this.prepare(`UPDATE ${table} SET consumed = ? WHERE provider = :provider AND binding = ? AND consumed IS NULL`).run(stamp, binding.id);
144
+ this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, 'revoked') WHERE provider = :provider AND binding = ? AND state = 'queued'").run(binding.id);
145
+ this.prepare(`UPDATE chat_part SET state = 'dropped', problem = 'Chat disconnected' WHERE provider = :provider AND state = 'pending'
146
+ AND event IN (SELECT id FROM chat_event WHERE provider = :provider AND binding = ?)`).run(binding.id);
147
+ return revoked;
148
+ });
149
+ }
150
+ /** End every pairing of an installation (its app was disconnected), and every code still waiting for one. */
151
+ revoke(installation, now = new Date(), by = null) {
152
+ this.store.transact(() => {
153
+ for (const binding of this.bindings(installation))
154
+ this.revokeBinding(binding, now, by);
155
+ this.prepare("UPDATE chat_pair SET consumed = ? WHERE provider = :provider AND (installation = ? OR installation IS NULL) AND consumed IS NULL").run(now.toISOString(), installation);
156
+ this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, 'revoked') WHERE provider = :provider AND installation = ? AND state = 'queued' AND kind <> 'update'").run(installation);
157
+ this.prepare(`UPDATE chat_part SET state = 'dropped', problem = 'Chat disconnected' WHERE provider = :provider AND state = 'pending'
158
+ AND event IN (SELECT id FROM chat_event WHERE provider = :provider AND installation = ?)`).run(installation);
159
+ });
160
+ }
161
+ // ---- rooms (team conversations) ---------------------------------------------------------------------------------
162
+ room(installation, chat) {
163
+ const row = this.prepare("SELECT * FROM chat_room WHERE provider = :provider AND installation = ? AND chat = ? AND revoked IS NULL").get(installation, chat);
164
+ return row === undefined ? null : readRoom(row);
165
+ }
166
+ rooms(installation) {
167
+ return this.prepare("SELECT * FROM chat_room WHERE provider = :provider AND installation = ? AND revoked IS NULL ORDER BY id").all(installation).map(readRoom);
168
+ }
169
+ /**
170
+ * Point a chat at a team conversation: an earlier choice for the same chat is revoked in the same transaction, and
171
+ * delivery starts from now (the conversation's history is read in the console, never replayed into a chat that
172
+ * just arrived). A conversation another group of this app already follows refuses.
173
+ */
174
+ bindRoom(args, now) {
175
+ return this.store.transact(() => this.store.savepoint(() => {
176
+ const stamp = now.toISOString();
177
+ if (args.kind === "group") {
178
+ const group = this.prepare("SELECT installation, chat FROM chat_room WHERE provider = :provider AND conversation = ? AND kind = 'group' AND revoked IS NULL").get(args.conversation);
179
+ if (group !== undefined && (group["installation"] !== args.installation || group["chat"] !== args.chat))
180
+ return { ok: false, reason: "group-taken" };
181
+ }
182
+ this.prepare("UPDATE chat_room SET revoked = ?, revoked_by = ? WHERE provider = :provider AND installation = ? AND chat = ? AND revoked IS NULL").run(stamp, args.by, args.installation, args.chat);
183
+ const thread = this.db.prepare("SELECT thread FROM team_conversation WHERE id = ?").get(args.conversation);
184
+ if (thread === undefined)
185
+ throw new Error("no such team conversation");
186
+ const cursor = Number(this.db.prepare("SELECT COALESCE(MAX(id), 0) AS n FROM mate_message WHERE thread = ?").get(Number(thread["thread"]))?.["n"] ?? 0);
187
+ this.prepare("INSERT INTO chat_room (provider, id, installation, chat, kind, conversation, binding, bound_by, bound, cursor) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
188
+ .run(this.nextId("chat_room"), args.installation, args.chat, args.kind, args.conversation, args.binding, args.by, stamp, cursor);
189
+ return { ok: true, room: this.room(args.installation, args.chat) };
190
+ }));
191
+ }
192
+ unbindRoom(installation, chat, by, now) {
193
+ return Number(this.prepare("UPDATE chat_room SET revoked = ?, revoked_by = ? WHERE provider = :provider AND installation = ? AND chat = ? AND revoked IS NULL").run(now.toISOString(), by, installation, chat).changes) > 0;
194
+ }
195
+ /** Forward only: a cursor never moves back, and a revoked room keeps its last position. */
196
+ advanceRoomCursor(id, messageId) {
197
+ return Number(this.prepare("UPDATE chat_room SET cursor = ? WHERE provider = :provider AND id = ? AND cursor < ? AND revoked IS NULL").run(messageId, id, messageId).changes) > 0;
198
+ }
199
+ /** The shared rooms engine's view of this installation's rooms (chat-rooms.ts). */
200
+ roomBackend(installation, now) {
201
+ const roomOf = (room) => room === null ? null : { id: room.id, chatId: room.chat, kind: room.kind, conversation: room.conversation, boundBy: room.boundBy, binding: room.binding, cursor: room.cursor };
202
+ return {
203
+ room: chat => roomOf(this.room(installation, chat)),
204
+ bind: args => {
205
+ const bound = this.bindRoom({ installation, chat: args.chatId, kind: args.kind, conversation: args.conversation, by: args.by, binding: args.binding }, now);
206
+ return bound.ok ? { ok: true } : bound;
207
+ },
208
+ unbind: (chat, by) => this.unbindRoom(installation, chat, by, now),
209
+ grant: id => { const binding = this.bindingById(id); return binding === null || !this.live(binding) ? null : { approver: binding.approver, generation: binding.generation, chatId: binding.channel }; },
210
+ };
211
+ }
212
+ // ---- the worker -------------------------------------------------------------------------------------------------
213
+ /**
214
+ * Take or renew the installation's one worker lease. A holder renews at its generation; an expired holder is taken
215
+ * over at the next generation, and the poll cursor rides the lease, so a stale generation can neither poll nor move it.
216
+ */
217
+ acquire(installation, owner, ttlMs, now) {
218
+ return this.store.transact(() => {
219
+ const stamp = now.toISOString(), until = new Date(now.getTime() + ttlMs).toISOString();
220
+ const row = this.prepare("SELECT * FROM chat_runtime WHERE provider = :provider AND installation = ?").get(installation);
221
+ if (row === undefined) {
222
+ this.prepare("INSERT INTO chat_runtime (provider, installation, owner, lease_until, generation, heartbeat) VALUES (:provider, ?, ?, ?, 1, ?)").run(installation, owner, until, stamp);
223
+ return { ok: true, generation: 1, cursor: 0 };
224
+ }
225
+ const holder = str(row["owner"]), held = holder !== null && str(row["lease_until"]) !== null && String(row["lease_until"]) > stamp;
226
+ if (held && holder !== owner)
227
+ return { ok: false, reason: "busy", holder: holder, until: String(row["lease_until"]) };
228
+ const generation = held ? Number(row["generation"]) : Number(row["generation"]) + 1;
229
+ this.prepare("UPDATE chat_runtime SET owner = ?, lease_until = ?, generation = ?, heartbeat = ? WHERE provider = :provider AND installation = ?").run(owner, until, generation, stamp, installation);
230
+ return { ok: true, generation, cursor: Number(row["cursor"]) };
231
+ });
232
+ }
233
+ /** Hand the lease back (it ends now), so the next worker is not told busy for the rest of its time. */
234
+ release(installation, owner, now) {
235
+ this.prepare("UPDATE chat_runtime SET lease_until = ? WHERE provider = :provider AND installation = ? AND owner = ?").run(now.toISOString(), installation, owner);
236
+ }
237
+ owns(installation, owner, now = new Date()) {
238
+ return this.prepare("SELECT 1 AS hit FROM chat_runtime WHERE provider = :provider AND installation = ? AND owner = ? AND lease_until > ?").get(installation, owner, now.toISOString()) !== undefined;
239
+ }
240
+ /** The poll cursor moves forward only, and only under the generation that read it. */
241
+ advanceCursor(installation, owner, generation, cursor, now) {
242
+ return Number(this.prepare(`UPDATE chat_runtime SET cursor = ?, heartbeat = ? WHERE provider = :provider AND installation = ? AND owner = ? AND generation = ? AND cursor < ?`)
243
+ .run(cursor, now.toISOString(), installation, owner, generation, cursor).changes) > 0;
244
+ }
245
+ /** When the app said to wait (a rate limit): "" when it hasn't. */
246
+ retryAt(installation) {
247
+ return String(this.prepare("SELECT retry_at FROM chat_runtime WHERE provider = :provider AND installation = ?").get(installation)?.["retry_at"] ?? "");
248
+ }
249
+ /** Wait at least until then before the next send; a later wait already asked for stands. */
250
+ deferUntil(installation, until) {
251
+ this.prepare(`INSERT INTO chat_runtime (provider, installation, retry_at) VALUES (:provider, ?, ?)
252
+ ON CONFLICT (provider, installation) DO UPDATE SET retry_at = CASE WHEN retry_at IS NULL OR retry_at < excluded.retry_at THEN excluded.retry_at ELSE retry_at END`).run(installation, until);
253
+ }
254
+ /** What is wrong with the connection right now (null: nothing), as the lease holder last saw it. */
255
+ setProblem(installation, owner, problem) {
256
+ this.prepare("UPDATE chat_runtime SET problem = ? WHERE provider = :provider AND installation = ? AND owner = ?").run(problem, installation, owner);
257
+ }
258
+ /** The worker reached the app. */
259
+ setConnected(installation, owner, now) {
260
+ this.prepare("UPDATE chat_runtime SET connected = ?, problem = NULL WHERE provider = :provider AND installation = ? AND owner = ?").run(now.toISOString(), installation, owner);
261
+ }
262
+ /** The worker stopped: no holder, no connection. */
263
+ stop(installation, owner) {
264
+ this.prepare("UPDATE chat_runtime SET owner = NULL, lease_until = NULL, connected = NULL WHERE provider = :provider AND installation = ? AND owner = ?").run(installation, owner);
265
+ }
266
+ /** The connection as settings show it. */
267
+ runtime(installation) {
268
+ const row = this.prepare("SELECT connected, problem, lease_until FROM chat_runtime WHERE provider = :provider AND installation = ?").get(installation);
269
+ return row === undefined ? null : { connected: str(row["connected"]), problem: str(row["problem"]), leaseUntil: str(row["lease_until"]) };
270
+ }
271
+ /** Where the app pushes this installation's updates (null: the worker asks for them), and what's wrong, if anything. */
272
+ setPush(installation, push, now) {
273
+ this.prepare("UPDATE chat_runtime SET push_url = ?, push_problem = ?, push_at = ? WHERE provider = :provider AND installation = ?").run(push.url, push.problem, now.toISOString(), installation);
274
+ }
275
+ push(installation) {
276
+ const row = this.prepare("SELECT push_url, push_problem, push_at, owner FROM chat_runtime WHERE provider = :provider AND installation = ?").get(installation);
277
+ return row === undefined || row["owner"] === null && row["push_at"] === null ? null : { url: str(row["push_url"]), problem: str(row["push_problem"]), at: str(row["push_at"]) };
278
+ }
279
+ // ---- small facts ------------------------------------------------------------------------------------------------
280
+ /** Small transport facts an app must remember per chat (a Teams service URL). Never credentials. */
281
+ meta(installation, key) {
282
+ return str(this.prepare("SELECT value FROM chat_meta WHERE provider = :provider AND installation = ? AND key = ?").get(installation, key)?.["value"]);
283
+ }
284
+ setMeta(installation, key, value, now) {
285
+ this.prepare(`INSERT INTO chat_meta (provider, installation, key, value, updated) VALUES (:provider, ?, ?, ?, ?)
286
+ ON CONFLICT (provider, installation, key) DO UPDATE SET value = excluded.value, updated = excluded.updated`).run(installation, key, value, now.toISOString());
287
+ }
288
+ }
@@ -9,8 +9,9 @@
9
9
  * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
10
  * card keeps its link.
11
11
  *
12
- * Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
13
- * tokens (the `channel` column). Every act runs through the door the console uses. */
12
+ * The same in every chat app: Telegram's bridge and the shared engine Slack, Discord and Teams run on (chat-delivery.ts)
13
+ * offer, mint and answer these through the same tokens (the `channel` column). Every act runs through the door the
14
+ * console uses. */
14
15
  import { DECIDE_ACTS } from "./contracts/chat-callback-rows.js";
15
16
  import { type ChatApprovalLimits } from "./chat-approval.js";
16
17
  import { type VerifiedApprover } from "./principal.js";
@@ -78,10 +79,11 @@ export type DecideOffer = {
78
79
  text: string | null;
79
80
  rows: Array<Array<ActSpec | DecideLink>>;
80
81
  };
81
- /** What lets this person's chat approve on this repository now: their lasting chat-approval setting (chat-approval.ts),
82
- * or else a live signed mode carrying the chatApprove term, signed by them. `digest` names that authority for the
82
+ /** What lets this person's chat on `channel` approve on this repository now: their lasting chat-approval setting
83
+ * (chat-approval.ts), or else a live signed mode carrying the chatApprove term, signed by them, that names this chat
84
+ * app (a mode signed before the term named its apps covers Telegram alone). `digest` names that authority for the
83
85
  * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
84
- export declare function chatApproveMode(store: Store, repo: string | null, approver: string, now: Date): {
86
+ export declare function chatApproveMode(store: Store, repo: string | null, approver: string, now: Date, channel: DecideChannel): {
85
87
  ok: true;
86
88
  digest: string;
87
89
  limits: ChatApprovalLimits;
@@ -94,7 +96,7 @@ export declare function chatApproveMode(store: Store, repo: string | null, appro
94
96
  export declare function permissionPlainWords(profile: Scope["profile"] | null | undefined): string | null;
95
97
  /** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
96
98
  * Toolroll instead. `digest` binds the scope and the plan document the card shows. */
97
- export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, root?: string): {
99
+ export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel, root?: string): {
98
100
  ok: true;
99
101
  modeDigest: string;
100
102
  source: "setting" | "mode";
@@ -105,7 +107,7 @@ export declare function planInChat(store: Store, taskId: string, approver: strin
105
107
  why: string;
106
108
  };
107
109
  /** A ready pull request this person's chat may merge now, bound to the exact commit. */
108
- export declare function mergeInChat(store: Store, taskId: string, run: number, approver: string, now: Date): {
110
+ export declare function mergeInChat(store: Store, taskId: string, run: number, approver: string, now: Date, channel: DecideChannel): {
109
111
  ok: true;
110
112
  modeDigest: string;
111
113
  source: "setting" | "mode";
@@ -119,7 +121,7 @@ export declare function mergeInChat(store: Store, taskId: string, run: number, a
119
121
  export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string, channel?: DecideChannel): string;
120
122
  /** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
121
123
  * link). Reads only; nothing is minted. */
122
- export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, root?: string, channel?: DecideChannel): DecideOffer | null;
124
+ export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, channel: DecideChannel, root?: string): DecideOffer | null;
123
125
  /** One fingerprint of what the card offers, without its tokens: a repaint with the same offer keeps its buttons. */
124
126
  export declare function offerFingerprint(offer: DecideOffer): string;
125
127
  /** The card's buttons, minted now (message unknown until it is sent: `placeDecideTokens` puts them on it). */