toolroll 0.9.17 → 0.9.18
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 +11 -0
- package/dist/browser/workspace.js +14 -14
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-decide.d.ts +203 -0
- package/dist/chat-decide.js +554 -0
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +32 -1
- package/dist/chat-quiet.d.ts +4 -1
- package/dist/chat-quiet.js +6 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +2 -0
- package/dist/discord-api.js +3 -1
- package/dist/discord-chat.js +17 -3
- package/dist/modes.d.ts +6 -0
- package/dist/modes.js +6 -0
- package/dist/operate.js +18 -8
- package/dist/project-delete.js +3 -0
- package/dist/result-shots.d.ts +51 -0
- package/dist/result-shots.js +119 -0
- package/dist/serve.js +23 -21
- package/dist/slack-chat.js +18 -4
- package/dist/store.d.ts +24 -0
- package/dist/store.js +119 -7
- package/dist/teams-chat.js +5 -0
- package/dist/telegram-mate.d.ts +10 -0
- package/dist/telegram.d.ts +5 -3
- package/dist/telegram.js +226 -20
- package/package.json +1 -1
package/dist/slack-chat.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chatFlowButtons } from "./chat-flow.js";
|
|
2
|
+
import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
|
|
2
3
|
import { chatQuestionButtons } from "./teammate-question.js";
|
|
3
4
|
import { chatAskButtons } from "./chat-ask.js";
|
|
4
5
|
import { channelInbox } from "./chat-inbox.js";
|
|
@@ -232,6 +233,13 @@ export async function deliverSlackPart(options) {
|
|
|
232
233
|
if (content.task &&
|
|
233
234
|
!repos.includes(store.lookupRef(content.task)?.repo ?? ""))
|
|
234
235
|
throw new SlackError("Connected projects changed");
|
|
236
|
+
// A screenshot sent with a result goes in that result's thread once its message is placed.
|
|
237
|
+
const follows = content.shot?.follows == null ? undefined : state.db.prepare("SELECT message FROM slack_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
|
|
238
|
+
const thread = slackTs(follows) ? String(follows) : event.thread;
|
|
239
|
+
if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
|
|
240
|
+
state.db.prepare("UPDATE slack_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
235
243
|
if (content.image) {
|
|
236
244
|
const verified = verifyResultImage(store, options.evidenceRoot, repos, content.image);
|
|
237
245
|
if (!verified.ok) {
|
|
@@ -272,7 +280,7 @@ export async function deliverSlackPart(options) {
|
|
|
272
280
|
: [];
|
|
273
281
|
const found = receipts
|
|
274
282
|
.map(object)
|
|
275
|
-
.find((receipt) => receipt.thread_ts ===
|
|
283
|
+
.find((receipt) => (receipt.thread_ts ?? "") === thread && slackTs(receipt.ts));
|
|
276
284
|
if (found) {
|
|
277
285
|
state.db
|
|
278
286
|
.prepare("UPDATE slack_part SET state='sent',message=?,problem=NULL WHERE id=?")
|
|
@@ -288,7 +296,7 @@ export async function deliverSlackPart(options) {
|
|
|
288
296
|
},
|
|
289
297
|
],
|
|
290
298
|
channel_id: destination,
|
|
291
|
-
thread_ts:
|
|
299
|
+
thread_ts: thread === "" ? undefined : thread,
|
|
292
300
|
initial_comment: safeResultImageCaption(content.text, content.image.taskId, content.image.run),
|
|
293
301
|
});
|
|
294
302
|
if (!Array.isArray(completed.files) ||
|
|
@@ -407,9 +415,9 @@ export async function deliverSlackPart(options) {
|
|
|
407
415
|
? await options.api("chat.update", { ...args, ts: target })
|
|
408
416
|
: await options.api("chat.postMessage", {
|
|
409
417
|
...args,
|
|
410
|
-
thread_ts: event.kind === "notice" &&
|
|
418
|
+
thread_ts: event.kind === "notice" && thread === ""
|
|
411
419
|
? undefined
|
|
412
|
-
:
|
|
420
|
+
: thread,
|
|
413
421
|
});
|
|
414
422
|
if (!slackTs(answer.ts) || (target && answer.ts !== target))
|
|
415
423
|
throw new SlackError("Slack did not confirm the message identity", 15_000, true);
|
|
@@ -422,6 +430,12 @@ export async function deliverSlackPart(options) {
|
|
|
422
430
|
return true;
|
|
423
431
|
}
|
|
424
432
|
catch (error) {
|
|
433
|
+
// No permission to upload files here: one plain line instead of the result's screenshots.
|
|
434
|
+
const content = JSON.parse(row.payload);
|
|
435
|
+
if (content.image && content.shot && error instanceof SlackError && ["missing_scope", "no_permission"].includes(error.code)) {
|
|
436
|
+
refuseResultShots(state, store, row, content, "Slack", nowOf(options));
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
425
439
|
const problem = error instanceof SlackError
|
|
426
440
|
? error
|
|
427
441
|
: new SlackError("Slack delivery is waiting to retry", 15_000, true);
|
package/dist/store.d.ts
CHANGED
|
@@ -47,6 +47,9 @@ export type TaskActRow = {
|
|
|
47
47
|
* "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
|
|
48
48
|
* a question is open while no later message from the owner sits in its thread. */
|
|
49
49
|
export declare const MATE_ASK_SCHEMA = "\nCREATE TABLE IF NOT EXISTS mate_ask (\n turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,\n thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,\n question TEXT NOT NULL,\n options_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);\n";
|
|
50
|
+
/** Decisions in the chat app (chat-decide.ts; no version bump: additive only). A token names one act on one card (the chat and message it rides); `digest` is
|
|
51
|
+
* what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
|
|
52
|
+
export declare const CHAT_DECIDE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_decide_action (\n token TEXT PRIMARY KEY,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n message TEXT,\n act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),\n phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),\n task_id TEXT NOT NULL,\n run INTEGER,\n digest TEXT NOT NULL,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);\nCREATE TABLE IF NOT EXISTS chat_decide_prompt (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n task_id TEXT NOT NULL,\n run INTEGER NOT NULL,\n digest TEXT NOT NULL,\n message TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);\n";
|
|
50
53
|
export type MateAsk = {
|
|
51
54
|
turn: number;
|
|
52
55
|
thread: number;
|
|
@@ -992,12 +995,18 @@ export type TelegramDelivery = Notification & {
|
|
|
992
995
|
};
|
|
993
996
|
/** How chats reach one person (quiet chat): only when they are needed, or every step. */
|
|
994
997
|
export type NotificationMode = "quiet" | "all";
|
|
998
|
+
/** Screenshots with results: none (the default), the first one, or up to four (result-shots.ts). */
|
|
999
|
+
export type ResultScreenshots = "off" | "first" | "all";
|
|
1000
|
+
export declare const RESULT_SCREENSHOTS: readonly ResultScreenshots[];
|
|
995
1001
|
export type NotificationPreference = {
|
|
996
1002
|
mode: NotificationMode;
|
|
997
1003
|
digestAt: string | null;
|
|
998
1004
|
digestOn: string | null;
|
|
999
1005
|
updatedAt: string | null;
|
|
1006
|
+
screenshots: ResultScreenshots;
|
|
1000
1007
|
};
|
|
1008
|
+
/** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
|
|
1009
|
+
export declare const RESULT_SHOTS_KIND = "result-screenshots";
|
|
1001
1010
|
/** Tasks filed this close together share one chat message. */
|
|
1002
1011
|
export declare const QUIET_GROUP_MS = 60000;
|
|
1003
1012
|
export declare function isDigestTime(value: string): boolean;
|
|
@@ -7495,7 +7504,20 @@ export declare class Store {
|
|
|
7495
7504
|
setNotificationPreference(account: string, change: {
|
|
7496
7505
|
mode?: NotificationMode;
|
|
7497
7506
|
digestAt?: string | null;
|
|
7507
|
+
screenshots?: ResultScreenshots;
|
|
7498
7508
|
}, by: string, now: Date): NotificationPreference;
|
|
7509
|
+
/** The screenshots of this result already sent (or being sent) to one destination. */
|
|
7510
|
+
resultShotsSent(destination: string, run: number): Set<number>;
|
|
7511
|
+
/** Mark screenshots as sent BEFORE they go: an unconfirmed send is never repeated. */
|
|
7512
|
+
markResultShotsSent(destination: string, run: number, artifacts: readonly number[], now: Date): void;
|
|
7513
|
+
/** Telegram refused them outright, so nothing arrived: they may be tried again. */
|
|
7514
|
+
unmarkResultShotsSent(destination: string, run: number, artifacts: readonly number[]): void;
|
|
7515
|
+
/**
|
|
7516
|
+
* A result message just became a fact: everyone who asked for screenshots with results, can see the project and
|
|
7517
|
+
* would be pinged by it gets one row of their own that follows it — once per result, whatever produced the fact.
|
|
7518
|
+
* Only a result that saved screenshots qualifies; what is sent, and whether at all, is decided again at send time.
|
|
7519
|
+
*/
|
|
7520
|
+
private enqueueResultShots;
|
|
7499
7521
|
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
7500
7522
|
eveningDigestPeople(): {
|
|
7501
7523
|
account: string;
|
|
@@ -7588,6 +7610,8 @@ export declare class Store {
|
|
|
7588
7610
|
chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
|
|
7589
7611
|
chatBatch(id: number): ChatBatch | null;
|
|
7590
7612
|
setChatBatchMessage(id: number, message: string, digest: string): void;
|
|
7613
|
+
/** The finished-work message that carries this result at one destination, if one was placed: screenshots follow it. */
|
|
7614
|
+
chatBatchMessageFor(destination: string, taskRef: number, run: number): string | null;
|
|
7591
7615
|
/** The message now showing this card, and what it shows. */
|
|
7592
7616
|
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7593
7617
|
markTelegramDigestSent(now: Date): void;
|
package/dist/store.js
CHANGED
|
@@ -148,7 +148,8 @@ CREATE TABLE IF NOT EXISTS notification_preference (
|
|
|
148
148
|
digest_at TEXT CHECK (digest_at IS NULL OR (length(digest_at) = 5 AND digest_at GLOB '[0-2][0-9]:[0-5][0-9]')),
|
|
149
149
|
digest_on TEXT,
|
|
150
150
|
updated_at TEXT NOT NULL,
|
|
151
|
-
updated_by TEXT NOT NULL
|
|
151
|
+
updated_by TEXT NOT NULL,
|
|
152
|
+
screenshots TEXT NOT NULL DEFAULT 'off' CHECK (screenshots IN ('off', 'first', 'all'))
|
|
152
153
|
);
|
|
153
154
|
CREATE TABLE IF NOT EXISTS chat_card (
|
|
154
155
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -183,6 +184,18 @@ CREATE TABLE IF NOT EXISTS chat_batch_item (
|
|
|
183
184
|
PRIMARY KEY (batch, task_ref)
|
|
184
185
|
);
|
|
185
186
|
`;
|
|
187
|
+
/** Screenshots with results (no version bump: additive only): each saved screenshot a destination was sent with a
|
|
188
|
+
* result, marked before the upload so a retry never sends it twice. No foreign keys: a deleted project's runs take
|
|
189
|
+
* their rows with them (project-delete.ts). */
|
|
190
|
+
const RESULT_SHOT_SCHEMA = `
|
|
191
|
+
CREATE TABLE IF NOT EXISTS result_shot_sent (
|
|
192
|
+
destination TEXT NOT NULL,
|
|
193
|
+
run INTEGER NOT NULL,
|
|
194
|
+
artifact INTEGER NOT NULL,
|
|
195
|
+
sent_at TEXT NOT NULL,
|
|
196
|
+
PRIMARY KEY (destination, run, artifact)
|
|
197
|
+
);
|
|
198
|
+
`;
|
|
186
199
|
/** Settings → Lead (no version bump: additive only): what one person calls their lead and the short persona it
|
|
187
200
|
* speaks with, and what their lead knows about them (`about_json`: the confirmed lines, lead-about.ts). No row reads
|
|
188
201
|
* as the defaults (lead-identity.ts). */
|
|
@@ -295,6 +308,40 @@ CREATE TABLE IF NOT EXISTS mate_ask (
|
|
|
295
308
|
);
|
|
296
309
|
CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
|
|
297
310
|
`;
|
|
311
|
+
/** Decisions in the chat app (chat-decide.ts; no version bump: additive only). A token names one act on one card (the chat and message it rides); `digest` is
|
|
312
|
+
* what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
|
|
313
|
+
export const CHAT_DECIDE_SCHEMA = `
|
|
314
|
+
CREATE TABLE IF NOT EXISTS chat_decide_action (
|
|
315
|
+
token TEXT PRIMARY KEY,
|
|
316
|
+
channel TEXT NOT NULL,
|
|
317
|
+
binding INTEGER NOT NULL,
|
|
318
|
+
chat TEXT NOT NULL,
|
|
319
|
+
message TEXT,
|
|
320
|
+
act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),
|
|
321
|
+
phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),
|
|
322
|
+
task_id TEXT NOT NULL,
|
|
323
|
+
run INTEGER,
|
|
324
|
+
digest TEXT NOT NULL,
|
|
325
|
+
created_at TEXT NOT NULL,
|
|
326
|
+
expires_at TEXT NOT NULL,
|
|
327
|
+
consumed_at TEXT
|
|
328
|
+
);
|
|
329
|
+
CREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);
|
|
330
|
+
CREATE TABLE IF NOT EXISTS chat_decide_prompt (
|
|
331
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
332
|
+
channel TEXT NOT NULL,
|
|
333
|
+
binding INTEGER NOT NULL,
|
|
334
|
+
chat TEXT NOT NULL,
|
|
335
|
+
task_id TEXT NOT NULL,
|
|
336
|
+
run INTEGER NOT NULL,
|
|
337
|
+
digest TEXT NOT NULL,
|
|
338
|
+
message TEXT,
|
|
339
|
+
created_at TEXT NOT NULL,
|
|
340
|
+
expires_at TEXT NOT NULL,
|
|
341
|
+
consumed_at TEXT
|
|
342
|
+
);
|
|
343
|
+
CREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);
|
|
344
|
+
`;
|
|
298
345
|
export const MATE_ASK_OTHER = "Something else";
|
|
299
346
|
/** How long the question's buttons work, on every channel. */
|
|
300
347
|
export const MATE_ASK_TTL_MS = 7 * 86_400_000;
|
|
@@ -771,6 +818,9 @@ const LIFECYCLE_HOLD_WORDS = {
|
|
|
771
818
|
revision: "a plan revision awaiting approval",
|
|
772
819
|
stop: "a stopped attempt that has not been resumed",
|
|
773
820
|
};
|
|
821
|
+
export const RESULT_SCREENSHOTS = ["off", "first", "all"];
|
|
822
|
+
/** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
|
|
823
|
+
export const RESULT_SHOTS_KIND = "result-screenshots";
|
|
774
824
|
/** Tasks filed this close together share one chat message. */
|
|
775
825
|
export const QUIET_GROUP_MS = 60_000;
|
|
776
826
|
export function isDigestTime(value) {
|
|
@@ -3886,6 +3936,8 @@ function initializeStore(db, file) {
|
|
|
3886
3936
|
db.exec(MONITORING_SCHEMA);
|
|
3887
3937
|
db.exec(INTEGRATIONS_SCHEMA);
|
|
3888
3938
|
db.exec(MATE_ASK_SCHEMA);
|
|
3939
|
+
// Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
|
|
3940
|
+
db.exec(CHAT_DECIDE_SCHEMA);
|
|
3889
3941
|
db.exec(SPEND_SCHEMA);
|
|
3890
3942
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
3891
3943
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
@@ -3901,6 +3953,9 @@ function initializeStore(db, file) {
|
|
|
3901
3953
|
db.exec(POLICY_SCHEMA);
|
|
3902
3954
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3903
3955
|
db.exec(QUIET_CHAT_SCHEMA);
|
|
3956
|
+
// Screenshots with results: each person's choice (an older notification_preference gains it), and what was sent.
|
|
3957
|
+
addColumn(db, "notification_preference", "screenshots", "TEXT NOT NULL DEFAULT 'off' CHECK (screenshots IN ('off', 'first', 'all'))");
|
|
3958
|
+
db.exec(RESULT_SHOT_SCHEMA);
|
|
3904
3959
|
// The update a batch line speaks for: its newest fact about that task (null: a finished result).
|
|
3905
3960
|
addColumn(db, "chat_batch_item", "notification", "INTEGER");
|
|
3906
3961
|
db.exec(LEAD_QUIET_SCHEMA);
|
|
@@ -15144,6 +15199,11 @@ export class Store {
|
|
|
15144
15199
|
if (Number(changes) > 0 && actor !== null) {
|
|
15145
15200
|
this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
|
|
15146
15201
|
}
|
|
15202
|
+
if (Number(changes) > 0 && run !== null && notification.recipient == null) {
|
|
15203
|
+
const made = this.notificationById(Number(lastInsertRowid));
|
|
15204
|
+
if (made !== null)
|
|
15205
|
+
this.enqueueResultShots(made, now);
|
|
15206
|
+
}
|
|
15147
15207
|
return Number(changes) > 0;
|
|
15148
15208
|
}
|
|
15149
15209
|
/**
|
|
@@ -22228,23 +22288,69 @@ export class Store {
|
|
|
22228
22288
|
// ---- quiet chat: each person's notification choice and each task's one message ----
|
|
22229
22289
|
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
22230
22290
|
notificationPreference(account) {
|
|
22231
|
-
const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at FROM notification_preference WHERE account = ?").get(account);
|
|
22232
|
-
return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null }
|
|
22291
|
+
const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at, screenshots FROM notification_preference WHERE account = ?").get(account);
|
|
22292
|
+
return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null, screenshots: "off" }
|
|
22233
22293
|
: { mode: row["mode"] === "all" ? "all" : "quiet", digestAt: row["digest_at"] == null ? null : String(row["digest_at"]),
|
|
22234
|
-
digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"])
|
|
22294
|
+
digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]),
|
|
22295
|
+
screenshots: row["screenshots"] === "first" || row["screenshots"] === "all" ? row["screenshots"] : "off" };
|
|
22235
22296
|
}
|
|
22236
22297
|
/** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
|
|
22237
22298
|
setNotificationPreference(account, change, by, now) {
|
|
22238
22299
|
if (change.digestAt != null && !isDigestTime(change.digestAt))
|
|
22239
22300
|
throw new Error("digest time must be HH:MM");
|
|
22301
|
+
if (change.screenshots !== undefined && !RESULT_SCREENSHOTS.includes(change.screenshots))
|
|
22302
|
+
throw new Error("screenshots must be off, first or all");
|
|
22240
22303
|
const current = this.notificationPreference(account);
|
|
22241
22304
|
const mode = change.mode ?? current.mode;
|
|
22242
22305
|
const digestAt = change.digestAt === undefined ? current.digestAt : change.digestAt;
|
|
22243
|
-
|
|
22244
|
-
|
|
22245
|
-
|
|
22306
|
+
const screenshots = change.screenshots ?? current.screenshots;
|
|
22307
|
+
this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by, screenshots) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
22308
|
+
ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by,
|
|
22309
|
+
screenshots = excluded.screenshots`)
|
|
22310
|
+
.run(account, mode, digestAt, current.digestOn, now.toISOString(), by, screenshots);
|
|
22246
22311
|
return this.notificationPreference(account);
|
|
22247
22312
|
}
|
|
22313
|
+
// ---- screenshots with results (result-shots.ts) ----
|
|
22314
|
+
/** The screenshots of this result already sent (or being sent) to one destination. */
|
|
22315
|
+
resultShotsSent(destination, run) {
|
|
22316
|
+
return new Set(this.db.prepare("SELECT artifact FROM result_shot_sent WHERE destination = ? AND run = ?").all(destination, run).map(row => Number(row["artifact"])));
|
|
22317
|
+
}
|
|
22318
|
+
/** Mark screenshots as sent BEFORE they go: an unconfirmed send is never repeated. */
|
|
22319
|
+
markResultShotsSent(destination, run, artifacts, now) {
|
|
22320
|
+
const insert = this.db.prepare("INSERT OR IGNORE INTO result_shot_sent (destination, run, artifact, sent_at) VALUES (?, ?, ?, ?)");
|
|
22321
|
+
this.transact(() => { for (const artifact of artifacts)
|
|
22322
|
+
insert.run(destination, run, artifact, now.toISOString()); });
|
|
22323
|
+
}
|
|
22324
|
+
/** Telegram refused them outright, so nothing arrived: they may be tried again. */
|
|
22325
|
+
unmarkResultShotsSent(destination, run, artifacts) {
|
|
22326
|
+
const remove = this.db.prepare("DELETE FROM result_shot_sent WHERE destination = ? AND run = ? AND artifact = ?");
|
|
22327
|
+
this.transact(() => { for (const artifact of artifacts)
|
|
22328
|
+
remove.run(destination, run, artifact); });
|
|
22329
|
+
}
|
|
22330
|
+
/**
|
|
22331
|
+
* A result message just became a fact: everyone who asked for screenshots with results, can see the project and
|
|
22332
|
+
* would be pinged by it gets one row of their own that follows it — once per result, whatever produced the fact.
|
|
22333
|
+
* Only a result that saved screenshots qualifies; what is sent, and whether at all, is decided again at send time.
|
|
22334
|
+
*/
|
|
22335
|
+
enqueueResultShots(source, now) {
|
|
22336
|
+
if (source.run === null || source.taskRef === null || source.project === null || source.recipient !== null)
|
|
22337
|
+
return;
|
|
22338
|
+
const ready = isLifecycleNotification(source) && source.kind === "run-finished";
|
|
22339
|
+
if (!ready && !/fail/.test(source.kind))
|
|
22340
|
+
return;
|
|
22341
|
+
const run = this.getRun(source.run);
|
|
22342
|
+
if (run === null || !["builder", "repair"].includes(run.role) || run.contestant !== null)
|
|
22343
|
+
return;
|
|
22344
|
+
if (!this.artifactsFor(run.id).some(one => one.kind === "screenshot"))
|
|
22345
|
+
return;
|
|
22346
|
+
const people = this.db.prepare("SELECT account FROM notification_preference WHERE screenshots IN ('first', 'all') ORDER BY account").all().map(row => String(row["account"]));
|
|
22347
|
+
for (const account of people) {
|
|
22348
|
+
if (!this.accountCanAccess(account, source.project) || !this.pingAllowed(source, account))
|
|
22349
|
+
continue;
|
|
22350
|
+
this.enqueueNotification({ dedupeKey: `result-shots:r${run.id}:${account}`, kind: RESULT_SHOTS_KIND, subject: "Screenshots with this result", body: "",
|
|
22351
|
+
...(source.link === null ? {} : { link: source.link }), source: { run: run.id }, recipient: account }, now);
|
|
22352
|
+
}
|
|
22353
|
+
}
|
|
22248
22354
|
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
22249
22355
|
eveningDigestPeople() {
|
|
22250
22356
|
return this.db.prepare("SELECT account, digest_at, digest_on FROM notification_preference WHERE digest_at IS NOT NULL ORDER BY account").all()
|
|
@@ -22523,6 +22629,12 @@ export class Store {
|
|
|
22523
22629
|
setChatBatchMessage(id, message, digest) {
|
|
22524
22630
|
this.db.prepare("UPDATE chat_batch SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
22525
22631
|
}
|
|
22632
|
+
/** The finished-work message that carries this result at one destination, if one was placed: screenshots follow it. */
|
|
22633
|
+
chatBatchMessageFor(destination, taskRef, run) {
|
|
22634
|
+
const row = this.db.prepare(`SELECT b.message FROM chat_batch b JOIN chat_batch_item i ON i.batch = b.id
|
|
22635
|
+
WHERE b.destination = ? AND i.task_ref = ? AND (i.run = ? OR i.run IS NULL) AND b.message IS NOT NULL ORDER BY b.id DESC LIMIT 1`).get(destination, taskRef, run);
|
|
22636
|
+
return row === undefined ? null : String(row["message"]);
|
|
22637
|
+
}
|
|
22526
22638
|
/** The message now showing this card, and what it shows. */
|
|
22527
22639
|
setChatCardMessage(id, message, digest) {
|
|
22528
22640
|
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
package/dist/teams-chat.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
|
|
2
2
|
* out, the same durable receipts, rooms and commands as every other channel. */
|
|
3
|
+
import { resultShotsPruned } from "./result-shots.js";
|
|
3
4
|
import { chatQuestionButtons } from "./teammate-question.js";
|
|
4
5
|
import { chatAskButtons } from "./chat-ask.js";
|
|
5
6
|
import { ChatState, chatHash } from "./chat-delivery-state.js";
|
|
@@ -175,6 +176,10 @@ export async function deliverTeamsPart(options) {
|
|
|
175
176
|
if (content.task && !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
|
|
176
177
|
throw new TeamsError("Connected projects changed");
|
|
177
178
|
let text = content.text, actions = [];
|
|
179
|
+
if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
|
|
180
|
+
state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
178
183
|
if (content.image) {
|
|
179
184
|
text = `${content.text}\n\nScreenshots open from the saved result.`;
|
|
180
185
|
actions = openUrlAction(options.origin(), { label: "Open result", path: chatResultHref(content.image.taskId, content.image.run, "checks") });
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -28,6 +28,16 @@ export type TelegramConversationOptions = {
|
|
|
28
28
|
* wires `phoneOrigin` from webhooks.ts; absent, no card carries a link.
|
|
29
29
|
*/
|
|
30
30
|
phoneOrigin?: () => string | null;
|
|
31
|
+
/** Injected by tests: how a Merge tapped in chat reaches GitHub (production: mergePullRequest). */
|
|
32
|
+
merge?: (input: {
|
|
33
|
+
runId: number;
|
|
34
|
+
by: string;
|
|
35
|
+
}) => Promise<{
|
|
36
|
+
ok: true;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
message: string;
|
|
40
|
+
}>;
|
|
31
41
|
};
|
|
32
42
|
/** One Bot API call, the shape telegram.ts injects. */
|
|
33
43
|
export type Transport = TelegramTransport;
|
package/dist/telegram.d.ts
CHANGED
|
@@ -75,7 +75,9 @@ export declare function scrub(text: string, token: string): string;
|
|
|
75
75
|
*/
|
|
76
76
|
export type TelegramTransport = (method: string, params: Record<string, unknown>, signal?: AbortSignal,
|
|
77
77
|
/** v64: one verified file to send as multipart — the ONLY way bytes leave. Absent, the call is JSON exactly as before. */
|
|
78
|
-
upload?: TelegramUpload
|
|
78
|
+
upload?: TelegramUpload,
|
|
79
|
+
/** More verified files in the same multipart call: the rest of one album (sendMediaGroup). */
|
|
80
|
+
more?: readonly TelegramUpload[]) => Promise<{
|
|
79
81
|
ok: boolean;
|
|
80
82
|
result?: unknown;
|
|
81
83
|
description?: string;
|
|
@@ -92,7 +94,7 @@ upload?: TelegramUpload) => Promise<{
|
|
|
92
94
|
* uploaded by name.
|
|
93
95
|
*/
|
|
94
96
|
export type TelegramUpload = {
|
|
95
|
-
field: "document"
|
|
97
|
+
field: "document" | "photo" | `shot${number}`;
|
|
96
98
|
fileName: string;
|
|
97
99
|
contentType: "image/png" | "image/jpeg";
|
|
98
100
|
bytes: Buffer;
|
|
@@ -113,7 +115,7 @@ export declare function networkFenceLine(reason: string): string;
|
|
|
113
115
|
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
114
116
|
export declare function transportError(error: unknown): string;
|
|
115
117
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
116
|
-
export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
|
|
118
|
+
export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload, ...more: readonly TelegramUpload[]): FormData;
|
|
117
119
|
export declare function hashPairingCode(code: string): string;
|
|
118
120
|
/** 128 bits, hex — pasteable, and not guessable inside any code's lifetime. */
|
|
119
121
|
export declare function mintPairingCode(): string;
|