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
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat core's messages, buttons and deliveries, for any chat app: what an app received (updates, and messages the
|
|
3
|
+
* lead answers, queued, claimed and finished), the reply parts planned before any send and settled on the app's own
|
|
4
|
+
* message id, the one-tap buttons behind decision, proposal, flow and question cards, the sent messages a reply can be
|
|
5
|
+
* about, and each paired person's notification receipts and away-mode digest. Every query names its provider
|
|
6
|
+
* (ChatCore.prepare), so one app's rows never reach another's. Telegram runs on it today through the store's
|
|
7
|
+
* Telegram-named methods (one line each); the adapters keep only their transport.
|
|
8
|
+
*
|
|
9
|
+
* Imports the store's types only (no runtime cycle): the store builds its chats on it.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { ChatCore } from "./chat-core.js";
|
|
13
|
+
import { readChatMessageBody, readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
|
|
14
|
+
import { isTelegramProgressNotification, proposalTaskOf, readNotification, readNotificationReceipt } from "./notification-rows.js";
|
|
15
|
+
/** v83: a notification for another person, settled for this destination without sending. */
|
|
16
|
+
export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
|
|
17
|
+
/** A met promise the lead made on another chat: reported there, settled here without sending. */
|
|
18
|
+
export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
|
|
19
|
+
/** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
|
|
20
|
+
export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
21
|
+
/** A destination receipt (`d` = notification_delivery) that still owes a
|
|
22
|
+
* send: not delivered, and not explicitly skipped as pre-pairing history.
|
|
23
|
+
* Every reader of Telegram receipts that means "undelivered" uses this, so
|
|
24
|
+
* a skip settles the row without ever pretending to be a delivery. */
|
|
25
|
+
export const TELEGRAM_UNSETTLED = "d.delivered_at IS NULL AND (d.receipt IS NULL OR d.receipt NOT LIKE 'skipped:%')";
|
|
26
|
+
/**
|
|
27
|
+
* Why the store itself holds a Telegram delivery back: authority,
|
|
28
|
+
* provenance, ordering, a switched-off channel, or a wait it imposed. A
|
|
29
|
+
* row held for one of these is waiting on policy, not failing on the wire.
|
|
30
|
+
* Every other error recorded on a receipt came from an actual send, and
|
|
31
|
+
* THAT is delivery trouble (`pendingForAttention`). One table, so the
|
|
32
|
+
* fences and the tally can never disagree about the words.
|
|
33
|
+
*/
|
|
34
|
+
export const TELEGRAM_HOLD_REASONS = Object.freeze({
|
|
35
|
+
authority: "Telegram pairing or actor authorization changed",
|
|
36
|
+
destination: "Telegram destination changed",
|
|
37
|
+
claim: "Telegram delivery claim expired or changed",
|
|
38
|
+
rateLimit: "Telegram rate limit is still active",
|
|
39
|
+
resolved: "Notification no longer needs delivery",
|
|
40
|
+
provenance: "Notification has no trusted project provenance",
|
|
41
|
+
installation: "Installation notification requires instance access",
|
|
42
|
+
project: "Notification project is not currently authorized and enrolled",
|
|
43
|
+
task: "Notification task provenance changed",
|
|
44
|
+
order: "Earlier task notification is still undelivered",
|
|
45
|
+
disabled: "Telegram delivery is disabled",
|
|
46
|
+
});
|
|
47
|
+
/** A Telegram message the lead answers is its event m<update id>, read with the pairing it came from. */
|
|
48
|
+
const TELEGRAM_CONVERSATION_SELECT = `SELECT e.*, b.approver AS paired_approver, b.generation AS paired_generation
|
|
49
|
+
FROM chat_event e JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding
|
|
50
|
+
WHERE e.provider = :provider AND e.kind = 'message'`;
|
|
51
|
+
/** In update order. */
|
|
52
|
+
const TELEGRAM_CONVERSATION_ORDER = "CAST(substr(e.id, 2) AS INTEGER)";
|
|
53
|
+
function readTelegramConversation(row) {
|
|
54
|
+
const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
|
|
55
|
+
const int = (key) => (row[key] === null || row[key] === undefined ? null : Number(row[key]));
|
|
56
|
+
// A message dropped when its chat was unpaired keeps no words.
|
|
57
|
+
const read = readChatMessageBody(String(row["payload"]));
|
|
58
|
+
const body = read.ok ? read.value : {};
|
|
59
|
+
const update = Number(String(row["id"]).slice(1));
|
|
60
|
+
const state = String(row["state"]);
|
|
61
|
+
return {
|
|
62
|
+
id: update,
|
|
63
|
+
binding: Number(row["binding"]),
|
|
64
|
+
botId: String(row["installation"]),
|
|
65
|
+
chatId: String(row["channel"]),
|
|
66
|
+
userId: String(row["member"]),
|
|
67
|
+
approver: String(row["paired_approver"]),
|
|
68
|
+
approverGeneration: Number(row["paired_generation"]),
|
|
69
|
+
updateId: update,
|
|
70
|
+
messageId: String(row["ts"]),
|
|
71
|
+
replyTo: row["thread"] === "" ? null : text("thread"),
|
|
72
|
+
request: body.request ?? "",
|
|
73
|
+
text: body.text ?? "",
|
|
74
|
+
context: body.context ?? null,
|
|
75
|
+
taskId: body.about?.task ?? null,
|
|
76
|
+
sourceRun: body.about?.run ?? null,
|
|
77
|
+
state: (state === "dropped" ? "failed" : state),
|
|
78
|
+
claimOwner: text("claim_owner"),
|
|
79
|
+
claimExpiresAt: text("claim_until"),
|
|
80
|
+
attempts: Number(row["attempts"]),
|
|
81
|
+
nextAttemptAt: text("next_at"),
|
|
82
|
+
session: int("session"),
|
|
83
|
+
turn: int("turn"),
|
|
84
|
+
outcome: text("result"),
|
|
85
|
+
replyMessageId: text("reply"),
|
|
86
|
+
createdAt: String(row["created"]),
|
|
87
|
+
startedAt: text("started"),
|
|
88
|
+
finishedAt: text("finished"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** One planned part of a Telegram reply (chat_part, its content by contracts/chat-content.ts). */
|
|
92
|
+
function readTelegramConversationPart(row, conversation) {
|
|
93
|
+
const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
|
|
94
|
+
const read = readChatPart(String(row["payload"]));
|
|
95
|
+
if (!read.ok)
|
|
96
|
+
throw new Error(`Telegram reply part ${conversation}:${String(row["ordinal"])} can't be read: ${read.issues.map(issue => issue.line).join("; ")}`);
|
|
97
|
+
const content = read.value;
|
|
98
|
+
const kind = content.image !== undefined ? "image" : content.card === true || content.proposal !== undefined ? "card" : "reply";
|
|
99
|
+
return {
|
|
100
|
+
conversation,
|
|
101
|
+
ordinal: Number(row["ordinal"]),
|
|
102
|
+
kind,
|
|
103
|
+
text: content.text,
|
|
104
|
+
replyTo: content.replyTo ?? null,
|
|
105
|
+
// A card's proposal, while it exists (a deleted one reads as gone).
|
|
106
|
+
proposal: content.proposal !== undefined && Number(row["proposal_live"] ?? 1) === 1 ? content.proposal : null,
|
|
107
|
+
keyboard: content.keyboard === undefined ? null : content.keyboard.map(line => line.map(button => ({ ...button, text: button.text, callback_data: button.callback_data }))),
|
|
108
|
+
state: String(row["state"]),
|
|
109
|
+
messageId: text("message"),
|
|
110
|
+
taskId: content.image?.taskId ?? null,
|
|
111
|
+
run: content.image?.run ?? null,
|
|
112
|
+
artifact: content.image?.artifact ?? null,
|
|
113
|
+
sha256: content.image?.sha256 ?? null,
|
|
114
|
+
attempts: Number(row["attempts"]),
|
|
115
|
+
uncertain: Number(row["uncertain"]),
|
|
116
|
+
nextAttemptAt: text("next_at"),
|
|
117
|
+
lastError: text("problem"),
|
|
118
|
+
createdAt: String(row["created"]),
|
|
119
|
+
sentAt: text("sent"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function readTelegramProposalAction(row) {
|
|
123
|
+
return {
|
|
124
|
+
token: String(row["token"]),
|
|
125
|
+
binding: Number(row["binding"]),
|
|
126
|
+
proposal: Number(row["proposal"]),
|
|
127
|
+
phase: String(row["phase"]),
|
|
128
|
+
chatId: String(row["chat"]),
|
|
129
|
+
messageId: row["message"] === null ? null : String(row["message"]),
|
|
130
|
+
createdAt: String(row["created"]),
|
|
131
|
+
expiresAt: row["expires"] === null ? null : String(row["expires"]),
|
|
132
|
+
consumedAt: row["consumed"] === null ? null : String(row["consumed"]),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function readTelegramAction(row) {
|
|
136
|
+
return {
|
|
137
|
+
token: String(row["token"]),
|
|
138
|
+
binding: Number(row["binding"]),
|
|
139
|
+
decision: Number(row["decision"]),
|
|
140
|
+
optionId: String(row["option_id"]),
|
|
141
|
+
phase: String(row["phase"]),
|
|
142
|
+
chatId: String(row["chat"]),
|
|
143
|
+
messageId: row["message"] === null ? null : String(row["message"]),
|
|
144
|
+
createdAt: String(row["created"]),
|
|
145
|
+
expiresAt: row["expires"] === null ? null : String(row["expires"]),
|
|
146
|
+
consumedAt: row["consumed"] === null ? null : String(row["consumed"]),
|
|
147
|
+
noteDigest: row["note_digest"] === null || row["note_digest"] === undefined ? null : String(row["note_digest"]),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export class ChatMessages extends ChatCore {
|
|
151
|
+
createDecisionAction(action, now) {
|
|
152
|
+
this
|
|
153
|
+
.prepare(`INSERT INTO chat_action (provider, token, binding, chat, message, decision, option_id, phase, created, expires, note_digest)
|
|
154
|
+
VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
155
|
+
.run(action.token, action.binding, action.chatId, action.messageId ?? null, action.decision, action.optionId, action.phase, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString(), action.noteDigest ?? null);
|
|
156
|
+
}
|
|
157
|
+
/** Which decision an outbound Telegram message carried. Recorded after the
|
|
158
|
+
* send returns its id; a send whose record was lost routes nothing. */
|
|
159
|
+
recordDecisionMessage(binding, chatId, messageId, decision, now) {
|
|
160
|
+
this
|
|
161
|
+
.prepare(`INSERT OR IGNORE INTO chat_message_ref (provider, binding, chat, message, kind, decision, created)
|
|
162
|
+
VALUES (:provider, ?, ?, ?, 'decision', ?, ?)`)
|
|
163
|
+
.run(binding, chatId, messageId, decision, now.toISOString());
|
|
164
|
+
}
|
|
165
|
+
/** The decision behind an exact replied-to message, or nothing. Never "the latest". */
|
|
166
|
+
decisionForMessage(binding, chatId, messageId) {
|
|
167
|
+
const row = this
|
|
168
|
+
.prepare("SELECT decision FROM chat_message_ref WHERE provider = :provider AND kind = 'decision' AND binding = ? AND chat = ? AND message = ?")
|
|
169
|
+
.get(binding, chatId, messageId);
|
|
170
|
+
return row === undefined ? null : Number(row["decision"]);
|
|
171
|
+
}
|
|
172
|
+
/** The one live, unexpired draft for a decision, if any. */
|
|
173
|
+
liveNoteDraft(binding, decision, now) {
|
|
174
|
+
const row = this
|
|
175
|
+
.prepare(`SELECT id, note, event, state FROM chat_note_draft
|
|
176
|
+
WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed') AND expires > ?`)
|
|
177
|
+
.get(binding, decision, now.toISOString());
|
|
178
|
+
return row === undefined
|
|
179
|
+
? null
|
|
180
|
+
: { id: Number(row["id"]), note: String(row["note"]), updateId: Number(row["event"]), state: String(row["state"]) };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Persist a validated note as the live draft. A newer update SUPERSEDES
|
|
184
|
+
* the old draft — drafts are immutable, and only a GREATER update_id may
|
|
185
|
+
* replace one, so out-of-order delivery cannot resurrect an older note
|
|
186
|
+
* (Codex free-text review, state machine). Returns false when an older
|
|
187
|
+
* or equal update tried.
|
|
188
|
+
*/
|
|
189
|
+
saveNoteDraft(draft, now, ttlMs = 10 * 60_000) {
|
|
190
|
+
return this.store.transact(() => {
|
|
191
|
+
const chat = this;
|
|
192
|
+
const live = chat
|
|
193
|
+
.prepare("SELECT id, event FROM chat_note_draft WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed')")
|
|
194
|
+
.get(draft.binding, draft.decision);
|
|
195
|
+
if (live !== undefined) {
|
|
196
|
+
if (Number(live["event"]) >= draft.updateId)
|
|
197
|
+
return false;
|
|
198
|
+
chat.prepare("UPDATE chat_note_draft SET state = 'superseded' WHERE provider = :provider AND id = ?").run(live["id"]);
|
|
199
|
+
}
|
|
200
|
+
chat
|
|
201
|
+
.prepare(`INSERT INTO chat_note_draft (provider, id, binding, decision, event, message, reply_to, note, state, created, expires)
|
|
202
|
+
VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
|
|
203
|
+
.run(chat.nextId("chat_note_draft"), draft.binding, draft.decision, draft.updateId, draft.messageId, draft.replyTo, draft.note, now.toISOString(), new Date(now.getTime() + ttlMs).toISOString());
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Move a draft between states; the caller owns the transaction story. */
|
|
208
|
+
setNoteDraftState(id, state) {
|
|
209
|
+
this.prepare("UPDATE chat_note_draft SET state = ? WHERE provider = :provider AND id = ?").run(state, id);
|
|
210
|
+
}
|
|
211
|
+
createFlowActions(at, actions, now, days = 7) {
|
|
212
|
+
const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
213
|
+
const insert = this.prepare("INSERT INTO chat_flow_action (provider, token, binding, card, entry, action, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
|
|
214
|
+
for (const one of actions)
|
|
215
|
+
insert.run(one.token, at.binding, at.card, at.entry, one.action, at.chatId, stamp, expires);
|
|
216
|
+
}
|
|
217
|
+
flowAction(token) {
|
|
218
|
+
if (!/^[0-9a-f]{32}$/.test(token))
|
|
219
|
+
return null;
|
|
220
|
+
const row = this.prepare("SELECT * FROM chat_flow_action WHERE provider = :provider AND token = ? AND action IN ('approve','edit','send-back')").get(token);
|
|
221
|
+
if (row === undefined)
|
|
222
|
+
return null;
|
|
223
|
+
return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), action: String(row["action"]),
|
|
224
|
+
chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
|
|
225
|
+
}
|
|
226
|
+
placeFlowActions(tokens, messageId) {
|
|
227
|
+
const place = this.prepare("UPDATE chat_flow_action SET message = ? WHERE provider = :provider AND token = ?");
|
|
228
|
+
for (const token of tokens)
|
|
229
|
+
place.run(messageId, token);
|
|
230
|
+
}
|
|
231
|
+
/** Retire every button (and prompt) for one visit of a card, in every chat app: it was decided, or its draft replaced. */
|
|
232
|
+
retireFlowVisit(card, entry, now) {
|
|
233
|
+
const stamp = now.toISOString();
|
|
234
|
+
this.db.prepare("UPDATE chat_flow_action SET consumed = ? WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
|
|
235
|
+
this.db.prepare("UPDATE chat_flow_prompt SET consumed = ? WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
|
|
236
|
+
}
|
|
237
|
+
recordFlowPrompt(prompt, now, hours = 24) {
|
|
238
|
+
const chat = this;
|
|
239
|
+
chat.prepare("DELETE FROM chat_flow_prompt WHERE provider = :provider AND chat = ? AND message = ?").run(prompt.chatId, prompt.messageId);
|
|
240
|
+
chat.prepare("INSERT INTO chat_flow_prompt (provider, id, chat, message, binding, card, entry, mode, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
241
|
+
.run(chat.nextId("chat_flow_prompt"), prompt.chatId, prompt.messageId, prompt.binding, prompt.card, prompt.entry, prompt.mode, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
|
|
242
|
+
}
|
|
243
|
+
/** The live prompt a message replies to, if any. */
|
|
244
|
+
flowPrompt(chatId, messageId, now) {
|
|
245
|
+
const row = this.prepare("SELECT * FROM chat_flow_prompt WHERE provider = :provider AND chat = ? AND message = ? AND consumed IS NULL AND expires > ?").get(chatId, messageId, now.toISOString());
|
|
246
|
+
if (row === undefined)
|
|
247
|
+
return null;
|
|
248
|
+
return { chatId: String(row["chat"]), messageId: String(row["message"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), mode: String(row["mode"]) };
|
|
249
|
+
}
|
|
250
|
+
createFlowChoices(at, choices, now, days = 7) {
|
|
251
|
+
const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
252
|
+
const insert = this.prepare("INSERT INTO chat_flow_choice (provider, token, binding, card, entry, choice, label, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
|
|
253
|
+
for (const one of choices)
|
|
254
|
+
insert.run(one.token, at.binding, at.card, at.entry, one.choice, one.label, at.chatId, stamp, expires);
|
|
255
|
+
}
|
|
256
|
+
flowChoice(token) {
|
|
257
|
+
if (!/^[0-9a-f]{32}$/.test(token))
|
|
258
|
+
return null;
|
|
259
|
+
const row = this.prepare("SELECT * FROM chat_flow_choice WHERE provider = :provider AND token = ?").get(token);
|
|
260
|
+
if (row === undefined)
|
|
261
|
+
return null;
|
|
262
|
+
return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), choice: Number(row["choice"]), label: String(row["label"]),
|
|
263
|
+
chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
|
|
264
|
+
}
|
|
265
|
+
placeFlowChoices(tokens, messageId) {
|
|
266
|
+
const place = this.prepare("UPDATE chat_flow_choice SET message = ? WHERE provider = :provider AND token = ?");
|
|
267
|
+
for (const token of tokens)
|
|
268
|
+
place.run(messageId, token);
|
|
269
|
+
}
|
|
270
|
+
createQuestionActions(at, choices, now, days = 7) {
|
|
271
|
+
const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
272
|
+
const insert = this.prepare("INSERT INTO chat_question_action (provider, token, binding, question, choice, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, NULL, ?, ?)");
|
|
273
|
+
for (const one of choices)
|
|
274
|
+
insert.run(one.token, at.binding, at.question, one.choice, at.chatId, stamp, expires);
|
|
275
|
+
}
|
|
276
|
+
questionAction(token) {
|
|
277
|
+
if (!/^[0-9a-f]{32}$/.test(token))
|
|
278
|
+
return null;
|
|
279
|
+
const row = this.prepare("SELECT * FROM chat_question_action WHERE provider = :provider AND token = ?").get(token);
|
|
280
|
+
if (row === undefined)
|
|
281
|
+
return null;
|
|
282
|
+
return { token: String(row["token"]), binding: Number(row["binding"]), question: Number(row["question"]), choice: row["choice"] === null ? null : String(row["choice"]),
|
|
283
|
+
chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
|
|
284
|
+
}
|
|
285
|
+
placeQuestionActions(tokens, messageId) {
|
|
286
|
+
const place = this.prepare("UPDATE chat_question_action SET message = ? WHERE provider = :provider AND token = ?");
|
|
287
|
+
for (const token of tokens)
|
|
288
|
+
place.run(messageId, token);
|
|
289
|
+
}
|
|
290
|
+
/** Retire every button and prompt for a question, in every chat app: it was answered (anywhere). */
|
|
291
|
+
retireQuestion(question, now) {
|
|
292
|
+
const stamp = now.toISOString();
|
|
293
|
+
this.db.prepare("UPDATE chat_question_action SET consumed = ? WHERE question = ? AND consumed IS NULL").run(stamp, question);
|
|
294
|
+
this.db.prepare("UPDATE chat_question_prompt SET consumed = ? WHERE question = ? AND consumed IS NULL").run(stamp, question);
|
|
295
|
+
}
|
|
296
|
+
recordQuestionPrompt(prompt, now, hours = 24) {
|
|
297
|
+
const chat = this;
|
|
298
|
+
chat.prepare("DELETE FROM chat_question_prompt WHERE provider = :provider AND chat = ? AND message = ?").run(prompt.chatId, prompt.messageId);
|
|
299
|
+
chat.prepare("INSERT INTO chat_question_prompt (provider, id, chat, message, binding, question, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?)")
|
|
300
|
+
.run(chat.nextId("chat_question_prompt"), prompt.chatId, prompt.messageId, prompt.binding, prompt.question, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
|
|
301
|
+
}
|
|
302
|
+
questionPrompt(chatId, messageId, now) {
|
|
303
|
+
const row = this.prepare("SELECT * FROM chat_question_prompt WHERE provider = :provider AND chat = ? AND message = ? AND consumed IS NULL AND expires > ?").get(chatId, messageId, now.toISOString());
|
|
304
|
+
return row === undefined ? null : { chatId: String(row["chat"]), messageId: String(row["message"]), binding: Number(row["binding"]), question: Number(row["question"]) };
|
|
305
|
+
}
|
|
306
|
+
decisionAction(token) {
|
|
307
|
+
const row = this.prepare("SELECT * FROM chat_action WHERE provider = :provider AND token = ? AND decision IS NOT NULL").get(token);
|
|
308
|
+
return row === undefined ? null : readTelegramAction(row);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Kill every live confirm/cancel challenge on a decision. Cancel means
|
|
312
|
+
* cancelled: a confirm that survived its own cancellation would be an
|
|
313
|
+
* irreversible choice still armed after the person said stop.
|
|
314
|
+
*/
|
|
315
|
+
consumeChallenges(decision, now) {
|
|
316
|
+
this
|
|
317
|
+
.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND decision = ? AND phase IN ('confirm','cancel') AND consumed IS NULL")
|
|
318
|
+
.run(now.toISOString(), decision);
|
|
319
|
+
}
|
|
320
|
+
/** Consume once. False means somebody already did, or it expired — either way, no. */
|
|
321
|
+
consumeDecisionAction(token, now) {
|
|
322
|
+
const stamp = now.toISOString();
|
|
323
|
+
const { changes } = this
|
|
324
|
+
.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND token = ? AND decision IS NOT NULL AND consumed IS NULL AND (expires IS NULL OR expires > ?)")
|
|
325
|
+
.run(stamp, token, stamp);
|
|
326
|
+
return Number(changes) > 0;
|
|
327
|
+
}
|
|
328
|
+
/** Stamp the message a choose-action's keyboard actually landed on. */
|
|
329
|
+
placeDecisionActions(tokens, messageId) {
|
|
330
|
+
const place = this.prepare("UPDATE chat_action SET message = ? WHERE provider = :provider AND token = ?");
|
|
331
|
+
for (const token of tokens)
|
|
332
|
+
place.run(messageId, token);
|
|
333
|
+
}
|
|
334
|
+
/** A Telegram message this bot sent that showed one task (v79): a reply to
|
|
335
|
+
* it is about that task (and that result, when it showed one). A message
|
|
336
|
+
* edited to show something else forgets it. */
|
|
337
|
+
recordTaskMessage(binding, messageId, taskId, run, now) {
|
|
338
|
+
const chat = this;
|
|
339
|
+
if (taskId === null) {
|
|
340
|
+
chat.prepare("DELETE FROM chat_message_ref WHERE provider = :provider AND kind = 'task' AND binding = ? AND chat = ? AND message = ?").run(binding.id, binding.chatId, messageId);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
chat.prepare(`INSERT INTO chat_message_ref (provider, binding, chat, message, kind, task_id, run, created) VALUES (:provider, ?, ?, ?, 'task', ?, ?, ?)
|
|
344
|
+
ON CONFLICT (provider, binding, chat, message, kind) WHERE kind <> 'notification' DO UPDATE SET task_id = excluded.task_id, run = excluded.run, created = excluded.created`)
|
|
345
|
+
.run(binding.id, binding.chatId, messageId, taskId, run, now.toISOString());
|
|
346
|
+
}
|
|
347
|
+
/** v98: an update Telegram pushed, kept (queued, with its body) until the bridge applies it; false when it's already kept or applied. */
|
|
348
|
+
queueUpdate(botId, updateId, payload, now) {
|
|
349
|
+
return Number(this.prepare(`INSERT OR IGNORE INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state)
|
|
350
|
+
VALUES (:provider, ?, ?, 'update', '', '', '', '', ?, ?, 'queued')`).run(String(updateId), botId, payload, now.toISOString()).changes) === 1;
|
|
351
|
+
}
|
|
352
|
+
/** v98: pushed updates waiting for the bridge, oldest first. */
|
|
353
|
+
inbox(botId, limit) {
|
|
354
|
+
return this.prepare(`SELECT id, payload FROM chat_event WHERE provider = :provider AND kind = 'update' AND state = 'queued' AND installation = ?
|
|
355
|
+
ORDER BY CAST(id AS INTEGER) LIMIT ?`).all(botId, limit)
|
|
356
|
+
.map(row => ({ updateId: Number(row["id"]), payload: String(row["payload"]) }));
|
|
357
|
+
}
|
|
358
|
+
/** A pushed update the bridge is done with: applied (then its receipt stays), or unreadable (then it is gone). */
|
|
359
|
+
dropInbox(updateId) {
|
|
360
|
+
this.prepare("DELETE FROM chat_event WHERE provider = :provider AND id = ? AND kind = 'update' AND state = 'queued'").run(String(updateId));
|
|
361
|
+
}
|
|
362
|
+
/** True if this update has not been applied before. The (provider, id) key is the idempotency; a pushed update waiting
|
|
363
|
+
* in the inbox is applied in place. A receipt names only its update, as it always has. */
|
|
364
|
+
markUpdateApplied(updateId, result, now) {
|
|
365
|
+
const { changes } = this
|
|
366
|
+
.prepare(`INSERT INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state, result)
|
|
367
|
+
VALUES (:provider, ?, '', 'update', '', '', '', '', '{}', ?, 'done', ?)
|
|
368
|
+
ON CONFLICT (provider, id) DO UPDATE SET state = 'done', installation = '', payload = '{}', result = excluded.result
|
|
369
|
+
WHERE chat_event.kind = 'update' AND chat_event.state = 'queued'`)
|
|
370
|
+
.run(String(updateId), now.toISOString(), result);
|
|
371
|
+
return Number(changes) > 0;
|
|
372
|
+
}
|
|
373
|
+
/** Persist one ordinary message before its update is acknowledged (event m<update id>). The caller's transaction also marks the update applied. */
|
|
374
|
+
enqueueMessage(row, now) {
|
|
375
|
+
const body = { text: row.text, request: row.request, ...(row.context === null ? {} : { context: row.context }),
|
|
376
|
+
...(row.taskId === null ? {} : { about: { task: row.taskId, run: row.sourceRun } }) };
|
|
377
|
+
this
|
|
378
|
+
.prepare(`INSERT INTO chat_event (provider, id, installation, binding, kind, channel, member, ts, thread, payload, created, state)
|
|
379
|
+
VALUES (:provider, ?, ?, ?, 'message', ?, ?, ?, ?, ?, ?, 'queued')`)
|
|
380
|
+
.run(`m${row.updateId}`, row.binding.botId, row.binding.id, row.binding.chatId, row.binding.userId, row.messageId, row.replyTo ?? "", savedChatEventBody(body), now.toISOString());
|
|
381
|
+
return row.updateId;
|
|
382
|
+
}
|
|
383
|
+
/** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
|
|
384
|
+
messageWaitingOn(binding, messageId) {
|
|
385
|
+
return this.prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND kind = 'message' AND binding = ? AND ts = ? AND state IN ('queued', 'running') LIMIT 1").get(binding, messageId) !== undefined;
|
|
386
|
+
}
|
|
387
|
+
getMessage(id) {
|
|
388
|
+
const row = this.prepare(`${TELEGRAM_CONVERSATION_SELECT} AND e.id = ?`).get(`m${id}`);
|
|
389
|
+
return row === undefined ? null : readTelegramConversation(row);
|
|
390
|
+
}
|
|
391
|
+
listMessages(botId) {
|
|
392
|
+
return this.prepare(`${TELEGRAM_CONVERSATION_SELECT} AND e.installation = ? ORDER BY ${TELEGRAM_CONVERSATION_ORDER}`).all(botId).map(readTelegramConversation);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Claim the oldest message that still needs a turn: queued, or running
|
|
396
|
+
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
397
|
+
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
398
|
+
*/
|
|
399
|
+
claimMessage(botId, owner, ttlMs, now, only) {
|
|
400
|
+
return this.store.transact(() => {
|
|
401
|
+
const chat = this, stamp = now.toISOString();
|
|
402
|
+
// "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
|
|
403
|
+
const planned = "EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = e.provider AND p.event = e.id)";
|
|
404
|
+
const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
|
|
405
|
+
const row = chat
|
|
406
|
+
.prepare(`SELECT e.id FROM chat_event e
|
|
407
|
+
WHERE e.provider = :provider AND e.kind = 'message' AND e.installation = ? AND e.state IN ('queued','running')
|
|
408
|
+
AND (e.claim_until IS NULL OR e.claim_until <= ?)
|
|
409
|
+
AND (e.next_at IS NULL OR e.next_at <= ?)
|
|
410
|
+
${which}
|
|
411
|
+
ORDER BY ${TELEGRAM_CONVERSATION_ORDER} LIMIT 1`)
|
|
412
|
+
.get(botId, stamp, stamp);
|
|
413
|
+
if (row === undefined)
|
|
414
|
+
return null;
|
|
415
|
+
chat
|
|
416
|
+
.prepare(`UPDATE chat_event SET state = 'running', claim_owner = ?, claim_until = ?, attempts = attempts + 1,
|
|
417
|
+
started = COALESCE(started, ?), next_at = NULL WHERE provider = :provider AND id = ?`)
|
|
418
|
+
.run(owner, new Date(now.getTime() + ttlMs).toISOString(), stamp, String(row["id"]));
|
|
419
|
+
return this.getMessage(Number(String(row["id"]).slice(1)));
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/** Extend a held claim; false means it lapsed and somebody else may hold the row. */
|
|
423
|
+
renewMessage(id, owner, ttlMs, now) {
|
|
424
|
+
const { changes } = this
|
|
425
|
+
.prepare("UPDATE chat_event SET claim_until = ? WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
|
|
426
|
+
.run(new Date(now.getTime() + ttlMs).toISOString(), `m${id}`, owner, now.toISOString());
|
|
427
|
+
return Number(changes) === 1;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Bind the session a turn is about to be dispatched under, BEFORE the
|
|
431
|
+
* dispatch: the engine receipts the request inside that session, so a
|
|
432
|
+
* later attempt — after a crash, even after the session was ended and
|
|
433
|
+
* replaced from the console — reads the receipt where it was written
|
|
434
|
+
* instead of resolving today's session and finding nothing there.
|
|
435
|
+
*/
|
|
436
|
+
bindMessageSession(id, owner, session) {
|
|
437
|
+
const { changes } = this
|
|
438
|
+
.prepare("UPDATE chat_event SET session = ? WHERE provider = :provider AND id = ? AND claim_owner = ? AND state = 'running'")
|
|
439
|
+
.run(session, `m${id}`, owner);
|
|
440
|
+
return Number(changes) === 1;
|
|
441
|
+
}
|
|
442
|
+
/** Bind the admitted turn to its row as soon as the engine's receipt names it. */
|
|
443
|
+
bindMessageTurn(id, owner, session, turn) {
|
|
444
|
+
const { changes } = this
|
|
445
|
+
.prepare("UPDATE chat_event SET session = ?, turn = ? WHERE provider = :provider AND id = ? AND claim_owner = ?")
|
|
446
|
+
.run(session, turn, `m${id}`, owner);
|
|
447
|
+
return Number(changes) === 1;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Settle a claimed row: done or failed with its outcome word, or back to
|
|
451
|
+
* queued for a later attempt (a busy engine). Only the claim holder may;
|
|
452
|
+
* a lapsed claim settles nothing, so a reclaimer's outcome stands.
|
|
453
|
+
*/
|
|
454
|
+
finishMessage(id, owner, result, now) {
|
|
455
|
+
const stamp = now.toISOString(), chat = this;
|
|
456
|
+
const { changes } = result.state === "queued"
|
|
457
|
+
? chat
|
|
458
|
+
.prepare(`UPDATE chat_event SET state = 'queued', result = ?, next_at = ?, claim_owner = NULL, claim_until = NULL
|
|
459
|
+
WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'`)
|
|
460
|
+
.run(result.outcome, result.nextAttemptAt, `m${id}`, owner, stamp)
|
|
461
|
+
: chat
|
|
462
|
+
.prepare(`UPDATE chat_event SET state = ?, result = ?, reply = COALESCE(?, reply), finished = ?,
|
|
463
|
+
claim_owner = NULL, claim_until = NULL
|
|
464
|
+
WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'`)
|
|
465
|
+
.run(result.state, result.outcome, result.replyMessageId ?? null, stamp, `m${id}`, owner, stamp);
|
|
466
|
+
return Number(changes) === 1;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Replies the assistant wrote that still have not reached the person
|
|
470
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
471
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
472
|
+
* written, and the last send error.
|
|
473
|
+
*/
|
|
474
|
+
unsentReplies(botId, approver, since) {
|
|
475
|
+
return this
|
|
476
|
+
.prepare(`SELECT c.id, b.approver, MIN(p.created) AS since,
|
|
477
|
+
(SELECT q.problem FROM chat_part q WHERE q.provider = c.provider AND q.event = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
|
|
478
|
+
FROM chat_event c JOIN chat_binding b ON b.provider = c.provider AND b.id = c.binding
|
|
479
|
+
JOIN chat_part p ON p.provider = c.provider AND p.event = c.id AND p.state = 'pending'
|
|
480
|
+
WHERE c.provider = :provider AND c.kind = 'message' AND c.state IN ('queued','running') AND (? IS NULL OR c.installation = ?) AND (? IS NULL OR b.approver = ?)
|
|
481
|
+
GROUP BY c.id HAVING MIN(p.created) <= ? ORDER BY ${TELEGRAM_CONVERSATION_ORDER.replaceAll("e.", "c.")}`)
|
|
482
|
+
.all(botId, botId, approver, approver, since.toISOString())
|
|
483
|
+
.map(row => ({ conversation: Number(String(row["id"]).slice(1)), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
|
|
484
|
+
}
|
|
485
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
486
|
+
retryReplies(approver, now) {
|
|
487
|
+
const { changes } = this
|
|
488
|
+
.prepare(`UPDATE chat_event SET next_at = ?
|
|
489
|
+
WHERE provider = :provider AND kind = 'message' AND state = 'queued'
|
|
490
|
+
AND (? IS NULL OR binding IN (SELECT id FROM chat_binding WHERE provider = :provider AND approver = ?))
|
|
491
|
+
AND EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = chat_event.provider AND p.event = chat_event.id AND p.state = 'pending')`)
|
|
492
|
+
.run(now.toISOString(), approver, approver);
|
|
493
|
+
return Number(changes);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Persist the whole outbound half of a turn — every reply part and every
|
|
497
|
+
* card, with the cards' tokens already minted — in one transaction, BEFORE
|
|
498
|
+
* any send. Only the claim holder may, and only once: a row that already
|
|
499
|
+
* has parts keeps them (a retry resumes, never re-plans). The turn's
|
|
500
|
+
* identity rides the row too, so the parts answer to exactly that turn.
|
|
501
|
+
*/
|
|
502
|
+
planReplyParts(id, owner, turn, parts, now) {
|
|
503
|
+
return this.store.transact(() => {
|
|
504
|
+
const chat = this, stamp = now.toISOString(), event = `m${id}`;
|
|
505
|
+
const held = chat
|
|
506
|
+
.prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
|
|
507
|
+
.get(event, owner, stamp);
|
|
508
|
+
if (held === undefined)
|
|
509
|
+
return false;
|
|
510
|
+
const existing = chat.prepare("SELECT COUNT(*) AS n FROM chat_part WHERE provider = :provider AND event = ?").get(event);
|
|
511
|
+
if (Number(existing?.["n"] ?? 0) > 0)
|
|
512
|
+
return false;
|
|
513
|
+
chat.prepare("UPDATE chat_event SET session = ?, turn = ? WHERE provider = :provider AND id = ?").run(turn.session, turn.turn, event);
|
|
514
|
+
const insert = chat.prepare("INSERT INTO chat_part (provider, id, event, ordinal, payload, state, created) VALUES (:provider, ?, ?, ?, ?, 'pending', ?)");
|
|
515
|
+
parts.forEach((part, ordinal) => {
|
|
516
|
+
const content = part.kind === "image"
|
|
517
|
+
? { text: part.text, image: { taskId: part.taskId, run: part.run, artifact: part.artifact, sha256: part.sha256 } }
|
|
518
|
+
: part.kind === "card"
|
|
519
|
+
? { text: part.text, proposal: part.proposal, ...(part.keyboard == null ? {} : { keyboard: part.keyboard }), card: true }
|
|
520
|
+
: { text: part.text, ...(part.replyTo == null ? {} : { replyTo: part.replyTo }), ...(part.keyboard == null ? {} : { keyboard: part.keyboard }) };
|
|
521
|
+
insert.run(chat.nextId("chat_part"), event, ordinal, savedChatPart(content), stamp);
|
|
522
|
+
});
|
|
523
|
+
return true;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
replyParts(conversation) {
|
|
527
|
+
return this
|
|
528
|
+
.prepare(`SELECT p.*, EXISTS (SELECT 1 FROM mate_proposal mp WHERE mp.id = json_extract(p.payload, '$.proposal')) AS proposal_live
|
|
529
|
+
FROM chat_part p WHERE p.provider = :provider AND p.event = ? ORDER BY p.ordinal`)
|
|
530
|
+
.all(`m${conversation}`).map(row => readTelegramConversationPart(row, conversation));
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Settle one send attempt under the row's claim. A confirmed message id
|
|
534
|
+
* is the only success: the part is sent, and a reply part's id becomes
|
|
535
|
+
* the row's reply id. Anything else leaves the part pending with its
|
|
536
|
+
* attempt counted, its error named, its next attempt scheduled, and —
|
|
537
|
+
* when the answer was lost rather than refused — its uncertainty counted.
|
|
538
|
+
* A lapsed claim settles nothing, so a reclaimer's outcome stands.
|
|
539
|
+
*/
|
|
540
|
+
settleReplyPart(conversation, ordinal, owner, outcome, now) {
|
|
541
|
+
return this.store.transact(() => {
|
|
542
|
+
const chat = this, stamp = now.toISOString(), event = `m${conversation}`;
|
|
543
|
+
const held = chat
|
|
544
|
+
.prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
|
|
545
|
+
.get(event, owner, stamp);
|
|
546
|
+
if (held === undefined)
|
|
547
|
+
return false;
|
|
548
|
+
const { changes } = outcome.ok
|
|
549
|
+
? chat
|
|
550
|
+
.prepare(`UPDATE chat_part SET state = 'sent', message = ?, sent = ?, attempts = attempts + 1, next_at = NULL, problem = NULL
|
|
551
|
+
WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'`)
|
|
552
|
+
.run(outcome.messageId, stamp, event, ordinal)
|
|
553
|
+
: chat
|
|
554
|
+
.prepare(`UPDATE chat_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_at = ?, problem = ?
|
|
555
|
+
WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'`)
|
|
556
|
+
.run(outcome.uncertain ? 1 : 0, outcome.retryAt, outcome.error, event, ordinal);
|
|
557
|
+
if (Number(changes) !== 1)
|
|
558
|
+
return false;
|
|
559
|
+
if (outcome.ok) {
|
|
560
|
+
const part = this.replyParts(conversation).find(one => one.ordinal === ordinal);
|
|
561
|
+
if (part?.kind === "reply")
|
|
562
|
+
chat.prepare("UPDATE chat_event SET reply = ? WHERE provider = :provider AND id = ?").run(outcome.messageId, event);
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/** A card whose proposal no longer waits (confirmed or dismissed from another surface, or gone) is moot: dropped with the reason, never sent. */
|
|
568
|
+
dropReplyPart(conversation, ordinal, owner, reason, now) {
|
|
569
|
+
const chat = this, stamp = now.toISOString(), event = `m${conversation}`;
|
|
570
|
+
const held = chat
|
|
571
|
+
.prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
|
|
572
|
+
.get(event, owner, stamp);
|
|
573
|
+
if (held === undefined)
|
|
574
|
+
return false;
|
|
575
|
+
const { changes } = chat
|
|
576
|
+
.prepare("UPDATE chat_part SET state = 'dropped', problem = ?, next_at = NULL WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'")
|
|
577
|
+
.run(reason, event, ordinal);
|
|
578
|
+
return Number(changes) === 1;
|
|
579
|
+
}
|
|
580
|
+
/** The exact task/run bindings of one outbound message this bot sent: a
|
|
581
|
+
* plain fact names one, a digest part may name several, (v64) a confirmed
|
|
582
|
+
* result image names its exact task and run, the lead's reply names the
|
|
583
|
+
* task its turn was about, a card names its task, and (v79) a status
|
|
584
|
+
* message names the task it showed. */
|
|
585
|
+
messageBindings(binding, messageId) {
|
|
586
|
+
const chat = this;
|
|
587
|
+
const facts = chat
|
|
588
|
+
.prepare(`SELECT DISTINCT task_id, task_ref, run, project FROM chat_message_ref
|
|
589
|
+
WHERE provider = :provider AND kind = 'notification' AND binding = ? AND chat = ? AND message = ? ORDER BY notification`)
|
|
590
|
+
.all(binding.id, binding.chatId, messageId)
|
|
591
|
+
.map(row => ({
|
|
592
|
+
taskId: row["task_id"] === null ? null : String(row["task_id"]),
|
|
593
|
+
taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]),
|
|
594
|
+
run: row["run"] === null ? null : (this.progressRun({ taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]), run: Number(row["run"]) })?.id ?? Number(row["run"])),
|
|
595
|
+
project: row["project"] === null ? null : String(row["project"]),
|
|
596
|
+
}));
|
|
597
|
+
// Every sent part of this bot's replies on that message, oldest first: images, the lead's replies and its cards.
|
|
598
|
+
const parts = chat
|
|
599
|
+
.prepare(`SELECT p.*, c.payload AS asked, EXISTS (SELECT 1 FROM mate_proposal mp WHERE mp.id = json_extract(p.payload, '$.proposal')) AS proposal_live
|
|
600
|
+
FROM chat_part p JOIN chat_event c ON c.provider = p.provider AND c.id = p.event
|
|
601
|
+
WHERE p.provider = :provider AND c.kind = 'message' AND c.binding = ? AND c.channel = ? AND p.message = ? AND p.state = 'sent'
|
|
602
|
+
ORDER BY CAST(substr(p.event, 2) AS INTEGER), p.ordinal`)
|
|
603
|
+
.all(binding.id, binding.chatId, messageId)
|
|
604
|
+
.map(row => ({ part: readTelegramConversationPart(row, Number(String(row["event"]).slice(1))), asked: readChatMessageBody(String(row["asked"])) }));
|
|
605
|
+
const images = parts.filter(one => one.part.kind === "image").map(({ part }) => {
|
|
606
|
+
const taskId = part.taskId;
|
|
607
|
+
const ref = this.store.lookupRef(taskId);
|
|
608
|
+
return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null };
|
|
609
|
+
});
|
|
610
|
+
// The lead's own messages: a reply from a turn that was about one task,
|
|
611
|
+
// and a card, which names its task (or, once it filed one, the new task).
|
|
612
|
+
const lead = parts.filter(one => one.part.kind !== "image").map(({ part, asked }) => part.kind === "card"
|
|
613
|
+
? proposalTaskOf(part.proposal === null ? null : this.store.getMateProposal(part.proposal))
|
|
614
|
+
: asked.ok && asked.value.about !== undefined ? { task: asked.value.about.task, run: asked.value.about.run } : null);
|
|
615
|
+
const shown = chat
|
|
616
|
+
.prepare("SELECT task_id, run FROM chat_message_ref WHERE provider = :provider AND kind = 'task' AND binding = ? AND chat = ? AND message = ?")
|
|
617
|
+
.all(binding.id, binding.chatId, messageId)
|
|
618
|
+
.map(row => ({ task: String(row["task_id"]), run: row["run"] === null ? null : Number(row["run"]) }));
|
|
619
|
+
const pointed = [...lead, ...shown].flatMap(one => {
|
|
620
|
+
if (one === null)
|
|
621
|
+
return [];
|
|
622
|
+
const ref = this.store.lookupRef(one.task);
|
|
623
|
+
return [{ taskId: one.task, taskRef: ref?.id ?? null, run: one.run, project: ref?.repo ?? null }];
|
|
624
|
+
});
|
|
625
|
+
return [...facts, ...images, ...pointed].filter((one, index, all) => all.findIndex(fact => fact.taskId === one.taskId && fact.taskRef === one.taskRef && fact.run === one.run && fact.project === one.project) === index);
|
|
626
|
+
}
|
|
627
|
+
createProposalAction(action, now) {
|
|
628
|
+
this
|
|
629
|
+
.prepare(`INSERT INTO chat_action (provider, token, binding, proposal, phase, chat, message, created, expires)
|
|
630
|
+
VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
631
|
+
.run(action.token, action.binding, action.proposal, action.phase, action.chatId, action.messageId ?? null, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString());
|
|
632
|
+
}
|
|
633
|
+
proposalAction(token) {
|
|
634
|
+
const row = this.prepare("SELECT * FROM chat_action WHERE provider = :provider AND token = ? AND proposal IS NOT NULL").get(token);
|
|
635
|
+
return row === undefined ? null : readTelegramProposalAction(row);
|
|
636
|
+
}
|
|
637
|
+
/** Consume once. False means somebody already did, or it expired. */
|
|
638
|
+
consumeProposalAction(token, now) {
|
|
639
|
+
const stamp = now.toISOString();
|
|
640
|
+
const { changes } = this
|
|
641
|
+
.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND token = ? AND proposal IS NOT NULL AND consumed IS NULL AND (expires IS NULL OR expires > ?)")
|
|
642
|
+
.run(stamp, token, stamp);
|
|
643
|
+
return Number(changes) > 0;
|
|
644
|
+
}
|
|
645
|
+
/** Kill every live token on a proposal — after it resolves, or when a challenge is cancelled. */
|
|
646
|
+
consumeProposalActions(proposal, now, phases) {
|
|
647
|
+
const filter = phases === undefined ? "" : ` AND phase IN (${phases.map(() => "?").join(",")})`;
|
|
648
|
+
this
|
|
649
|
+
.prepare(`UPDATE chat_action SET consumed = ? WHERE provider = :provider AND proposal = ? AND consumed IS NULL${filter}`)
|
|
650
|
+
.run(now.toISOString(), proposal, ...(phases ?? []));
|
|
651
|
+
}
|
|
652
|
+
/** Stamp the message a card's keyboard actually landed on. */
|
|
653
|
+
placeProposalActions(tokens, messageId) {
|
|
654
|
+
const place = this.prepare("UPDATE chat_action SET message = ? WHERE provider = :provider AND token = ?");
|
|
655
|
+
for (const token of tokens)
|
|
656
|
+
place.run(messageId, token);
|
|
657
|
+
}
|
|
658
|
+
destination(binding) {
|
|
659
|
+
return `telegram:${binding.botId}:${binding.chatId}:${binding.id}:${binding.approverGeneration}`;
|
|
660
|
+
}
|
|
661
|
+
/** A progress card belongs to one builder result, including its root review.
|
|
662
|
+
* Do not combine planners, correction children, or separate build attempts. */
|
|
663
|
+
progressRun(row) {
|
|
664
|
+
const source = row.run === null ? null : this.store.getRun(row.run);
|
|
665
|
+
const result = source?.role === "reviewer" && source.reviewAttempt != null && source.parentRun !== null
|
|
666
|
+
? this.store.getRun(source.parentRun) : source;
|
|
667
|
+
return result?.role === "builder" && result.taskRef === row.taskRef && source?.taskRef === row.taskRef ? result : null;
|
|
668
|
+
}
|
|
669
|
+
/** Reuse a confirmed message in this exact destination. A mixed digest,
|
|
670
|
+
* decision, image, or message naming another attempt is never editable. The
|
|
671
|
+
* outbound history survives a restart even if the final receipt was lost. */
|
|
672
|
+
progressMessage(binding, result) {
|
|
673
|
+
const chat = this, destination = this.destination(binding);
|
|
674
|
+
const messages = chat.prepare(`SELECT DISTINCT message FROM chat_message_ref
|
|
675
|
+
WHERE provider = :provider AND kind = 'notification' AND destination = ? AND task_ref = ? ORDER BY CAST(message AS INTEGER) DESC`)
|
|
676
|
+
.all(destination, result.taskRef);
|
|
677
|
+
for (const message of messages) {
|
|
678
|
+
const rows = chat.prepare(`SELECT n.* FROM chat_message_ref m JOIN notification n ON n.id = m.notification
|
|
679
|
+
WHERE m.provider = :provider AND m.kind = 'notification' AND m.destination = ? AND m.message = ?`).all(destination, String(message["message"]));
|
|
680
|
+
if (rows.length > 0 && rows.every(raw => {
|
|
681
|
+
const row = readNotification(raw);
|
|
682
|
+
return isTelegramProgressNotification(row) && this.progressRun(row)?.id === result.id;
|
|
683
|
+
}))
|
|
684
|
+
return String(message["message"]);
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
/** The existing outbox, with a separate receipt for this exact pairing. */
|
|
689
|
+
claimDeliveries(binding, owner, ttlMs, now, only = "all") {
|
|
690
|
+
return this.store.transact(() => {
|
|
691
|
+
const destination = this.destination(binding);
|
|
692
|
+
this.db.prepare(`INSERT OR IGNORE INTO notification_delivery (notification, destination)
|
|
693
|
+
SELECT id, ? FROM notification WHERE resolved_at IS NULL`).run(destination);
|
|
694
|
+
// Someone else's notification is settled here as skipped, never sent —
|
|
695
|
+
// and never left unsettled, where it would fence that task's later rows.
|
|
696
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
|
|
697
|
+
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
698
|
+
AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
|
|
699
|
+
// A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
|
|
700
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
|
|
701
|
+
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
702
|
+
AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
|
|
703
|
+
// Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
|
|
704
|
+
for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
705
|
+
WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
|
|
706
|
+
const row = readNotification(raw);
|
|
707
|
+
if (!this.store.pingAllowed(row, binding.approver)) {
|
|
708
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (this.store.telegramRetryAt(binding.botId) > now.toISOString())
|
|
712
|
+
return [];
|
|
713
|
+
const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
|
|
714
|
+
JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
715
|
+
WHERE n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}
|
|
716
|
+
AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)
|
|
717
|
+
AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
|
|
718
|
+
AND (? = 'all' OR d.attempts > 0 OR n.dedupe_key LIKE 'decision:%' OR n.push_class = 'attention' OR n.kind IN ('acceptance-evidence', 'acceptance-ready')
|
|
719
|
+
OR EXISTS (SELECT 1 FROM notification urgent
|
|
720
|
+
WHERE urgent.task_ref = n.task_ref AND urgent.id > n.id AND urgent.resolved_at IS NULL
|
|
721
|
+
AND (urgent.dedupe_key LIKE 'decision:%' OR urgent.push_class = 'attention' OR urgent.kind IN ('acceptance-evidence', 'acceptance-ready'))))
|
|
722
|
+
ORDER BY n.id`).all(destination, now.toISOString(), now.toISOString(), only);
|
|
723
|
+
return rows.map(row => {
|
|
724
|
+
this.db.prepare(`UPDATE notification_delivery SET claim_owner = ?, claim_expires_at = ?,
|
|
725
|
+
claim_generation = claim_generation + 1 WHERE notification = ? AND destination = ?`)
|
|
726
|
+
.run(owner, new Date(now.getTime() + ttlMs).toISOString(), Number(row["id"]), destination);
|
|
727
|
+
return { ...readNotification(row), destination, claimGeneration: Number(row["claim_generation"]) + 1 };
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
/** Synchronous final fence, called AFTER each asynchronous enrollment read. */
|
|
732
|
+
deliveryProblem(row, binding, owner, projects, now, batch = [row.id]) {
|
|
733
|
+
const live = this.store.liveTelegramBindingById(binding.id);
|
|
734
|
+
if (live === null || live.approverGeneration !== binding.approverGeneration ||
|
|
735
|
+
this.store.accountOf(binding.approver)?.role !== "approver")
|
|
736
|
+
return TELEGRAM_HOLD_REASONS.authority;
|
|
737
|
+
if (row.destination !== this.destination(live))
|
|
738
|
+
return TELEGRAM_HOLD_REASONS.destination;
|
|
739
|
+
if (!this.claimHeld(row, owner, now))
|
|
740
|
+
return TELEGRAM_HOLD_REASONS.claim;
|
|
741
|
+
if (this.store.telegramRetryAt(binding.botId) > now.toISOString())
|
|
742
|
+
return TELEGRAM_HOLD_REASONS.rateLimit;
|
|
743
|
+
const current = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(row.id);
|
|
744
|
+
if (current === undefined || current["resolved_at"] !== null)
|
|
745
|
+
return TELEGRAM_HOLD_REASONS.resolved;
|
|
746
|
+
if (row.scope === "unknown")
|
|
747
|
+
return TELEGRAM_HOLD_REASONS.provenance;
|
|
748
|
+
if (row.scope === "installation") {
|
|
749
|
+
// One addressed to this person (their own budget, v105) reaches them; the rest of the installation's are an operator's.
|
|
750
|
+
if (!this.store.isInstanceOperator(binding.approver) && current["recipient"] !== binding.approver)
|
|
751
|
+
return TELEGRAM_HOLD_REASONS.installation;
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
if (row.project === null || !projects.includes(row.project) || !this.store.accountCanAccess(binding.approver, row.project))
|
|
755
|
+
return TELEGRAM_HOLD_REASONS.project;
|
|
756
|
+
if (row.scope === "task") {
|
|
757
|
+
const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(row.taskRef);
|
|
758
|
+
if (ref === undefined || ref["repo"] !== row.project || ref["external_id"] !== row.taskId ||
|
|
759
|
+
(row.run !== null && this.store.getRun(row.run)?.taskRef !== row.taskRef))
|
|
760
|
+
return TELEGRAM_HOLD_REASONS.task;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (row.taskRef !== null) {
|
|
764
|
+
// Skipped history never fences what follows it: it is settled.
|
|
765
|
+
const earlier = this.db.prepare(`SELECT n.id FROM notification n
|
|
766
|
+
LEFT JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
767
|
+
WHERE n.task_ref = ? AND n.id < ? AND n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}`)
|
|
768
|
+
.all(row.destination, row.taskRef, row.id);
|
|
769
|
+
if (earlier.some(one => !batch.includes(Number(one["id"]))))
|
|
770
|
+
return TELEGRAM_HOLD_REASONS.order;
|
|
771
|
+
}
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
claimHeld(row, owner, now) {
|
|
775
|
+
return this.db.prepare(`SELECT 1 FROM notification_delivery WHERE notification = ? AND destination = ?
|
|
776
|
+
AND claim_owner = ? AND claim_generation = ? AND claim_expires_at > ? AND delivered_at IS NULL`)
|
|
777
|
+
.get(row.id, row.destination, owner, row.claimGeneration, now.toISOString()) !== undefined;
|
|
778
|
+
}
|
|
779
|
+
/** A confirmed network message remains history even if authority changed
|
|
780
|
+
* while it was in flight. It only binds the OLD destination, never new work. */
|
|
781
|
+
recordMessage(row, binding, messageId, now) {
|
|
782
|
+
if (row.destination !== this.destination(binding))
|
|
783
|
+
throw new Error("Telegram message destination mismatch");
|
|
784
|
+
this.prepare(`INSERT OR IGNORE INTO chat_message_ref
|
|
785
|
+
(provider, binding, chat, message, kind, notification, destination, project, task_ref, task_id, run, created)
|
|
786
|
+
VALUES (:provider, ?, ?, ?, 'notification', ?, ?, ?, ?, ?, ?, ?)`)
|
|
787
|
+
.run(binding.id, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
|
|
788
|
+
}
|
|
789
|
+
/** The newest confirmed message this notification became at one destination, if any. */
|
|
790
|
+
messageOf(notification, destination) {
|
|
791
|
+
const row = this.prepare("SELECT message FROM chat_message_ref WHERE provider = :provider AND kind = 'notification' AND notification = ? AND destination = ? ORDER BY CAST(message AS INTEGER) DESC LIMIT 1").get(notification, destination);
|
|
792
|
+
return row === undefined ? null : String(row["message"]);
|
|
793
|
+
}
|
|
794
|
+
finalizeDelivery(row, binding, owner, outcome, now) {
|
|
795
|
+
return this.store.transact(() => {
|
|
796
|
+
if (!this.claimHeld(row, owner, now) || row.destination !== this.destination(binding))
|
|
797
|
+
return false;
|
|
798
|
+
if (outcome.ok && (this.store.liveTelegramBindingById(binding.id) === null || this.store.accountOf(binding.approver)?.role !== "approver"))
|
|
799
|
+
return false;
|
|
800
|
+
// A `skipped:` receipt settles the row without a delivery: nothing
|
|
801
|
+
// reached the phone, so delivered_at stays empty exactly as it does
|
|
802
|
+
// for the pairing skip (TELEGRAM_UNSETTLED treats both as settled).
|
|
803
|
+
const skipped = outcome.ok && outcome.receipt !== null && outcome.receipt.startsWith("skipped:");
|
|
804
|
+
this.db.prepare(`UPDATE notification_delivery SET attempts = attempts + 1, last_attempt_at = ?,
|
|
805
|
+
delivered_at = ?, receipt = ?, last_error = ?, next_attempt_at = ?, claim_owner = NULL, claim_expires_at = NULL
|
|
806
|
+
WHERE notification = ? AND destination = ? AND claim_owner = ? AND claim_generation = ?`)
|
|
807
|
+
.run(now.toISOString(), outcome.ok && !skipped ? now.toISOString() : null, outcome.ok ? outcome.receipt : null, outcome.ok ? null : outcome.error, outcome.ok ? null : outcome.retryAt ?? new Date(now.getTime() + 1_000).toISOString(), row.id, row.destination, owner, row.claimGeneration);
|
|
808
|
+
return true;
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
/** The notifications this pairing holds, each with its delivery state there. */
|
|
812
|
+
deliveries(binding) {
|
|
813
|
+
return this.db.prepare(`SELECT n.*, d.attempts, d.last_attempt_at, d.last_error, d.delivered_at, d.receipt
|
|
814
|
+
FROM notification n JOIN notification_delivery d ON d.notification = n.id WHERE d.destination = ? ORDER BY n.id`)
|
|
815
|
+
.all(this.destination(binding)).map(readNotificationReceipt);
|
|
816
|
+
}
|
|
817
|
+
/** The digest cadence: null everyMs = off. */
|
|
818
|
+
digest() {
|
|
819
|
+
const row = this.prepare("SELECT every_ms, set_by, set_at, last_sent_at FROM chat_digest WHERE provider = :provider").get();
|
|
820
|
+
return {
|
|
821
|
+
everyMs: row?.["every_ms"] === null || row?.["every_ms"] === undefined ? null : Number(row["every_ms"]),
|
|
822
|
+
setBy: row?.["set_by"] === null || row?.["set_by"] === undefined ? null : String(row["set_by"]),
|
|
823
|
+
setAt: row?.["set_at"] === null || row?.["set_at"] === undefined ? null : String(row["set_at"]),
|
|
824
|
+
lastSentAt: row?.["last_sent_at"] === null || row?.["last_sent_at"] === undefined ? null : String(row["last_sent_at"]),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/** Set (or clear, with null) the cadence. Turning it on starts the
|
|
828
|
+
* window NOW — nothing held before the choice is dumped at once. */
|
|
829
|
+
setDigest(everyMs, by, now) {
|
|
830
|
+
const chat = this;
|
|
831
|
+
chat.prepare("INSERT OR IGNORE INTO chat_digest (provider) VALUES (:provider)").run();
|
|
832
|
+
chat
|
|
833
|
+
.prepare("UPDATE chat_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE provider = :provider")
|
|
834
|
+
.run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
|
|
835
|
+
}
|
|
836
|
+
markDigestSent(now) {
|
|
837
|
+
const chat = this;
|
|
838
|
+
chat.prepare("INSERT OR IGNORE INTO chat_digest (provider) VALUES (:provider)").run();
|
|
839
|
+
chat.prepare("UPDATE chat_digest SET last_sent_at = ? WHERE provider = :provider").run(now.toISOString());
|
|
840
|
+
}
|
|
841
|
+
}
|