toolroll 0.9.96 → 0.9.97
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/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2, §3.5, slice 3): the 👀 on a person's
|
|
3
|
+
* message when it arrives, and one "Working on it…" message per turn that shows the lead's status and its reply as it
|
|
4
|
+
* is written, then becomes the answer.
|
|
5
|
+
*
|
|
6
|
+
* - The 👀 is put on beside the serial chat queue (a turn there can take a minute), from the saved event, for the
|
|
7
|
+
* owner's own direct message only, and taken off once the answer or the failure notice is delivered. It carries no
|
|
8
|
+
* project data: the lease and a live binding are its fence.
|
|
9
|
+
* - The live message is its own `chat_live` row (v157), never a `chat_part`: planning the reply still adds its own
|
|
10
|
+
* parts, and the first of them edits this message, so the event stays queued until then. Creation is
|
|
11
|
+
* creating → placed. On Discord a nonce makes a repeated create within its window return the same message, and
|
|
12
|
+
* a lookup that fails decides nothing; on Slack and Teams an answer that never came leaves the row uncertain, as
|
|
13
|
+
* does an older Discord create not found among the recent messages. An uncertain message is never posted again (the
|
|
14
|
+
* reply then posts fresh, and the event's problem says a "Working on it…" may be left above it). That is the
|
|
15
|
+
* promise: no duplicate, except after an uncertain create.
|
|
16
|
+
* - Edits go through one serialized sender per message (live-reply.ts): one request in flight, the newest view
|
|
17
|
+
* waiting, closed (and the request in flight settled) before the reply is planned. Every edit re-proves the chat's
|
|
18
|
+
* standing (`standing`, the same proof final delivery uses), then the lease after it. Once that fails, nothing more is
|
|
19
|
+
* edited, cleanup included: the last text already ends "(still writing — not final)".
|
|
20
|
+
* - Cosmetic sends share the app's cooldown with durable delivery: none goes while it holds, and a "slow down" on one
|
|
21
|
+
* sets it for everything. Final parts come first (they never wait on these), then the 👀, then live edits, which
|
|
22
|
+
* start each app's spacing conservatively and widen it on every "slow down".
|
|
23
|
+
*
|
|
24
|
+
* No live payload carries a button, a card or an ask: those exist only on the planned reply.
|
|
25
|
+
*/
|
|
26
|
+
import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
|
|
27
|
+
import { renderReply, shapeReplyParts } from "./reply-shape.js";
|
|
28
|
+
/** The live message's fixed words. */
|
|
29
|
+
export const LIVE_WORDS = {
|
|
30
|
+
working: "Working on it…",
|
|
31
|
+
notFinal: "(still writing — not final)",
|
|
32
|
+
continues: "(continues…)",
|
|
33
|
+
maybeLeft: "A “Working on it…” message may be left above this reply.",
|
|
34
|
+
};
|
|
35
|
+
/** Each app's starting least time between two live edits (§3.5); a "slow down" doubles it, up to the most. */
|
|
36
|
+
export const LIVE_SPACING_MS = { slack: 1_500, discord: 1_250, teams: 2_000 };
|
|
37
|
+
const MOST_SPACING_MS = 10_000;
|
|
38
|
+
/** A message older than this when the 👀 would go gets none. */
|
|
39
|
+
const ACK_WINDOW_MS = 60_000;
|
|
40
|
+
/** One 👀 per person a second; a burst waits its turn. */
|
|
41
|
+
const ACK_SPACING_MS = 1_000;
|
|
42
|
+
/** After an app refused reactions (a missing permission), ask again at most this often (one quiet try a minute). */
|
|
43
|
+
const ACK_REFUSED_RETRY_MS = 60_000;
|
|
44
|
+
/** The chat_meta key that says reactions were refused, and when. */
|
|
45
|
+
export const ACK_REFUSED_META = "reactions-refused";
|
|
46
|
+
/** The transport's spacing for that switch (undefined: the app's own start). */
|
|
47
|
+
export const liveSpacingOf = (on) => typeof on === "object" ? on.spacingMs : undefined;
|
|
48
|
+
const gates = new Map();
|
|
49
|
+
function gateOf(state, installation, base) {
|
|
50
|
+
const key = `${state.provider}:${installation}`;
|
|
51
|
+
let gate = gates.get(key);
|
|
52
|
+
if (gate === undefined || gate.base !== base)
|
|
53
|
+
gates.set(key, gate = { spacing: base, base, liveNext: 0 });
|
|
54
|
+
return gate;
|
|
55
|
+
}
|
|
56
|
+
/** The app asked everyone to wait (shared with durable delivery). */
|
|
57
|
+
const coolingDown = (state, installation, now) => state.retryAt(installation) > now.toISOString();
|
|
58
|
+
/** A "slow down" on a cosmetic send: the whole connection waits, and live edits space out further. */
|
|
59
|
+
function slowDown(state, installation, gate, retryMs, now) {
|
|
60
|
+
state.deferUntil(installation, new Date(now.getTime() + Math.max(1_000, retryMs)).toISOString());
|
|
61
|
+
if (gate !== null)
|
|
62
|
+
gate.spacing = Math.min(MOST_SPACING_MS, Math.max(gate.base, gate.spacing * 2, 500));
|
|
63
|
+
}
|
|
64
|
+
const easeBack = (gate) => { gate.spacing = Math.max(gate.base, gate.spacing - (gate.spacing - gate.base) / 4); };
|
|
65
|
+
/** Forget every connection's live spacing (tests). */
|
|
66
|
+
export function resetLiveGates() { gates.clear(); }
|
|
67
|
+
// ---- rendering --------------------------------------------------------------------------------------------------
|
|
68
|
+
const italic = (platform, plain) => {
|
|
69
|
+
const words = renderReply(plain, platform);
|
|
70
|
+
return platform === "discord" ? `*${words}*` : `_${words}_`;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* A view as one live message's text, rendered for the app and measured after rendering: the shaped reply's first
|
|
74
|
+
* window that fits (cut where the final split would cut, never inside a link, code or bold), "(continues…)" when
|
|
75
|
+
* more follows, then the status and the "not final" marker. Null: nothing to show (the turn ended).
|
|
76
|
+
*/
|
|
77
|
+
export function liveText(view, platform, limit, shape = {}) {
|
|
78
|
+
switch (view.phase) {
|
|
79
|
+
case "closed": return null;
|
|
80
|
+
case "queued": return italic(platform, LIVE_STATUS.queued);
|
|
81
|
+
case "working": return italic(platform, view.status);
|
|
82
|
+
case "writing": {
|
|
83
|
+
const marker = italic(platform, view.status === LIVE_STATUS.writing ? LIVE_WORDS.notFinal : `${view.status} ${LIVE_WORDS.notFinal}`);
|
|
84
|
+
const room = Math.max(200, limit - marker.length - LIVE_WORDS.continues.length - 8);
|
|
85
|
+
const windows = shapeReplyParts(view.text, room, shape, shaped => renderReply(shaped, platform).length);
|
|
86
|
+
const first = renderReply(windows[0] ?? "", platform).slice(0, room);
|
|
87
|
+
const more = windows.length > 1 ? `\n\n${italic(platform, LIVE_WORDS.continues)}` : "";
|
|
88
|
+
return `${first}${more}\n\n${marker}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function liveRow(state, event) {
|
|
93
|
+
const row = state.prepare("SELECT state, message, generation, problem, created FROM chat_live WHERE provider = :provider AND event = ?").get(event);
|
|
94
|
+
return row === undefined ? null : { state: String(row["state"]), message: row["message"] == null ? null : String(row["message"]),
|
|
95
|
+
generation: Number(row["generation"]), problem: row["problem"] == null ? null : String(row["problem"]), created: String(row["created"]) };
|
|
96
|
+
}
|
|
97
|
+
/** The message an unconfirmed create left: found, not among the recent messages (absent), or the lookup failed (a
|
|
98
|
+
* "slow down" is shared). */
|
|
99
|
+
async function recoverLive(state, installation, transport, now) {
|
|
100
|
+
try {
|
|
101
|
+
const found = await transport.recover();
|
|
102
|
+
return found === null ? { absent: true } : { found };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
const wait = transport.rateLimited(error);
|
|
106
|
+
if (wait !== null)
|
|
107
|
+
slowDown(state, installation, null, wait, now());
|
|
108
|
+
return { failed: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const setLive = (state, event, from, to, now, extra = {}) => Number(state.prepare(`UPDATE chat_live SET state = ?, message = COALESCE(?, message), problem = COALESCE(?, problem), updated = ?
|
|
112
|
+
WHERE provider = :provider AND event = ? AND state IN (SELECT value FROM json_each(?))`)
|
|
113
|
+
.run(to, extra.message ?? null, extra.problem ?? null, now.toISOString(), event, JSON.stringify(from)).changes) === 1;
|
|
114
|
+
/** Is this message id the live message of that event (the one its reply's first part edits)? */
|
|
115
|
+
export function isLiveMessage(state, event, message) {
|
|
116
|
+
if (message === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
const row = liveRow(state, event);
|
|
119
|
+
return row !== null && row.message === message;
|
|
120
|
+
}
|
|
121
|
+
/** A first reply part that may replace the live message: words only, to the person's own chat. */
|
|
122
|
+
const editable = (part) => part !== undefined && part.edit === undefined && part.channel === undefined && part.proposal === undefined && part.image === undefined &&
|
|
123
|
+
part.rich === undefined && part.ask === undefined && part.decide === undefined && part.flow === undefined && part.choose === undefined &&
|
|
124
|
+
part.note === undefined && part.question === undefined && part.prompt === undefined;
|
|
125
|
+
/**
|
|
126
|
+
* Plan an event's reply (ChatState.plan) onto its live message: when one was placed, the first part edits it, and the
|
|
127
|
+
* row is finalized in the same transaction. An uncertain create (Slack, Teams) leaves the reply to post fresh, and
|
|
128
|
+
* the event's problem says a "Working on it…" may be left above it.
|
|
129
|
+
*/
|
|
130
|
+
export function planWithLive(state, event, parts, now) {
|
|
131
|
+
state.store.transact(() => {
|
|
132
|
+
const row = liveRow(state, event);
|
|
133
|
+
const onto = row !== null && row.message !== null && (row.state === "placed" || row.state === "abandoned") && editable(parts[0]);
|
|
134
|
+
state.plan(event, onto ? [{ ...parts[0], edit: row.message }, ...parts.slice(1)] : parts, now);
|
|
135
|
+
if (row === null)
|
|
136
|
+
return;
|
|
137
|
+
if (onto)
|
|
138
|
+
setLive(state, event, ["placed", "abandoned"], "finalized", now);
|
|
139
|
+
else if (row.state === "creating" || row.state === "uncertain") {
|
|
140
|
+
setLive(state, event, ["creating", "uncertain"], "uncertain", now, { problem: LIVE_WORDS.maybeLeft });
|
|
141
|
+
state.prepare("UPDATE chat_event SET problem = ? WHERE provider = :provider AND id = ?").run(LIVE_WORDS.maybeLeft, event);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function openChatLive(input) {
|
|
146
|
+
const { state, event, transport } = input;
|
|
147
|
+
const gate = gateOf(state, input.installation, transport.spacingMs ?? LIVE_SPACING_MS[transport.platform]);
|
|
148
|
+
const live = createLiveReply(input.shape);
|
|
149
|
+
let row = liveRow(state, event.id);
|
|
150
|
+
// Off for good: an uncertain or finished message, a refused create, or lost standing.
|
|
151
|
+
let off = row !== null && row.state !== "placed" && !(row.state === "creating" && transport.recover !== undefined);
|
|
152
|
+
// A create from before a restart on an app that can't tell whether it landed: uncertain, never posted again.
|
|
153
|
+
if (row?.state === "creating" && transport.recover === undefined)
|
|
154
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
155
|
+
let message = row?.state === "placed" ? row.message : null;
|
|
156
|
+
let placing = null;
|
|
157
|
+
const stopped = new AbortController();
|
|
158
|
+
const abandon = (problem) => {
|
|
159
|
+
off = true;
|
|
160
|
+
setLive(state, event.id, ["creating", "placed"], "abandoned", input.now(), { problem });
|
|
161
|
+
};
|
|
162
|
+
const place = (text) => {
|
|
163
|
+
if (off || message !== null)
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
placing ??= (async () => {
|
|
166
|
+
try {
|
|
167
|
+
if (!input.fenced() || coolingDown(state, input.installation, input.now()))
|
|
168
|
+
return;
|
|
169
|
+
row = liveRow(state, event.id);
|
|
170
|
+
if (row === null) {
|
|
171
|
+
state.prepare("INSERT OR IGNORE INTO chat_live (provider, event, state, created, updated) VALUES (:provider, ?, 'creating', ?, ?)")
|
|
172
|
+
.run(event.id, input.now().toISOString(), input.now().toISOString());
|
|
173
|
+
}
|
|
174
|
+
else if (row.state === "creating") {
|
|
175
|
+
// A create from before a restart: Discord finds it by its nonce, or repeats it under the same nonce while
|
|
176
|
+
// that is known to return the same message; Slack and Teams can't know, so it is never posted again.
|
|
177
|
+
if (transport.recover === undefined) {
|
|
178
|
+
off = true;
|
|
179
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const found = await recoverLive(state, input.installation, transport, input.now);
|
|
183
|
+
// The lookup failed: undecided, so nothing is posted; the row stays creating for a later look.
|
|
184
|
+
if ("failed" in found)
|
|
185
|
+
return;
|
|
186
|
+
if ("found" in found) {
|
|
187
|
+
message = found.found;
|
|
188
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const age = input.now().getTime() - Date.parse(row.created);
|
|
192
|
+
if (!(age >= 0 && age < (transport.nonceMs ?? 0))) {
|
|
193
|
+
off = true;
|
|
194
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (row.state === "placed") {
|
|
199
|
+
message = row.message;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
off = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!input.fenced())
|
|
207
|
+
return;
|
|
208
|
+
const id = await transport.create(text);
|
|
209
|
+
message = id;
|
|
210
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: id });
|
|
211
|
+
gate.liveNext = input.now().getTime() + gate.spacing;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const wait = transport.rateLimited(error);
|
|
215
|
+
if (wait !== null)
|
|
216
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
217
|
+
if (transport.uncertain(error) && transport.recover === undefined) {
|
|
218
|
+
off = true;
|
|
219
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
220
|
+
}
|
|
221
|
+
else if (transport.uncertain(error)) {
|
|
222
|
+
// Discord: the row stays creating, so the reply's planning looks for it by nonce once more (settleLive).
|
|
223
|
+
const found = await recoverLive(state, input.installation, transport, input.now);
|
|
224
|
+
if ("found" in found) {
|
|
225
|
+
message = found.found;
|
|
226
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
off = true;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Refused outright: nothing was posted. The reply goes out as a new message.
|
|
233
|
+
off = true;
|
|
234
|
+
state.prepare("DELETE FROM chat_live WHERE provider = :provider AND event = ? AND state = 'creating'").run(event.id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
placing = null;
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
return placing;
|
|
242
|
+
};
|
|
243
|
+
const pause = (ms) => new Promise(resolve => {
|
|
244
|
+
if (ms <= 0 || stopped.signal.aborted)
|
|
245
|
+
return resolve();
|
|
246
|
+
const end = () => { clearTimeout(timer); stopped.signal.removeEventListener("abort", end); resolve(); };
|
|
247
|
+
const timer = setTimeout(end, ms);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
stopped.signal.addEventListener("abort", end, { once: true });
|
|
250
|
+
});
|
|
251
|
+
const sender = createLiveSender({
|
|
252
|
+
send: async (text, generation) => {
|
|
253
|
+
if (placing !== null)
|
|
254
|
+
await placing;
|
|
255
|
+
if (off || message === null || stopped.signal.aborted)
|
|
256
|
+
return;
|
|
257
|
+
// Spacing: the app's current pace, and a 👀 just sent goes first.
|
|
258
|
+
await pause(gate.liveNext - input.now().getTime());
|
|
259
|
+
if (off || stopped.signal.aborted)
|
|
260
|
+
return;
|
|
261
|
+
if (!input.fenced())
|
|
262
|
+
return abandon("Chat access changed");
|
|
263
|
+
let standing;
|
|
264
|
+
try {
|
|
265
|
+
standing = await input.standing();
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// Not a refusal, only no answer now (a "slow down", a lost answer): this view is dropped and the message stays.
|
|
269
|
+
const wait = transport.rateLimited(error);
|
|
270
|
+
if (wait !== null)
|
|
271
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!standing)
|
|
275
|
+
return abandon("Chat access changed");
|
|
276
|
+
// Re-read after the awaits: the turn may have ended, or the lease moved, meanwhile.
|
|
277
|
+
if (off || stopped.signal.aborted || !input.fenced())
|
|
278
|
+
return;
|
|
279
|
+
// A cooldown holds: this view is dropped (a later one may go).
|
|
280
|
+
if (coolingDown(state, input.installation, input.now()))
|
|
281
|
+
return;
|
|
282
|
+
gate.liveNext = input.now().getTime() + gate.spacing;
|
|
283
|
+
try {
|
|
284
|
+
await transport.edit(message, text);
|
|
285
|
+
easeBack(gate);
|
|
286
|
+
state.prepare("UPDATE chat_live SET generation = ?, updated = ? WHERE provider = :provider AND event = ? AND state = 'placed'")
|
|
287
|
+
.run(generation, input.now().toISOString(), event.id);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const wait = transport.rateLimited(error);
|
|
291
|
+
if (wait !== null)
|
|
292
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
293
|
+
// Anything else: this view is dropped, previews are best effort.
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const show = (view) => {
|
|
298
|
+
// A blocked preview clears at once back to the status line; the turn may still answer.
|
|
299
|
+
const shown = view.phase === "closed" && view.why === "blocked" ? { phase: "working", status: live.status() } : view;
|
|
300
|
+
const text = liveText(shown, transport.platform, transport.limit, input.shape);
|
|
301
|
+
if (text === null)
|
|
302
|
+
return;
|
|
303
|
+
void place(italic(transport.platform, LIVE_WORDS.working));
|
|
304
|
+
sender.offer(text);
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
push(progress) {
|
|
308
|
+
try {
|
|
309
|
+
if (off || sender.closed)
|
|
310
|
+
return;
|
|
311
|
+
if (live.push(progress))
|
|
312
|
+
show(live.view());
|
|
313
|
+
else if (message === null && placing === null)
|
|
314
|
+
void place(italic(transport.platform, LIVE_WORDS.working));
|
|
315
|
+
}
|
|
316
|
+
catch { /* a live message never breaks the turn */ }
|
|
317
|
+
},
|
|
318
|
+
async queued() {
|
|
319
|
+
if (off || message !== null)
|
|
320
|
+
return;
|
|
321
|
+
await place(italic(transport.platform, LIVE_STATUS.queued));
|
|
322
|
+
},
|
|
323
|
+
async close() {
|
|
324
|
+
stopped.abort();
|
|
325
|
+
await sender.close();
|
|
326
|
+
if (placing !== null)
|
|
327
|
+
await placing;
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const setAck = (state, event, ack, now) => {
|
|
332
|
+
state.prepare(`INSERT INTO chat_ack (provider, event, state, created, updated) VALUES (:provider, ?, ?, ?, ?)
|
|
333
|
+
ON CONFLICT (provider, event) DO UPDATE SET state = excluded.state, updated = excluded.updated`).run(event, ack, now.toISOString(), now.toISOString());
|
|
334
|
+
};
|
|
335
|
+
/** Did this install refuse reactions within the last minute? Then no 👀 is asked for (no error after error). */
|
|
336
|
+
export function reactionsRefused(state, installation, now) {
|
|
337
|
+
const at = state.meta(installation, ACK_REFUSED_META);
|
|
338
|
+
return at !== null && Date.parse(at) + ACK_REFUSED_RETRY_MS > now.getTime();
|
|
339
|
+
}
|
|
340
|
+
const lastAck = new Map();
|
|
341
|
+
/**
|
|
342
|
+
* One step of the 👀 work for an installation, beside the queue: take one 👀 off a message whose reply (or failure
|
|
343
|
+
* notice) is delivered, else put one on the next owner's message that arrived. True: it did something (call again).
|
|
344
|
+
*/
|
|
345
|
+
export async function ackPass(input) {
|
|
346
|
+
const { state, installation, hooks } = input;
|
|
347
|
+
const fenced = () => input.current() && state.owns(installation, input.owner, input.now());
|
|
348
|
+
if (!fenced() || coolingDown(state, installation, input.now()))
|
|
349
|
+
return false;
|
|
350
|
+
const eventOf = (row) => (row === undefined ? null : state.event(String(row["event"])));
|
|
351
|
+
// Taking one off: its event is handled and nothing of its reply waits to send.
|
|
352
|
+
if (hooks.unreact !== undefined) {
|
|
353
|
+
const done = eventOf(state.prepare(`SELECT a.event FROM chat_ack a JOIN chat_event e ON e.provider = a.provider AND e.id = a.event
|
|
354
|
+
WHERE a.provider = :provider AND a.state = 'sent' AND e.installation = ? AND e.state IN ('done','dropped')
|
|
355
|
+
AND NOT EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = a.provider AND p.event = a.event AND p.state = 'pending')
|
|
356
|
+
ORDER BY a.created LIMIT 1`).get(installation));
|
|
357
|
+
if (done !== null) {
|
|
358
|
+
try {
|
|
359
|
+
await hooks.unreact(done);
|
|
360
|
+
setAck(state, done.id, "done", input.now());
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
const wait = hooks.rateLimited(error);
|
|
364
|
+
if (wait !== null)
|
|
365
|
+
slowDown(state, installation, null, wait, input.now());
|
|
366
|
+
// Gone already, or not ours to take off any more: settled. Anything else is tried again on a later pass.
|
|
367
|
+
else if (hooks.settled(error) || hooks.refused(error))
|
|
368
|
+
setAck(state, done.id, "done", input.now());
|
|
369
|
+
else
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Putting one on: the owner's own direct message, saved and not yet acknowledged, recent enough to matter.
|
|
376
|
+
const since = new Date(input.now().getTime() - ACK_WINDOW_MS).toISOString();
|
|
377
|
+
const event = eventOf(state.prepare(`SELECT e.id AS event FROM chat_event e JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding
|
|
378
|
+
WHERE e.provider = :provider AND e.installation = ? AND e.kind = 'message' AND e.state = 'queued' AND e.created >= ?
|
|
379
|
+
AND b.revoked IS NULL AND e.member = b.member AND e.channel = b.channel
|
|
380
|
+
AND NOT EXISTS (SELECT 1 FROM chat_ack a WHERE a.provider = e.provider AND a.event = e.id)
|
|
381
|
+
ORDER BY e.created, e.id LIMIT 1`).get(installation, since));
|
|
382
|
+
if (event === null || event.binding === null)
|
|
383
|
+
return false;
|
|
384
|
+
const binding = state.bindingById(event.binding);
|
|
385
|
+
if (binding === null || !state.live(binding)) {
|
|
386
|
+
setAck(state, event.id, "skipped", input.now());
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
const key = `${state.provider}:${binding.id}`;
|
|
390
|
+
if ((lastAck.get(key) ?? Number.NEGATIVE_INFINITY) + ACK_SPACING_MS > input.now().getTime())
|
|
391
|
+
return false;
|
|
392
|
+
if (hooks.react === undefined && hooks.typing === undefined) {
|
|
393
|
+
setAck(state, event.id, "skipped", input.now());
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
if (hooks.react !== undefined && reactionsRefused(state, installation, input.now())) {
|
|
397
|
+
setAck(state, event.id, "skipped", input.now());
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
if (!fenced())
|
|
401
|
+
return false;
|
|
402
|
+
lastAck.set(key, input.now().getTime());
|
|
403
|
+
// Saved as on before it is asked for: a crash in between can only leave a take-off with nothing to take off.
|
|
404
|
+
setAck(state, event.id, hooks.react !== undefined ? "sent" : "done", input.now());
|
|
405
|
+
// A 👀 goes before the next live edit.
|
|
406
|
+
for (const [name, gate] of gates)
|
|
407
|
+
if (name === `${state.provider}:${installation}`)
|
|
408
|
+
gate.liveNext = Math.max(gate.liveNext, input.now().getTime() + gate.spacing / 2);
|
|
409
|
+
try {
|
|
410
|
+
if (hooks.react !== undefined)
|
|
411
|
+
await hooks.react(event);
|
|
412
|
+
else
|
|
413
|
+
await hooks.typing(event);
|
|
414
|
+
if (hooks.react !== undefined)
|
|
415
|
+
state.deleteMeta(installation, ACK_REFUSED_META);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
const wait = hooks.rateLimited(error);
|
|
419
|
+
if (wait !== null)
|
|
420
|
+
slowDown(state, installation, null, wait, input.now());
|
|
421
|
+
if (hooks.react !== undefined && hooks.settled(error))
|
|
422
|
+
return true;
|
|
423
|
+
if (hooks.react !== undefined && hooks.refused(error))
|
|
424
|
+
state.setMeta(installation, ACK_REFUSED_META, input.now().toISOString(), input.now());
|
|
425
|
+
setAck(state, event.id, "skipped", input.now());
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Before an event's reply is planned: a create that never confirmed (this pass, or before a restart) is found by its
|
|
431
|
+
* nonce on Discord, else marked uncertain, so planning edits the right message or none. False: the lookup failed, so
|
|
432
|
+
* it is still undecided and planning waits (a "slow down" is shared).
|
|
433
|
+
*/
|
|
434
|
+
export async function settleLive(state, event, transport, now) {
|
|
435
|
+
const row = liveRow(state, event);
|
|
436
|
+
if (row?.state !== "creating")
|
|
437
|
+
return true;
|
|
438
|
+
const found = transport?.recover === undefined ? { absent: true } : await recoverLive(state, state.event(event)?.installation ?? "", transport, now);
|
|
439
|
+
if ("failed" in found)
|
|
440
|
+
return false;
|
|
441
|
+
if ("found" in found)
|
|
442
|
+
setLive(state, event, ["creating"], "placed", now(), { message: found.found });
|
|
443
|
+
else
|
|
444
|
+
setLive(state, event, ["creating"], "uncertain", now(), { problem: LIVE_WORDS.maybeLeft });
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
/** A cosmetic send (typing…) under the app's shared cooldown: skipped while it holds, and a "slow down" sets it. */
|
|
448
|
+
export function underCooldown(state, installation, rateLimited, now, call) {
|
|
449
|
+
return async () => {
|
|
450
|
+
if (coolingDown(state, installation, now()))
|
|
451
|
+
return;
|
|
452
|
+
try {
|
|
453
|
+
return await call();
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
const wait = rateLimited(error);
|
|
457
|
+
if (wait !== null)
|
|
458
|
+
slowDown(state, installation, null, wait, now());
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Forget who was acknowledged when (tests). */
|
|
464
|
+
export function resetAckSpacing() { lastAck.clear(); }
|
|
465
|
+
/**
|
|
466
|
+
* The 👀 loop a worker runs beside its queue while its connection is the current one: a pass every quarter second,
|
|
467
|
+
* and at once when `wake` is called (a message just arrived). Never throws.
|
|
468
|
+
*/
|
|
469
|
+
export function runChatAcks(input) {
|
|
470
|
+
let wakeUp = null;
|
|
471
|
+
const wake = () => { wakeUp?.(); };
|
|
472
|
+
input.signal.addEventListener("abort", wake, { once: true });
|
|
473
|
+
const done = (async () => {
|
|
474
|
+
while (input.same() && !input.signal.aborted) {
|
|
475
|
+
try {
|
|
476
|
+
for (let steps = 0; steps < 8 && await ackPass({ ...input, now: input.now ?? (() => new Date()) }); steps++)
|
|
477
|
+
;
|
|
478
|
+
}
|
|
479
|
+
catch { /* quiet: a 👀 is a courtesy */ }
|
|
480
|
+
if (!input.same() || input.signal.aborted)
|
|
481
|
+
break;
|
|
482
|
+
await new Promise(resolve => {
|
|
483
|
+
const timer = setTimeout(() => wakeUp?.(), 250);
|
|
484
|
+
timer.unref?.();
|
|
485
|
+
wakeUp = () => { clearTimeout(timer); wakeUp = null; resolve(); };
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
input.signal.removeEventListener("abort", wake);
|
|
489
|
+
})();
|
|
490
|
+
return { wake, done };
|
|
491
|
+
}
|
|
@@ -168,6 +168,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
168
168
|
readonly leadIdentity: FormContract<"name" | "persona">;
|
|
169
169
|
readonly leadAbout: FormContract<"about">;
|
|
170
170
|
readonly leadPromiseCancel: FormContract<"promise">;
|
|
171
|
+
readonly leadQuick: FormContract<"enabled">;
|
|
171
172
|
readonly leadCheck: FormContract<"return">;
|
|
172
173
|
readonly leadOn: FormContract<"agent" | "return">;
|
|
173
174
|
readonly welcomeSample: FormContract<never>;
|
|
@@ -183,6 +183,7 @@ export const CONSOLE_FORMS = {
|
|
|
183
183
|
leadIdentity: formContract("POST /settings/lead/identity", ["name", "persona"]),
|
|
184
184
|
leadAbout: formContract("POST /settings/lead/about", ["about"]),
|
|
185
185
|
leadPromiseCancel: formContract("POST /settings/lead/promise/cancel", ["promise"]),
|
|
186
|
+
leadQuick: formContract("POST /settings/lead/quick", ["enabled"]),
|
|
186
187
|
leadCheck: formContract("POST /lead/check", ["return"]),
|
|
187
188
|
leadOn: formContract("POST /settings/lead/on", ["agent", "return"]),
|
|
188
189
|
welcomeSample: formContract("POST /welcome/sample", []),
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
|
|
|
97
97
|
export declare function lockHeld(file: string): boolean;
|
|
98
98
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
99
99
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
100
|
+
version?: never;
|
|
100
101
|
active: boolean;
|
|
101
102
|
phase: string;
|
|
102
103
|
detail: string;
|
|
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
110
111
|
wasRunning?: never;
|
|
111
112
|
backupPath?: never;
|
|
112
113
|
updateId?: never;
|
|
113
|
-
version?: never;
|
|
114
114
|
buildId?: never;
|
|
115
115
|
workDir?: never;
|
|
116
116
|
} | {
|
package/dist/discord-api.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const discordId: (v: unknown) => v is string;
|
|
|
5
5
|
export type DiscordCredentials = ChatIdentity & {
|
|
6
6
|
botToken: string;
|
|
7
7
|
};
|
|
8
|
-
export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT", path: string, body?: Record<string, unknown>, file?: {
|
|
8
|
+
export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", path: string, body?: Record<string, unknown>, file?: {
|
|
9
9
|
bytes: Uint8Array;
|
|
10
10
|
name: string;
|
|
11
11
|
}) => Promise<Record<string, unknown>>;
|
|
@@ -13,6 +13,8 @@ export declare class DiscordError extends ChatDeliveryError {
|
|
|
13
13
|
}
|
|
14
14
|
/** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
|
|
15
15
|
export declare const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
|
|
16
|
+
/** Discord's 404: the message (or the conversation) is gone. */
|
|
17
|
+
export declare const DISCORD_GONE = "The Discord message or conversation is unavailable. Open the saved chat.";
|
|
16
18
|
export declare function discordApi(token: string, fetcher?: typeof fetch): DiscordApi;
|
|
17
19
|
export declare const discordCredentialFile: (dir: string) => string;
|
|
18
20
|
export declare function loadDiscordCredentials(dir: string): DiscordCredentials | null;
|
package/dist/discord-api.js
CHANGED
|
@@ -10,10 +10,16 @@ export class DiscordError extends ChatDeliveryError {
|
|
|
10
10
|
}
|
|
11
11
|
/** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
|
|
12
12
|
export const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
|
|
13
|
+
/** Discord's 404: the message (or the conversation) is gone. */
|
|
14
|
+
export const DISCORD_GONE = "The Discord message or conversation is unavailable. Open the saved chat.";
|
|
13
15
|
export function discordApi(token, fetcher = fetch) {
|
|
14
16
|
return async (method, path, body, file) => {
|
|
15
|
-
// Only relative API routes; never send the bot credential to supplied URLs.
|
|
16
|
-
|
|
17
|
+
// Only relative API routes; never send the bot credential to supplied URLs. Beyond messages: typing, and the bot's
|
|
18
|
+
// own 👀 on a message (put on, taken off).
|
|
19
|
+
if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/typing|\/messages\/[0-9]+\/reactions\/%F0%9F%91%80\/@me|\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
|
|
20
|
+
throw new DiscordError("Invalid Discord request");
|
|
21
|
+
// DELETE takes off the bot's own 👀 and nothing else (never a message).
|
|
22
|
+
if (method === "DELETE" && !/^\/channels\/[0-9]+\/messages\/[0-9]+\/reactions\/%F0%9F%91%80\/@me$/.test(path))
|
|
17
23
|
throw new DiscordError("Invalid Discord request");
|
|
18
24
|
let response;
|
|
19
25
|
const headers = { Authorization: `Bot ${token}` };
|
|
@@ -60,7 +66,7 @@ export function discordApi(token, fetcher = fetch) {
|
|
|
60
66
|
: response.status === 403
|
|
61
67
|
? DISCORD_REFUSED
|
|
62
68
|
: response.status === 404
|
|
63
|
-
?
|
|
69
|
+
? DISCORD_GONE
|
|
64
70
|
: response.status === 413
|
|
65
71
|
? "Discord refused this file size. Open the saved result to view the evidence."
|
|
66
72
|
: "Discord could not complete the request";
|
package/dist/discord-chat.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { ChatState, type ChatIdentity } from "./chat-delivery-state.js";
|
|
1
|
+
import { ChatState, type ChatIdentity, type ChatEvent } from "./chat-delivery-state.js";
|
|
2
2
|
import { type ChatDeliveryOptions } from "./chat-delivery.js";
|
|
3
3
|
import { discordPlain } from "./reply-shape.js";
|
|
4
|
+
import { type ChatAckHooks, type ChatLiveTransport, type LiveReplies } from "./chat-live.js";
|
|
4
5
|
import { type DiscordApi } from "./discord-api.js";
|
|
5
|
-
export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal"> & {
|
|
6
|
-
api: DiscordApi;
|
|
6
|
+
export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal" | "live"> & {
|
|
7
|
+
api: DiscordApi; /** The live "Working on it…" message (chat-live.ts): on in the worker. */
|
|
8
|
+
liveReplies?: LiveReplies;
|
|
7
9
|
};
|
|
8
10
|
export declare const discordDelivery: (options: DiscordChatOptions) => ChatDeliveryOptions;
|
|
9
11
|
export declare const processDiscordEvent: (options: DiscordChatOptions) => Promise<boolean>;
|
|
@@ -15,3 +17,11 @@ export declare function receiveDiscord(state: ChatState, identity: ChatIdentity,
|
|
|
15
17
|
export { discordPlain };
|
|
16
18
|
export declare function discordCard(text: string, buttons?: Record<string, unknown>[], voice?: boolean): Record<string, unknown>;
|
|
17
19
|
export declare function deliverDiscordPart(options: DiscordChatOptions): Promise<boolean>;
|
|
20
|
+
/** The lead's words as Discord carries them, already rendered: one embed description (cut to its 4 096), empty
|
|
21
|
+
* content, its buttons (none on a live message) and never a ping. Every field is sent on every edit: Discord keeps
|
|
22
|
+
* what an edit leaves out, and inherits no allowed_mentions. */
|
|
23
|
+
export declare function discordVoicePayload(rendered: string, buttons?: Record<string, unknown>[]): Record<string, unknown>;
|
|
24
|
+
/** The live message for one owner message: a reply to it, created under a nonce so a repeat returns the same one. */
|
|
25
|
+
export declare function discordLive(options: Pick<DiscordChatOptions, "api" | "identity">, event: ChatEvent, spacingMs?: number): ChatLiveTransport;
|
|
26
|
+
/** The 👀 on the owner's message, and taking it off. A missing permission is asked about at most once a minute, quietly. */
|
|
27
|
+
export declare function discordAckHooks(options: Pick<DiscordChatOptions, "api">): ChatAckHooks;
|