toolroll 0.9.53 → 0.9.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/chat-ask.js +8 -8
- package/dist/chat-core.d.ts +171 -0
- package/dist/chat-core.js +288 -0
- package/dist/chat-decide.d.ts +10 -8
- package/dist/chat-decide.js +26 -21
- package/dist/chat-delivery-state.d.ts +51 -75
- package/dist/chat-delivery-state.js +140 -170
- package/dist/chat-delivery.d.ts +21 -2
- package/dist/chat-delivery.js +313 -194
- package/dist/chat-flow.d.ts +29 -1
- package/dist/chat-flow.js +24 -50
- package/dist/chat-messages.d.ts +319 -0
- package/dist/chat-messages.js +841 -0
- package/dist/chat-migration.d.ts +30 -0
- package/dist/chat-migration.js +802 -0
- package/dist/chat-quiet.d.ts +3 -0
- package/dist/chat-quiet.js +5 -0
- package/dist/contracts/chat-content.d.ts +71 -2
- package/dist/contracts/chat-content.js +22 -3
- package/dist/contracts/chat-tables.d.ts +11 -4
- package/dist/contracts/chat-tables.js +165 -87
- package/dist/contracts/discord-callback.js +2 -1
- package/dist/contracts/slack-callback.js +3 -2
- package/dist/contracts/store-json.d.ts +1 -5
- package/dist/contracts/store-json.js +1 -4
- package/dist/contracts/teams-callback.js +2 -1
- package/dist/discord-chat.js +19 -38
- package/dist/discord-settings.js +4 -13
- package/dist/discord.js +4 -14
- package/dist/export.js +4 -4
- package/dist/mate-tools.js +3 -2
- package/dist/modes.d.ts +13 -1
- package/dist/modes.js +17 -1
- package/dist/notification-rows.d.ts +30 -0
- package/dist/notification-rows.js +102 -0
- package/dist/operate.js +5 -2
- package/dist/plane-review.js +5 -11
- package/dist/project-delete.js +21 -26
- package/dist/result-shots.js +2 -2
- package/dist/retention.js +1 -1
- package/dist/server/settings.js +5 -4
- package/dist/slack-api.d.ts +5 -1
- package/dist/slack-api.js +2 -2
- package/dist/slack-chat.d.ts +9 -4
- package/dist/slack-chat.js +29 -52
- package/dist/slack-settings.js +6 -15
- package/dist/slack.js +6 -16
- package/dist/store.d.ts +97 -364
- package/dist/store.js +207 -1531
- package/dist/teammate-question.js +12 -12
- package/dist/teams-chat.js +12 -9
- package/dist/teams-settings.js +2 -2
- package/dist/teams.js +4 -5
- package/dist/telegram-flow.js +5 -5
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update.d.ts +10 -0
- package/dist/toolroll-update.js +50 -3
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
- package/dist/slack-state.d.ts +0 -13
- package/dist/slack-state.js +0 -10
|
@@ -1,210 +1,180 @@
|
|
|
1
|
-
/** Durable
|
|
2
|
-
import {
|
|
1
|
+
/** Durable chat receipts on the shared chat core: what each app's chats sent us and what we plan to send them. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { MATE_ASK_TTL_MS } from "./store.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ChatCore, chatHash } from "./chat-core.js";
|
|
5
|
+
import { ChatMessages } from "./chat-messages.js";
|
|
5
6
|
import { readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
|
|
6
|
-
export { savedChatPart };
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export class ChatState {
|
|
10
|
-
store
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this.channel = channel;
|
|
15
|
-
}
|
|
16
|
-
prepare(sql) {
|
|
17
|
-
return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|flow_choice|flow_note|question_action|question_prompt|ask_action)\b/g, `${this.channel}_$1`));
|
|
18
|
-
}
|
|
19
|
-
/** Small transport facts a channel must remember per chat (a Teams service URL). Never credentials. */
|
|
20
|
-
meta(installation, key) {
|
|
21
|
-
const row = this.prepare("SELECT value FROM chat_meta WHERE installation=? AND key=?").get(installation, key);
|
|
22
|
-
return row === undefined ? null : String(row.value);
|
|
23
|
-
}
|
|
24
|
-
setMeta(installation, key, value, now) {
|
|
25
|
-
this.prepare("INSERT INTO chat_meta(installation,key,value,updated) VALUES(?,?,?,?) ON CONFLICT(installation,key) DO UPDATE SET value=excluded.value,updated=excluded.updated").run(installation, key, value, now.toISOString());
|
|
26
|
-
}
|
|
27
|
-
// ---- rooms (v73) -----------------------------------------------------------
|
|
28
|
-
readRoom(row) {
|
|
29
|
-
return { id: Number(row.id), installation: String(row.installation), chat: String(row.chat), kind: row.kind === "group" ? "group" : "private",
|
|
30
|
-
conversation: String(row.conversation), boundBy: String(row.bound_by), binding: Number(row.binding), cursor: Number(row.cursor) };
|
|
31
|
-
}
|
|
32
|
-
room(installation, chat) {
|
|
33
|
-
const row = this.prepare("SELECT * FROM chat_room WHERE installation=? AND chat=? AND revoked IS NULL").get(installation, chat);
|
|
34
|
-
return row === undefined ? null : this.readRoom(row);
|
|
35
|
-
}
|
|
36
|
-
rooms(installation) {
|
|
37
|
-
return this.prepare("SELECT * FROM chat_room WHERE installation=? AND revoked IS NULL ORDER BY id").all(installation).map(row => this.readRoom(row));
|
|
38
|
-
}
|
|
39
|
-
/** Point a chat at a conversation; an earlier choice for the same chat is revoked and delivery starts from now. */
|
|
40
|
-
bindRoom(args, now) {
|
|
41
|
-
return this.store.transact(() => {
|
|
42
|
-
const stamp = now.toISOString();
|
|
43
|
-
// Check the expected collision before revoking the current selection.
|
|
44
|
-
if (args.kind === "group") {
|
|
45
|
-
const group = this.prepare("SELECT installation, chat FROM chat_room WHERE conversation=? AND kind='group' AND revoked IS NULL").get(args.conversation);
|
|
46
|
-
if (group !== undefined && (group.installation !== args.installation || group.chat !== args.chat))
|
|
47
|
-
return { ok: false, reason: "group-taken" };
|
|
48
|
-
}
|
|
49
|
-
this.prepare("UPDATE chat_room SET revoked=?,revoked_by=? WHERE installation=? AND chat=? AND revoked IS NULL").run(stamp, args.by, args.installation, args.chat);
|
|
50
|
-
const thread = this.db.prepare("SELECT thread FROM team_conversation WHERE id=?").get(args.conversation);
|
|
51
|
-
if (thread === undefined)
|
|
52
|
-
throw new Error("no such team conversation");
|
|
53
|
-
const cursor = Number(this.db.prepare("SELECT COALESCE(MAX(id),0) AS n FROM mate_message WHERE thread=?").get(Number(thread["thread"]))?.["n"] ?? 0);
|
|
54
|
-
this.prepare("INSERT INTO chat_room(installation,chat,kind,conversation,binding,bound_by,bound,cursor) VALUES(?,?,?,?,?,?,?,?)").run(args.installation, args.chat, args.kind, args.conversation, args.binding, args.by, stamp, cursor);
|
|
55
|
-
return { ok: true };
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
unbindRoom(installation, chat, by, now) {
|
|
59
|
-
return Number(this.prepare("UPDATE chat_room SET revoked=?,revoked_by=? WHERE installation=? AND chat=? AND revoked IS NULL").run(now.toISOString(), by, installation, chat).changes) > 0;
|
|
60
|
-
}
|
|
61
|
-
/** The shared rooms core's view of this installation's room records. */
|
|
62
|
-
roomBackend(installation, now) {
|
|
63
|
-
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 };
|
|
64
|
-
return {
|
|
65
|
-
room: chat => roomOf(this.room(installation, chat)),
|
|
66
|
-
bind: args => this.bindRoom({ installation, chat: args.chatId, kind: args.kind, conversation: args.conversation, by: args.by, binding: args.binding }, now),
|
|
67
|
-
unbind: (chat, by) => this.unbindRoom(installation, chat, by, now),
|
|
68
|
-
grant: id => { const binding = this.bindingById(id); return binding === null || !this.live(binding) ? null : { approver: binding.approver, generation: binding.generation, chatId: binding.channel }; },
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
advanceRoomCursor(id, messageId) {
|
|
72
|
-
return Number(this.prepare("UPDATE chat_room SET cursor=? WHERE id=? AND cursor<? AND revoked IS NULL").run(messageId, id, messageId).changes) > 0;
|
|
73
|
-
}
|
|
74
|
-
get db() {
|
|
75
|
-
return this.store.handle;
|
|
76
|
-
}
|
|
77
|
-
/** Every unrevoked binding for an installation, oldest first: one per
|
|
78
|
-
* paired person (v73). Liveness is checked per binding with `live`. */
|
|
79
|
-
bindings(installation) {
|
|
80
|
-
return this.prepare("SELECT * FROM chat_binding WHERE installation=? AND revoked IS NULL ORDER BY id").all(installation);
|
|
81
|
-
}
|
|
82
|
-
/** The unrevoked binding one channel member holds, if any. */
|
|
83
|
-
bindingFor(installation, member) {
|
|
84
|
-
return (this.prepare("SELECT * FROM chat_binding WHERE installation=? AND member=? AND revoked IS NULL").get(installation, member) ?? null);
|
|
85
|
-
}
|
|
86
|
-
bindingById(id) {
|
|
87
|
-
return (this.prepare("SELECT * FROM chat_binding WHERE id=? AND revoked IS NULL").get(id) ?? null);
|
|
88
|
-
}
|
|
89
|
-
/** The oldest unrevoked binding — the single-person reading kept for
|
|
90
|
-
* status lines and fixtures; inbound, delivery and notices read
|
|
91
|
-
* `bindingFor` / `bindingById` / `bindings`. */
|
|
92
|
-
binding(installation) {
|
|
93
|
-
return this.bindings(installation)[0] ?? null;
|
|
94
|
-
}
|
|
95
|
-
live(binding) {
|
|
96
|
-
const current = this.bindingById(binding.id);
|
|
97
|
-
const account = this.store.accountOf(binding.approver);
|
|
98
|
-
return (current !== null &&
|
|
99
|
-
account?.role === "approver" &&
|
|
100
|
-
account.revokedAt === null &&
|
|
101
|
-
account.generation === binding.generation);
|
|
102
|
-
}
|
|
103
|
-
/** End one person's pairing and everything their chat could still do; teammates' bindings stay. */
|
|
104
|
-
revokeBinding(binding, now = new Date()) {
|
|
105
|
-
this.store.transact(() => {
|
|
106
|
-
this.prepare("UPDATE chat_binding SET revoked=? WHERE id=? AND revoked IS NULL").run(now.toISOString(), binding.id);
|
|
107
|
-
this.prepare("UPDATE chat_event SET state='dropped',payload='{}',problem='Chat disconnected' WHERE binding=? AND state='queued'").run(binding.id);
|
|
108
|
-
this.prepare("UPDATE chat_part SET state='dropped',problem='Chat disconnected' WHERE state='pending' AND event IN (SELECT id FROM chat_event WHERE binding=?)").run(binding.id);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
revoke(installation, now = new Date()) {
|
|
112
|
-
this.store.transact(() => {
|
|
113
|
-
this.prepare("UPDATE chat_binding SET revoked=? WHERE installation=? AND revoked IS NULL").run(now.toISOString(), installation);
|
|
114
|
-
this.prepare("UPDATE chat_pair SET consumed=? WHERE installation=? AND consumed IS NULL").run(now.toISOString(), installation);
|
|
115
|
-
this.prepare("UPDATE chat_event SET state='dropped',payload='{}',problem='Chat disconnected' WHERE installation=? AND state='queued'").run(installation);
|
|
116
|
-
this.prepare("UPDATE chat_part SET state='dropped',problem='Chat disconnected' WHERE state='pending' AND event IN (SELECT id FROM chat_event WHERE installation=?)").run(installation);
|
|
117
|
-
});
|
|
118
|
-
}
|
|
7
|
+
export { savedChatPart, chatHash };
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
/** One app's chats on the shared core (the same object Telegram's run on): its events, planned parts and their buttons. */
|
|
10
|
+
export class ChatState extends ChatMessages {
|
|
11
|
+
constructor(store, provider) {
|
|
12
|
+
super(store, provider);
|
|
13
|
+
}
|
|
14
|
+
/** Pair-time half kept for the settings pages: mint a code for this installation and return it (only its hash is kept). */
|
|
119
15
|
pairing(installation, approver, generation, now = new Date()) {
|
|
120
16
|
const code = randomBytes(16).toString("hex");
|
|
121
|
-
this.
|
|
122
|
-
// A fresh code replaces this person's outstanding ones; a teammate's pending code is theirs.
|
|
123
|
-
this.prepare("UPDATE chat_pair SET consumed=? WHERE installation=? AND approver=? AND consumed IS NULL").run(now.toISOString(), installation, approver);
|
|
124
|
-
this.prepare("INSERT INTO chat_pair VALUES(?,?,?,?,?,NULL)").run(chatHash(code), installation, approver, generation, new Date(now.getTime() + 600_000).toISOString());
|
|
125
|
-
});
|
|
17
|
+
this.savePairing({ hash: chatHash(code), installation, approver, generation, by: approver }, now);
|
|
126
18
|
return code;
|
|
127
19
|
}
|
|
128
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Consume a code sent in a chat (the shared rules, chat-core.ts). The installation's notice cursor starts from now
|
|
22
|
+
* on its FIRST pairing; a later person joins the running cursor (their own history is fenced by their binding's
|
|
23
|
+
* creation time).
|
|
24
|
+
*/
|
|
25
|
+
pair(identity, hash, member, channel, now, event = null) {
|
|
129
26
|
return this.store.transact(() => {
|
|
130
|
-
const
|
|
131
|
-
if (!
|
|
27
|
+
const paired = this.consumePairing({ hash, installation: identity.installation, team: identity.team ?? identity.app, app: identity.app, member, channel, event }, now);
|
|
28
|
+
if (!paired.ok)
|
|
132
29
|
return null;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (this.bindings(identity.installation).some(one => one.approver === String(pair.approver) && this.live(one)))
|
|
137
|
-
return null;
|
|
138
|
-
const account = this.store.accountOf(String(pair.approver));
|
|
139
|
-
if (account?.role !== "approver" ||
|
|
140
|
-
account.revokedAt !== null ||
|
|
141
|
-
account.generation !== Number(pair.generation))
|
|
142
|
-
return null;
|
|
143
|
-
this.prepare("UPDATE chat_pair SET consumed=? WHERE hash=?").run(now.toISOString(), hash);
|
|
144
|
-
this.prepare("INSERT INTO chat_binding(installation,team,app,member,channel,approver,generation,created) VALUES(?,?,?,?,?,?,?,?)").run(identity.installation, identity.team ?? identity.app, identity.app, member, channel, String(pair.approver), Number(pair.generation), now.toISOString());
|
|
145
|
-
// The installation's notice cursor starts from now on its FIRST pairing;
|
|
146
|
-
// a later person joins the running cursor (their own history is fenced
|
|
147
|
-
// by their binding's creation time).
|
|
148
|
-
this.prepare("INSERT OR IGNORE INTO chat_runtime(installation, notification) VALUES(?, (SELECT COALESCE(MAX(id),0) FROM notification))").run(identity.installation);
|
|
149
|
-
return this.bindingFor(identity.installation, member);
|
|
30
|
+
this.prepare("INSERT OR IGNORE INTO chat_runtime (provider, installation, notification) VALUES (:provider, ?, (SELECT COALESCE(MAX(id), 0) FROM notification))")
|
|
31
|
+
.run(identity.installation);
|
|
32
|
+
return paired.binding;
|
|
150
33
|
});
|
|
151
34
|
}
|
|
152
|
-
/**
|
|
35
|
+
/** The 60-second lease the Slack, Discord and Teams workers renew. */
|
|
36
|
+
lease(installation, owner, now) {
|
|
37
|
+
return this.acquire(installation, owner, 60_000, now).ok;
|
|
38
|
+
}
|
|
39
|
+
/** Save a received event with its body (contracts/chat-content.ts), versioned. False: it was already here. */
|
|
153
40
|
enqueue(event) {
|
|
154
|
-
return
|
|
41
|
+
return Number(this.prepare(`INSERT OR IGNORE INTO chat_event (provider, id, installation, binding, kind, channel, member, ts, thread, payload, created)
|
|
42
|
+
VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
43
|
+
.run(event.id, event.installation, event.binding, event.kind, event.channel, event.member, event.ts, event.thread, savedChatEventBody(event.payload), event.created).changes) === 1;
|
|
155
44
|
}
|
|
156
45
|
event(id) {
|
|
157
|
-
return
|
|
46
|
+
return this.prepare("SELECT * FROM chat_event WHERE provider = :provider AND id = ?").get(id) ?? null;
|
|
158
47
|
}
|
|
159
48
|
next(installation, now) {
|
|
160
|
-
return
|
|
49
|
+
return this.prepare(`SELECT * FROM chat_event WHERE provider = :provider AND installation = ? AND kind <> 'update' AND state = 'queued'
|
|
50
|
+
AND (next_at IS NULL OR next_at <= ?) ORDER BY created, id LIMIT 1`).get(installation, now.toISOString()) ?? null;
|
|
161
51
|
}
|
|
162
52
|
defer(event, problem, until) {
|
|
163
|
-
this.prepare("UPDATE chat_event SET next_at
|
|
53
|
+
this.prepare("UPDATE chat_event SET next_at = ?, problem = ? WHERE provider = :provider AND id = ? AND state = 'queued'").run(until.toISOString(), problem, event);
|
|
164
54
|
}
|
|
165
55
|
finish(event, dropped = false) {
|
|
166
|
-
this.prepare("UPDATE chat_event SET state
|
|
56
|
+
this.prepare("UPDATE chat_event SET state = ?, payload = '{}' WHERE provider = :provider AND id = ?").run(dropped ? "dropped" : "done", event);
|
|
57
|
+
}
|
|
58
|
+
/** Point an event at the binding its pairing made. */
|
|
59
|
+
setEventBinding(event, binding) {
|
|
60
|
+
this.prepare("UPDATE chat_event SET binding = ? WHERE provider = :provider AND id = ?").run(binding, event);
|
|
167
61
|
}
|
|
168
62
|
part(id) {
|
|
169
|
-
return
|
|
63
|
+
return this.prepare("SELECT * FROM chat_part WHERE provider = :provider AND id = ?").get(id) ?? null;
|
|
170
64
|
}
|
|
65
|
+
/** The reply to an event, part by part, with each part's buttons minted with it; the event is then handled. Planned once. */
|
|
171
66
|
plan(event, parts, now) {
|
|
172
67
|
this.store.transact(() => {
|
|
173
68
|
for (const [ordinal, part] of parts.entries()) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
69
|
+
if (this.prepare("SELECT 1 AS hit FROM chat_part WHERE provider = :provider AND event = ? AND ordinal = ?").get(event, ordinal) !== undefined)
|
|
70
|
+
continue;
|
|
71
|
+
const id = this.nextId("chat_part");
|
|
72
|
+
this.prepare("INSERT INTO chat_part (provider, id, event, ordinal, payload, created) VALUES (:provider, ?, ?, ?, ?, ?)").run(id, event, ordinal, savedChatPart(part), now.toISOString());
|
|
73
|
+
const token = () => randomBytes(16).toString("hex");
|
|
74
|
+
if (part.proposal)
|
|
75
|
+
this.tokens(id, part.proposal, ["confirm", "dismiss"], now);
|
|
76
|
+
if (part.question)
|
|
178
77
|
for (const one of part.question.choices)
|
|
179
|
-
this.prepare("INSERT INTO chat_question_action(token,part,question,choice,expires) VALUES
|
|
180
|
-
|
|
78
|
+
this.prepare("INSERT INTO chat_question_action (provider, token, part, question, choice, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?)")
|
|
79
|
+
.run(token(), id, part.question.id, one.choice, now.toISOString(), new Date(now.getTime() + 7 * DAY_MS).toISOString());
|
|
80
|
+
if (part.ask)
|
|
181
81
|
for (const choice of [...part.ask.options.map((_, index) => index), null])
|
|
182
|
-
this.prepare("INSERT INTO chat_ask_action(token,part,turn,choice,expires) VALUES
|
|
183
|
-
|
|
82
|
+
this.prepare("INSERT INTO chat_ask_action (provider, token, part, turn, choice, expires) VALUES (:provider, ?, ?, ?, ?, ?)")
|
|
83
|
+
.run(token(), id, part.ask.turn, choice, new Date(now.getTime() + MATE_ASK_TTL_MS).toISOString());
|
|
84
|
+
if (part.choose)
|
|
184
85
|
for (const one of part.choose.options)
|
|
185
|
-
this.prepare("INSERT INTO chat_flow_choice(token,part,card,entry,choice,label,expires) VALUES
|
|
186
|
-
|
|
86
|
+
this.prepare("INSERT INTO chat_flow_choice (provider, token, part, card, entry, choice, label, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
87
|
+
.run(token(), id, part.choose.card, part.choose.entry, one.choice, one.label, now.toISOString(), new Date(now.getTime() + 7 * DAY_MS).toISOString());
|
|
88
|
+
if (part.note)
|
|
187
89
|
for (const answer of ["yes", "no"])
|
|
188
|
-
this.prepare("INSERT INTO chat_flow_note(token,part,card,entry,held,answer,expires) VALUES
|
|
189
|
-
|
|
90
|
+
this.prepare("INSERT INTO chat_flow_note (provider, token, part, card, entry, held, answer, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?)")
|
|
91
|
+
.run(token(), id, part.note.card, part.note.entry, part.note.held, answer, new Date(now.getTime() + DAY_MS).toISOString());
|
|
92
|
+
if (part.flow)
|
|
190
93
|
for (const action of part.flow.actions)
|
|
191
|
-
this.prepare("INSERT INTO chat_flow_action(token,part,card,entry,action,expires) VALUES
|
|
94
|
+
this.prepare("INSERT INTO chat_flow_action (provider, token, part, card, entry, action, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?)")
|
|
95
|
+
.run(token(), id, part.flow.card, part.flow.entry, action, now.toISOString(), new Date(now.getTime() + 7 * DAY_MS).toISOString());
|
|
192
96
|
}
|
|
193
97
|
this.finish(event);
|
|
194
98
|
});
|
|
195
99
|
}
|
|
100
|
+
/** A proposal card's buttons on its part: earlier ones on that part are spent. Yes and Cancel last ten minutes. */
|
|
196
101
|
tokens(part, proposal, phases, now) {
|
|
197
|
-
this.prepare("UPDATE chat_action SET consumed
|
|
102
|
+
this.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND part = ? AND consumed IS NULL").run(now.toISOString(), part);
|
|
198
103
|
for (const phase of phases)
|
|
199
|
-
this.prepare("INSERT INTO chat_action VALUES(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.prepare("
|
|
204
|
-
|
|
104
|
+
this.prepare("INSERT INTO chat_action (provider, token, part, proposal, phase, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?)").run(randomBytes(16).toString("hex"), part, proposal, phase, now.toISOString(), new Date(now.getTime() + (phase === "yes" || phase === "cancel" ? 600_000 : DAY_MS)).toISOString());
|
|
105
|
+
}
|
|
106
|
+
/** A proposal card's live buttons on one part, in the order they were minted. */
|
|
107
|
+
partTokens(part, now) {
|
|
108
|
+
return this.prepare("SELECT token, phase FROM chat_action WHERE provider = :provider AND part = ? AND consumed IS NULL AND expires > ? ORDER BY rowid")
|
|
109
|
+
.all(part, now.toISOString()).map(row => ({ token: String(row["token"]), phase: String(row["phase"]) }));
|
|
110
|
+
}
|
|
111
|
+
// ---- sending, the same for every app's worker ----------------------------------------------------------------------
|
|
112
|
+
/** The next part to send for an installation: notices only while notifications may go out. */
|
|
113
|
+
nextPart(installation, notices, now, order = "id") {
|
|
114
|
+
return this.prepare(`SELECT p.* FROM chat_part p JOIN chat_event e ON e.provider = p.provider AND e.id = p.event
|
|
115
|
+
WHERE p.provider = :provider AND e.installation = ? AND p.state = 'pending' AND (e.kind <> 'notice' OR ? = 1)
|
|
116
|
+
AND (p.next_at IS NULL OR p.next_at <= ?) ORDER BY ${order === "created" ? "p.created, " : ""}p.id LIMIT 1`)
|
|
117
|
+
.get(installation, notices ? 1 : 0, now.toISOString()) ?? null;
|
|
118
|
+
}
|
|
119
|
+
/** A part that will not go: why, in words the saved chat shows. */
|
|
120
|
+
dropPart(id, problem) {
|
|
121
|
+
this.prepare("UPDATE chat_part SET state = 'dropped', next_at = NULL, problem = ? WHERE provider = :provider AND id = ?").run(problem, id);
|
|
122
|
+
}
|
|
123
|
+
/** Try again no earlier than then (a screenshot waiting for its result's message). */
|
|
124
|
+
holdPart(id, until) {
|
|
125
|
+
this.prepare("UPDATE chat_part SET next_at = ? WHERE provider = :provider AND id = ?").run(until, id);
|
|
126
|
+
}
|
|
127
|
+
/** The app's upload identity for a part's file, and whether its bytes are up. */
|
|
128
|
+
setPartFile(id, file, uploaded, message) {
|
|
129
|
+
if (message !== undefined)
|
|
130
|
+
this.prepare("UPDATE chat_part SET message = ? WHERE provider = :provider AND id = ?").run(message, id);
|
|
131
|
+
if (file !== null)
|
|
132
|
+
this.prepare("UPDATE chat_part SET file = ? WHERE provider = :provider AND id = ?").run(file, id);
|
|
133
|
+
if (uploaded !== undefined)
|
|
134
|
+
this.prepare("UPDATE chat_part SET uploaded = ? WHERE provider = :provider AND id = ?").run(uploaded ? 1 : 0, id);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Sent: on the app's own message id when it gave one (an uploaded file may not). A card's decide buttons
|
|
138
|
+
* (chat-decide.ts) now ride that message, and any older ones on it stop working; a "What should change?" names it.
|
|
139
|
+
*/
|
|
140
|
+
partSent(id, message, now, attempt = true) {
|
|
141
|
+
this.store.transact(() => {
|
|
142
|
+
this.prepare(`UPDATE chat_part SET state = 'sent', message = COALESCE(?, message), attempts = attempts + ?, problem = NULL, next_at = NULL, sent = ?
|
|
143
|
+
WHERE provider = :provider AND id = ?`).run(message, attempt ? 1 : 0, now.toISOString(), id);
|
|
144
|
+
const row = this.prepare(`SELECT p.payload, p.message, b.channel AS own FROM chat_part p JOIN chat_event e ON e.provider = p.provider AND e.id = p.event
|
|
145
|
+
LEFT JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding WHERE p.provider = :provider AND p.id = ?`).get(id);
|
|
146
|
+
const read = row === undefined ? null : readChatPart(String(row["payload"]));
|
|
147
|
+
const placed = row?.["message"] == null ? null : String(row["message"]);
|
|
148
|
+
if (read?.ok !== true || placed === null)
|
|
149
|
+
return;
|
|
150
|
+
const content = read.value, chat = content.channel ?? (row?.["own"] == null ? null : String(row["own"]));
|
|
151
|
+
if (chat !== null) {
|
|
152
|
+
// What the message shows now is all that acts on it: buttons it no longer shows stop working.
|
|
153
|
+
const tokens = JSON.stringify((content.decide?.rows ?? []).flat().flatMap(one => "token" in one ? [one.token] : []));
|
|
154
|
+
this.prepare(`UPDATE chat_decide_action SET consumed_at = ? WHERE channel = :provider AND chat = ? AND message = ? AND consumed_at IS NULL
|
|
155
|
+
AND token NOT IN (SELECT value FROM json_each(?))`).run(now.toISOString(), chat, placed, tokens);
|
|
156
|
+
if (content.decide !== undefined)
|
|
157
|
+
this.prepare("UPDATE chat_decide_action SET message = ? WHERE channel = :provider AND message IS NULL AND token IN (SELECT value FROM json_each(?))").run(placed, tokens);
|
|
158
|
+
}
|
|
159
|
+
if (content.prompt !== undefined)
|
|
160
|
+
this.prepare("UPDATE chat_decide_prompt SET message = ? WHERE channel = :provider AND id = ? AND message IS NULL").run(placed, content.prompt);
|
|
161
|
+
});
|
|
205
162
|
}
|
|
206
|
-
|
|
207
|
-
|
|
163
|
+
/** Not sent this time: counted, named, and (when the answer was lost rather than refused) counted as uncertain. */
|
|
164
|
+
partFailed(id, failure, giveUpAfter) {
|
|
165
|
+
this.prepare(`UPDATE chat_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_at = ?, problem = ?,
|
|
166
|
+
state = CASE WHEN ? IS NOT NULL AND attempts >= ? THEN 'dropped' ELSE state END WHERE provider = :provider AND id = ?`)
|
|
167
|
+
.run(failure.uncertain ? 1 : 0, failure.until, failure.problem, giveUpAfter ?? null, giveUpAfter ?? 0, id);
|
|
168
|
+
}
|
|
169
|
+
/** One person's replies still waiting to send, and those that could not be delivered (settings show both). */
|
|
170
|
+
partCounts(binding) {
|
|
171
|
+
const row = this.prepare(`SELECT SUM(p.state = 'pending') AS pending, SUM(p.state = 'dropped') AS dropped FROM chat_part p
|
|
172
|
+
JOIN chat_event e ON e.provider = p.provider AND e.id = p.event WHERE p.provider = :provider AND e.binding = ?`).get(binding);
|
|
173
|
+
return { pending: Number(row?.["pending"] ?? 0), dropped: Number(row?.["dropped"] ?? 0) };
|
|
174
|
+
}
|
|
175
|
+
/** Replace a part's content and send it again (a repaint after a tap). */
|
|
176
|
+
repaint(id, content) {
|
|
177
|
+
this.prepare("UPDATE chat_part SET payload = ?, state = 'pending', next_at = NULL WHERE provider = :provider AND id = ?").run(savedChatPart(content), id);
|
|
208
178
|
}
|
|
209
179
|
}
|
|
210
180
|
/** A saved part's content. One that can't be read is a delivery problem that names the field, never a guess. */
|
package/dist/chat-delivery.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type WarmHooks } from "./chat-warmth.js";
|
|
2
|
-
import { ChatState, type ChatBinding, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
|
|
2
|
+
import { ChatState, type ChatBinding, type ChatContent, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
|
|
3
3
|
import { type Store } from "./store.js";
|
|
4
4
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
5
|
+
import { type ChatMerge } from "./chat-decide.js";
|
|
5
6
|
export declare const chatObject: (v: unknown) => Record<string, unknown>;
|
|
6
7
|
export type ChatDeliveryOptions = {
|
|
7
8
|
store: Store;
|
|
@@ -21,12 +22,29 @@ export type ChatDeliveryOptions = {
|
|
|
21
22
|
maxProposal?: number;
|
|
22
23
|
/** The app's warm touches for one owner message (a 👍, typing), where the app and the bot's permissions allow them. */
|
|
23
24
|
warm?: (event: ChatEvent, binding: ChatBinding) => WarmHooks;
|
|
25
|
+
/** Merges a pull request approved in chat (tests script it); otherwise the project's own merge. */
|
|
26
|
+
merge?: (input: {
|
|
27
|
+
runId: number;
|
|
28
|
+
by: string;
|
|
29
|
+
}) => Promise<{
|
|
30
|
+
ok: true;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
message: string;
|
|
34
|
+
}>;
|
|
24
35
|
};
|
|
25
36
|
export declare const splitChatText: (text: string, size?: number) => string[];
|
|
26
37
|
export declare function channelAccess(options: ChatDeliveryOptions, binding: ChatBinding, ceiling?: string): Promise<string[]>;
|
|
38
|
+
/** A merge approved in chat, waiting on GitHub, and the card that says how it went. */
|
|
39
|
+
type PendingMerge = {
|
|
40
|
+
merge: ChatMerge;
|
|
41
|
+
part: number;
|
|
42
|
+
shown: string;
|
|
43
|
+
content: ChatContent;
|
|
44
|
+
};
|
|
27
45
|
export declare function processChatEvent(options: ChatDeliveryOptions): Promise<boolean>;
|
|
28
46
|
/** Bound to the paired member, exact channel/message, pending proposal and current ceiling. */
|
|
29
|
-
export declare function applyChatAction(options: ChatDeliveryOptions, event: ChatEvent, binding: ChatBinding, repos: readonly string[]):
|
|
47
|
+
export declare function applyChatAction(options: ChatDeliveryOptions, event: ChatEvent, binding: ChatBinding, repos: readonly string[]): PendingMerge | null;
|
|
30
48
|
/**
|
|
31
49
|
* Carry new conversation messages to every room that follows one: the
|
|
32
50
|
* lead's replies, teammates' messages (never the room's own), and each
|
|
@@ -37,3 +55,4 @@ export declare function applyChatAction(options: ChatDeliveryOptions, event: Cha
|
|
|
37
55
|
export declare function planRoomMessages(options: ChatDeliveryOptions): Promise<void>;
|
|
38
56
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
39
57
|
export declare function planChatNotifications(options: ChatDeliveryOptions): Promise<void>;
|
|
58
|
+
export {};
|