toolroll 0.9.53 → 0.9.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/dist/agent-onboard.js +4 -1
- package/dist/approval-rules-ui.d.ts +3 -2
- package/dist/approval-rules-ui.js +15 -20
- package/dist/assignment-ui.d.ts +3 -2
- package/dist/assignment-ui.js +4 -11
- package/dist/backup-ui.d.ts +2 -1
- package/dist/backup-ui.js +16 -27
- package/dist/brand-mark.d.ts +3 -2
- package/dist/brand-mark.js +4 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-shell.d.ts +2 -1
- package/dist/browser-shell.js +9 -9
- package/dist/browser-workspace.d.ts +2 -3
- package/dist/browser-workspace.js +0 -3
- package/dist/chat-ask.js +8 -8
- package/dist/chat-core.d.ts +171 -0
- package/dist/chat-core.js +288 -0
- package/dist/chat-decide.d.ts +10 -8
- package/dist/chat-decide.js +26 -21
- package/dist/chat-delivery-state.d.ts +51 -75
- package/dist/chat-delivery-state.js +140 -170
- package/dist/chat-delivery.d.ts +21 -2
- package/dist/chat-delivery.js +313 -194
- package/dist/chat-flow.d.ts +29 -1
- package/dist/chat-flow.js +24 -50
- package/dist/chat-messages.d.ts +319 -0
- package/dist/chat-messages.js +841 -0
- package/dist/chat-migration.d.ts +30 -0
- package/dist/chat-migration.js +802 -0
- package/dist/chat-polish.d.ts +5 -4
- package/dist/chat-polish.js +9 -10
- package/dist/chat-quiet.d.ts +3 -0
- package/dist/chat-quiet.js +5 -0
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +26 -53
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +9 -3
- package/dist/coding-shipping-ui.d.ts +2 -1
- package/dist/coding-shipping-ui.js +6 -6
- package/dist/coding-ui.d.ts +3 -1
- package/dist/coding-ui.js +30 -29
- package/dist/contracts/chat-content.d.ts +71 -2
- package/dist/contracts/chat-content.js +22 -3
- package/dist/contracts/chat-tables.d.ts +11 -4
- package/dist/contracts/chat-tables.js +165 -87
- package/dist/contracts/cli.d.ts +1 -3
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +6 -5
- package/dist/contracts/discord-callback.js +2 -1
- package/dist/contracts/health.d.ts +1 -3
- package/dist/contracts/slack-callback.js +3 -2
- package/dist/contracts/store-json.d.ts +1 -5
- package/dist/contracts/store-json.js +1 -4
- package/dist/contracts/teams-callback.js +2 -1
- package/dist/control-ui.d.ts +5 -4
- package/dist/control-ui.js +9 -34
- package/dist/credentials-ui.d.ts +5 -4
- package/dist/credentials-ui.js +23 -24
- package/dist/demo-chat.d.ts +3 -3
- package/dist/demo-chat.js +29 -76
- package/dist/discord-chat.js +19 -38
- package/dist/discord-settings.d.ts +3 -2
- package/dist/discord-settings.js +21 -55
- package/dist/discord.js +4 -14
- package/dist/envelope.js +10 -0
- package/dist/evidence-pack.d.ts +2 -1
- package/dist/evidence-pack.js +45 -49
- package/dist/export-ui.d.ts +7 -2
- package/dist/export-ui.js +4 -10
- package/dist/export.js +4 -4
- package/dist/flow-gallery-ui.d.ts +4 -3
- package/dist/flow-gallery-ui.js +28 -43
- package/dist/flow-starters-ui.d.ts +2 -1
- package/dist/flow-starters-ui.js +12 -15
- package/dist/flow-triggers.d.ts +1 -0
- package/dist/flow-triggers.js +16 -14
- package/dist/flows-ui.d.ts +4 -3
- package/dist/flows-ui.js +19 -30
- package/dist/flows.js +2 -1
- package/dist/guides.js +108 -3
- package/dist/html.d.ts +76 -0
- package/dist/html.js +173 -0
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +19 -23
- package/dist/kits-ui.d.ts +5 -4
- package/dist/kits-ui.js +18 -20
- package/dist/knowledge-ui.d.ts +6 -5
- package/dist/knowledge-ui.js +26 -25
- package/dist/lead-context.d.ts +11 -1
- package/dist/lead-context.js +6 -8
- package/dist/ledger-view.d.ts +2 -1
- package/dist/ledger-view.js +27 -29
- package/dist/limits-ui.d.ts +2 -1
- package/dist/limits-ui.js +7 -7
- package/dist/live-bus.d.ts +50 -8
- package/dist/live-bus.js +133 -50
- package/dist/mate-tools.js +3 -2
- package/dist/mcp-oauth.js +35 -36
- package/dist/models-ui.d.ts +2 -1
- package/dist/models-ui.js +25 -35
- package/dist/modes.d.ts +13 -1
- package/dist/modes.js +17 -1
- package/dist/monitoring-ui.d.ts +4 -3
- package/dist/monitoring-ui.js +13 -26
- package/dist/notification-rows.d.ts +30 -0
- package/dist/notification-rows.js +102 -0
- package/dist/openrouter-models.d.ts +2 -1
- package/dist/openrouter-models.js +5 -9
- package/dist/operate.js +16 -9
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +13 -13
- package/dist/plane-review.js +5 -11
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +17 -23
- package/dist/project-delete-ui.d.ts +4 -3
- package/dist/project-delete-ui.js +15 -27
- package/dist/project-delete.js +21 -26
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +17 -32
- package/dist/recipe-ui.d.ts +8 -7
- package/dist/recipe-ui.js +39 -32
- package/dist/release-adapters.d.ts +60 -0
- package/dist/release-adapters.js +435 -0
- package/dist/release-cli.d.ts +24 -0
- package/dist/release-cli.js +181 -0
- package/dist/release-gate.d.ts +80 -0
- package/dist/release-gate.js +150 -0
- package/dist/release-links.d.ts +29 -0
- package/dist/release-links.js +102 -0
- package/dist/release.d.ts +331 -0
- package/dist/release.js +550 -0
- package/dist/reply-shape.d.ts +2 -1
- package/dist/reply-shape.js +5 -5
- package/dist/repository-context-ui.d.ts +2 -1
- package/dist/repository-context-ui.js +2 -7
- package/dist/request-budget-ui.d.ts +2 -1
- package/dist/request-budget-ui.js +16 -16
- package/dist/result-review.d.ts +2 -3
- package/dist/result-review.js +3 -9
- package/dist/result-shots.js +2 -2
- package/dist/retention-ui.d.ts +7 -1
- package/dist/retention-ui.js +7 -11
- package/dist/retention.js +1 -1
- package/dist/serve.d.ts +11 -2
- package/dist/serve.js +69 -58
- package/dist/server/chat.d.ts +3 -6
- package/dist/server/chat.js +969 -1006
- package/dist/server/chrome.d.ts +291 -0
- package/dist/server/chrome.js +747 -0
- package/dist/server/console.css +2819 -0
- package/dist/server/flows.d.ts +2 -6
- package/dist/server/flows.js +840 -766
- package/dist/server/guards.d.ts +1 -1
- package/dist/server/guards.js +5 -2
- package/dist/server/handler-context.d.ts +1 -1
- package/dist/server/handler-context.js +1 -1
- package/dist/server/handler-registry.d.ts +8 -6
- package/dist/server/handler-registry.js +14 -2
- package/dist/server/http.d.ts +274 -0
- package/dist/server/http.js +239 -0
- package/dist/server/live.d.ts +34 -0
- package/dist/server/live.js +309 -0
- package/dist/server/pages.d.ts +2 -6
- package/dist/server/pages.js +808 -789
- package/dist/server/people-tokens.d.ts +1 -6
- package/dist/server/people-tokens.js +580 -572
- package/dist/server/remote.d.ts +4 -7
- package/dist/server/remote.js +37 -70
- package/dist/server/render-chat.d.ts +402 -0
- package/dist/server/render-chat.js +927 -0
- package/dist/server/render-pages.d.ts +535 -0
- package/dist/server/render-pages.js +1277 -0
- package/dist/server/render-people.d.ts +32 -0
- package/dist/server/render-people.js +121 -0
- package/dist/server/render-results.d.ts +744 -0
- package/dist/server/render-results.js +2237 -0
- package/dist/server/render-settings.d.ts +147 -0
- package/dist/server/render-settings.js +577 -0
- package/dist/server/render-tasks.d.ts +632 -0
- package/dist/server/render-tasks.js +2345 -0
- package/dist/server/request-context.d.ts +46 -0
- package/dist/server/request-context.js +17 -0
- package/dist/server/route-table.d.ts +25 -3
- package/dist/server/route-table.js +163 -75
- package/dist/server/runtime.d.ts +15 -7
- package/dist/server/runtime.js +7 -1
- package/dist/server/session.d.ts +138 -0
- package/dist/server/session.js +94 -0
- package/dist/server/settings.d.ts +1 -5
- package/dist/server/settings.js +2182 -2117
- package/dist/server/task-views.d.ts +11 -4
- package/dist/server/task-views.js +26 -22
- package/dist/server/tasks.d.ts +9 -9
- package/dist/server/tasks.js +1486 -1418
- package/dist/server-telemetry.d.ts +4 -3
- package/dist/server-telemetry.js +4 -8
- package/dist/skills-ui.d.ts +4 -3
- package/dist/skills-ui.js +14 -17
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +2 -1
- package/dist/slack-api.d.ts +5 -1
- package/dist/slack-api.js +2 -2
- package/dist/slack-chat.d.ts +9 -4
- package/dist/slack-chat.js +29 -52
- package/dist/slack-settings.d.ts +3 -2
- package/dist/slack-settings.js +23 -57
- package/dist/slack.js +6 -16
- package/dist/spend-ui.d.ts +2 -1
- package/dist/spend-ui.js +9 -32
- package/dist/sso-ui.d.ts +3 -2
- package/dist/sso-ui.js +26 -27
- package/dist/storage-ui.d.ts +2 -1
- package/dist/storage-ui.js +14 -33
- package/dist/store.d.ts +97 -364
- package/dist/store.js +207 -1531
- package/dist/surface.js +14 -3
- package/dist/task-composer.d.ts +2 -1
- package/dist/task-composer.js +7 -7
- package/dist/task-status.d.ts +4 -3
- package/dist/task-status.js +15 -16
- package/dist/team-http.d.ts +5 -9
- package/dist/team-http.js +5 -63
- package/dist/team-ui.d.ts +2 -1
- package/dist/team-ui.js +2 -5
- package/dist/teammate-question.js +12 -12
- package/dist/teammates-ui.d.ts +5 -4
- package/dist/teammates-ui.js +61 -90
- package/dist/teams-chat.js +12 -9
- package/dist/teams-settings.d.ts +3 -2
- package/dist/teams-settings.js +22 -33
- package/dist/teams.js +4 -5
- package/dist/telegram-flow.js +5 -5
- package/dist/telegram-settings.d.ts +3 -2
- package/dist/telegram-settings.js +14 -25
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update-ui.d.ts +3 -2
- package/dist/toolroll-update-ui.js +35 -39
- package/dist/toolroll-update.d.ts +10 -0
- package/dist/toolroll-update.js +50 -3
- package/dist/tools-ui.d.ts +5 -2
- package/dist/tools-ui.js +31 -31
- package/dist/when-html.d.ts +11 -3
- package/dist/when-html.js +5 -6
- package/dist/workspace-revision.js +3 -1
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +10 -10
- package/package.json +1 -1
- package/dist/server/shared.d.ts +0 -3151
- package/dist/server/shared.js +0 -12001
- package/dist/slack-state.d.ts +0 -13
- package/dist/slack-state.js +0 -10
package/dist/browser-shell.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type BrowserWorkspace } from './browser-workspace.js';
|
|
3
|
+
import { type Html } from './html.js';
|
|
3
4
|
export declare function browserAssetsAvailable(): boolean;
|
|
4
5
|
export declare function serveBrowserAsset(request: IncomingMessage, response: ServerResponse, path: string): boolean;
|
|
5
6
|
/** Retain the complete native document as the no-JavaScript/unloaded-bundle
|
|
6
7
|
* fallback. React replaces its root only once the local module has loaded.
|
|
7
8
|
* The data script is escaped for HTML parsing, not merely valid JSON. */
|
|
8
|
-
export declare function browserWorkspaceDocument(
|
|
9
|
+
export declare function browserWorkspaceDocument(document: Html, workspace: BrowserWorkspace, nonce: string, functionalScript: string): Html;
|
package/dist/browser-shell.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** The browser bundle is a presentation client. Only these fixed public assets
|
|
2
2
|
* are readable here; authenticated data stays in the existing page handlers. */
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
import {
|
|
4
|
+
import {} from './browser-workspace.js';
|
|
5
|
+
import { html, jsonScript, replaceMarkup, scriptElement } from './html.js';
|
|
5
6
|
const assets = new Map();
|
|
6
7
|
function readAsset(name) {
|
|
7
8
|
// Compiled installation first; the second location supports source-based
|
|
@@ -47,16 +48,15 @@ export function serveBrowserAsset(request, response, path) {
|
|
|
47
48
|
/** Retain the complete native document as the no-JavaScript/unloaded-bundle
|
|
48
49
|
* fallback. React replaces its root only once the local module has loaded.
|
|
49
50
|
* The data script is escaped for HTML parsing, not merely valid JSON. */
|
|
50
|
-
export function browserWorkspaceDocument(
|
|
51
|
+
export function browserWorkspaceDocument(document, workspace, nonce, functionalScript) {
|
|
51
52
|
const initialize = `window.addEventListener('standing-orders:workspace-rendered',function initializeWorkspace(){window.removeEventListener('standing-orders:workspace-rendered',initializeWorkspace);${functionalScript}});`;
|
|
52
53
|
// A cross-page fade the browser gives up on rejects its promises; settle them from the head, before the
|
|
53
54
|
// first frame (the app's own listener arrives too late when a page is revealed before the bundle runs).
|
|
54
55
|
const settleFades = `(function(){function s(e){var f=e.viewTransition;if(f)[f.finished,f.ready,f.updateCallbackDone].forEach(function(p){if(p)p.catch(function(){})})}addEventListener('pageswap',s);addEventListener('pagereveal',s)})();`;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.replace('</body>', () => `</div><script type="application/json" id="standing-orders-workspace-data" nonce="${nonce}">${serializeBrowserWorkspace(workspace)}</script><script nonce="${nonce}">${initialize}</script><script type="module" src="/assets/workspace.js" nonce="${nonce}"></script></body>`);
|
|
56
|
+
// Each insertion is markup built here; the page's own markup (and its data) is never re-read as a pattern.
|
|
57
|
+
let out = replaceMarkup(document, /<\/head>/, () =>
|
|
58
|
+
// A page showing a password keeps to its one script (the sensitivity contract), so it goes without.
|
|
59
|
+
html `<link rel="stylesheet" href="/assets/workspace.css">${workspace.sensitive ? "" : scriptElement(settleFades, { nonce })}</head>`);
|
|
60
|
+
out = replaceMarkup(out, /<body>/, () => html `<body><div id="standing-orders-workspace">`);
|
|
61
|
+
return replaceMarkup(out, /<\/body>/, () => html `</div>${jsonScript(workspace, { id: "standing-orders-workspace-data", nonce })}${scriptElement(initialize, { nonce })}<script type="module" src="/assets/workspace.js" nonce="${nonce}"></script></body>`);
|
|
62
62
|
}
|
|
@@ -430,9 +430,9 @@ export type BrowserTaskView = {
|
|
|
430
430
|
} | null;
|
|
431
431
|
/** What the agent did last on the live run, and when; the page words it against its own clock (activity-line.ts). */
|
|
432
432
|
activity?: RunActivity | null;
|
|
433
|
-
/** The task's live
|
|
433
|
+
/** The task's live room (GET /live?room=task:<root>): change nudges and who else has it open. */
|
|
434
434
|
live?: {
|
|
435
|
-
|
|
435
|
+
room: string;
|
|
436
436
|
at: string | null;
|
|
437
437
|
} | null;
|
|
438
438
|
/** Stop, on the Building card: the exact live build's stop form (posts its run id). */
|
|
@@ -1276,7 +1276,6 @@ export type BrowserSignIn = {
|
|
|
1276
1276
|
};
|
|
1277
1277
|
/** Safe inside a script[type=application/json] element. JSON escaping alone
|
|
1278
1278
|
* does not stop the HTML parser from closing that element at </script>. */
|
|
1279
|
-
export declare function serializeBrowserWorkspace(workspace: BrowserWorkspace): string;
|
|
1280
1279
|
export declare const BROWSER_CREW_LIMIT = 40;
|
|
1281
1280
|
export type BrowserCrewOptions = {
|
|
1282
1281
|
evidenceRoot?: string;
|
|
@@ -9,9 +9,6 @@ export function needsYouLabelOf(count, projectName, saturated = false) {
|
|
|
9
9
|
}
|
|
10
10
|
/** Safe inside a script[type=application/json] element. JSON escaping alone
|
|
11
11
|
* does not stop the HTML parser from closing that element at </script>. */
|
|
12
|
-
export function serializeBrowserWorkspace(workspace) {
|
|
13
|
-
return JSON.stringify(workspace).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
14
|
-
}
|
|
15
12
|
export const BROWSER_CREW_LIMIT = 40;
|
|
16
13
|
/** A bounded recent family list. The caller supplies its already admitted
|
|
17
14
|
* access; an optional project can only narrow it. Admission and family grouping
|
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
|
|
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.
|
|
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
|
+
}
|