toolroll 0.9.9 → 0.9.11
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 +15 -0
- package/dist/assignment.js +3 -1
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +49 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/discord-chat.js +1 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +118 -37
- package/dist/project-delete.js +1 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/serve.js +9 -2
- package/dist/slack-chat.js +2 -0
- package/dist/store.d.ts +39 -1
- package/dist/store.js +112 -2
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +11 -4
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +3 -2
- package/dist/telegram.d.ts +15 -1
- package/dist/telegram.js +164 -52
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -139,7 +139,8 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
|
|
|
139
139
|
* every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
|
|
140
140
|
* digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
|
|
141
141
|
* for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
|
|
142
|
-
* part id, `digest` the text last shown, so an unchanged card is never re-sent.
|
|
142
|
+
* part id, `digest` the text last shown, so an unchanged card is never re-sent. A `chat_batch` is one person's
|
|
143
|
+
* finished-work message: every result that lands within two minutes of its first joins it, edited in place. */
|
|
143
144
|
const QUIET_CHAT_SCHEMA = `
|
|
144
145
|
CREATE TABLE IF NOT EXISTS notification_preference (
|
|
145
146
|
account TEXT PRIMARY KEY,
|
|
@@ -166,6 +167,21 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
|
166
167
|
PRIMARY KEY (destination, task_ref)
|
|
167
168
|
);
|
|
168
169
|
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
170
|
+
CREATE TABLE IF NOT EXISTS chat_batch (
|
|
171
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
172
|
+
destination TEXT NOT NULL,
|
|
173
|
+
opened_at TEXT NOT NULL,
|
|
174
|
+
message TEXT,
|
|
175
|
+
digest TEXT
|
|
176
|
+
);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS chat_batch_destination ON chat_batch(destination, opened_at);
|
|
178
|
+
CREATE TABLE IF NOT EXISTS chat_batch_item (
|
|
179
|
+
batch INTEGER NOT NULL REFERENCES chat_batch(id),
|
|
180
|
+
task_ref INTEGER NOT NULL,
|
|
181
|
+
run INTEGER,
|
|
182
|
+
joined_at TEXT NOT NULL,
|
|
183
|
+
PRIMARY KEY (batch, task_ref)
|
|
184
|
+
);
|
|
169
185
|
`;
|
|
170
186
|
/** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
|
|
171
187
|
* for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
@@ -3826,6 +3842,8 @@ function initializeStore(db, file) {
|
|
|
3826
3842
|
db.exec(POLICY_SCHEMA);
|
|
3827
3843
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3828
3844
|
db.exec(QUIET_CHAT_SCHEMA);
|
|
3845
|
+
// The update a batch line speaks for: its newest fact about that task (null: a finished result).
|
|
3846
|
+
addColumn(db, "chat_batch_item", "notification", "INTEGER");
|
|
3829
3847
|
db.exec(LEAD_QUIET_SCHEMA);
|
|
3830
3848
|
mergeCheckTables(db);
|
|
3831
3849
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
@@ -15092,6 +15110,10 @@ export class Store {
|
|
|
15092
15110
|
.run(now.toISOString(), head.length, head);
|
|
15093
15111
|
return Number(changes);
|
|
15094
15112
|
}
|
|
15113
|
+
notificationById(id) {
|
|
15114
|
+
const row = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(id);
|
|
15115
|
+
return row === undefined ? null : readNotification(row);
|
|
15116
|
+
}
|
|
15095
15117
|
notificationsAfter(id, limit = 100) {
|
|
15096
15118
|
return this.db.prepare("SELECT * FROM notification WHERE id>? ORDER BY id LIMIT ?").all(id, Math.max(1, Math.min(100, limit))).map(readNotification);
|
|
15097
15119
|
}
|
|
@@ -21599,14 +21621,18 @@ export class Store {
|
|
|
21599
21621
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
21600
21622
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
21601
21623
|
*/
|
|
21602
|
-
claimTelegramConversation(botId, owner, ttlMs, now) {
|
|
21624
|
+
claimTelegramConversation(botId, owner, ttlMs, now, only) {
|
|
21603
21625
|
return this.transact(() => {
|
|
21604
21626
|
const stamp = now.toISOString();
|
|
21627
|
+
// "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
|
|
21628
|
+
const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
|
|
21629
|
+
const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
|
|
21605
21630
|
const row = this.db
|
|
21606
21631
|
.prepare(`SELECT * FROM telegram_conversation
|
|
21607
21632
|
WHERE bot_id = ? AND state IN ('queued','running')
|
|
21608
21633
|
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
21609
21634
|
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
21635
|
+
${which}
|
|
21610
21636
|
ORDER BY id LIMIT 1`)
|
|
21611
21637
|
.get(botId, stamp, stamp);
|
|
21612
21638
|
if (row === undefined)
|
|
@@ -21664,6 +21690,31 @@ export class Store {
|
|
|
21664
21690
|
.run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
|
|
21665
21691
|
return Number(changes) === 1;
|
|
21666
21692
|
}
|
|
21693
|
+
/**
|
|
21694
|
+
* Replies the assistant wrote that still have not reached the person
|
|
21695
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
21696
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
21697
|
+
* written, and the last send error.
|
|
21698
|
+
*/
|
|
21699
|
+
unsentTelegramReplies(botId, approver, since) {
|
|
21700
|
+
return this.db
|
|
21701
|
+
.prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
|
|
21702
|
+
(SELECT q.last_error FROM telegram_conversation_part q WHERE q.conversation = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
|
|
21703
|
+
FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
|
|
21704
|
+
WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
|
|
21705
|
+
GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
|
|
21706
|
+
.all(botId, botId, approver, approver, since.toISOString())
|
|
21707
|
+
.map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
|
|
21708
|
+
}
|
|
21709
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
21710
|
+
retryTelegramReplies(approver, now) {
|
|
21711
|
+
const { changes } = this.db
|
|
21712
|
+
.prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
|
|
21713
|
+
WHERE state = 'queued' AND (? IS NULL OR approver = ?)
|
|
21714
|
+
AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
|
|
21715
|
+
.run(now.toISOString(), approver, approver);
|
|
21716
|
+
return Number(changes);
|
|
21717
|
+
}
|
|
21667
21718
|
// ---- the outbound parts (v63) ------------------------------------------------
|
|
21668
21719
|
/**
|
|
21669
21720
|
* Persist the whole outbound half of a turn — every reply part and every
|
|
@@ -22197,6 +22248,7 @@ export class Store {
|
|
|
22197
22248
|
* and the evening digest keep every fact either way.
|
|
22198
22249
|
*/
|
|
22199
22250
|
pingAllowed(row, account) {
|
|
22251
|
+
// A security alert always pings, ahead of every rule below: own acts, settled, replaced or release-check tasks.
|
|
22200
22252
|
if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
|
|
22201
22253
|
return true;
|
|
22202
22254
|
if (row.recipient !== null && row.kind === "task-ask")
|
|
@@ -22206,10 +22258,42 @@ export class Store {
|
|
|
22206
22258
|
return false;
|
|
22207
22259
|
if (row.recipient !== null)
|
|
22208
22260
|
return true;
|
|
22261
|
+
// A task this person (or their lead) completed or cancelled is settled for them: nothing more about it.
|
|
22262
|
+
// Release checks and replaced or superseded tasks never ping anyone; a deploy says "is live" instead.
|
|
22263
|
+
if (row.taskRef !== null && (this.settledBy(row.taskRef) === account || this.neverPings(row.taskRef)))
|
|
22264
|
+
return false;
|
|
22209
22265
|
if (this.leadQuiet(row, actor))
|
|
22210
22266
|
return false;
|
|
22211
22267
|
return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
|
|
22212
22268
|
}
|
|
22269
|
+
/** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
|
|
22270
|
+
* completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
|
|
22271
|
+
* revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
|
|
22272
|
+
settledBy(taskRef) {
|
|
22273
|
+
const ancestors = [];
|
|
22274
|
+
for (let at = 0, from = taskRef; at < 20; at++) {
|
|
22275
|
+
const parent = this.db.prepare(`SELECT p.id FROM task_ref c JOIN task_ref p ON p.backend = c.backend AND p.external_id = c.revision_of AND p.repo IS c.repo
|
|
22276
|
+
WHERE c.id = ?`).get(from);
|
|
22277
|
+
if (parent === undefined || from === Number(parent["id"]) || ancestors.includes(Number(parent["id"])))
|
|
22278
|
+
break;
|
|
22279
|
+
from = Number(parent["id"]);
|
|
22280
|
+
ancestors.push(from);
|
|
22281
|
+
}
|
|
22282
|
+
const filed = this.db.prepare("SELECT t.created_at FROM task_ref r JOIN task t ON t.id = r.external_id AND r.backend = ? WHERE r.id = ?").get(BUILT_IN, taskRef);
|
|
22283
|
+
const since = filed === undefined || ancestors.length === 0 ? null : String(filed["created_at"]);
|
|
22284
|
+
const last = since === null
|
|
22285
|
+
? this.db.prepare("SELECT act, account FROM task_act WHERE task_ref = ? ORDER BY id DESC LIMIT 1").get(taskRef)
|
|
22286
|
+
: this.db.prepare(`SELECT act, account FROM task_act WHERE task_ref = ? OR (task_ref IN (${ancestors.map(() => "?").join(", ")}) AND at >= ?)
|
|
22287
|
+
ORDER BY id DESC LIMIT 1`).get(taskRef, ...ancestors, since);
|
|
22288
|
+
return last !== undefined && (last["act"] === "completed" || last["act"] === "cancelled") ? String(last["account"]) : null;
|
|
22289
|
+
}
|
|
22290
|
+
/** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
|
|
22291
|
+
neverPings(taskRef) {
|
|
22292
|
+
return this.db.prepare(`SELECT 1 AS hit FROM task_ref r WHERE r.id = ? AND (
|
|
22293
|
+
EXISTS (SELECT 1 FROM run JOIN run_check rc ON rc.run = run.id WHERE run.task_ref = r.id AND rc.release = 1)
|
|
22294
|
+
OR EXISTS (SELECT 1 FROM task_replacement x WHERE x.task_ref = r.id)
|
|
22295
|
+
OR EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = r.backend AND c.revision_of = r.external_id AND c.repo IS r.repo))`).get(taskRef) !== undefined;
|
|
22296
|
+
}
|
|
22213
22297
|
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
22214
22298
|
leadQuiet(row, actor = this.notificationActor(row.id)) {
|
|
22215
22299
|
if (SECURITY_KINDS.has(row.kind))
|
|
@@ -22262,6 +22346,32 @@ export class Store {
|
|
|
22262
22346
|
return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
|
|
22263
22347
|
.all(since, limit).map(readNotification).reverse();
|
|
22264
22348
|
}
|
|
22349
|
+
/** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
|
|
22350
|
+
* has one line in it, saying its newest update (`notification`; null for a finished result). */
|
|
22351
|
+
chatBatchFor(destination, taskRef, run, now, windowMs, notification = null) {
|
|
22352
|
+
return this.transact(() => {
|
|
22353
|
+
const open = this.db.prepare("SELECT id FROM chat_batch WHERE destination = ? AND opened_at > ? AND opened_at <= ? ORDER BY id DESC LIMIT 1")
|
|
22354
|
+
.get(destination, new Date(now.getTime() - windowMs).toISOString(), now.toISOString());
|
|
22355
|
+
const batch = open !== undefined ? Number(open["id"])
|
|
22356
|
+
: Number(this.db.prepare("INSERT INTO chat_batch (destination, opened_at) VALUES (?, ?)").run(destination, now.toISOString()).lastInsertRowid);
|
|
22357
|
+
this.db.prepare(`INSERT INTO chat_batch_item (batch, task_ref, run, joined_at, notification) VALUES (?, ?, ?, ?, ?)
|
|
22358
|
+
ON CONFLICT (batch, task_ref) DO UPDATE SET run = excluded.run, notification = excluded.notification`).run(batch, taskRef, run, now.toISOString(), notification);
|
|
22359
|
+
return this.chatBatch(batch);
|
|
22360
|
+
});
|
|
22361
|
+
}
|
|
22362
|
+
chatBatch(id) {
|
|
22363
|
+
const row = this.db.prepare("SELECT * FROM chat_batch WHERE id = ?").get(id);
|
|
22364
|
+
if (row === undefined)
|
|
22365
|
+
return null;
|
|
22366
|
+
const items = this.db.prepare("SELECT task_ref, run, notification FROM chat_batch_item WHERE batch = ? ORDER BY joined_at, task_ref").all(id)
|
|
22367
|
+
.map(one => ({ taskRef: Number(one["task_ref"]), run: one["run"] == null ? null : Number(one["run"]),
|
|
22368
|
+
notification: one["notification"] == null ? null : Number(one["notification"]) }));
|
|
22369
|
+
return { id, destination: String(row["destination"]), openedAt: String(row["opened_at"]), message: row["message"] == null ? null : String(row["message"]),
|
|
22370
|
+
digest: row["digest"] == null ? null : String(row["digest"]), items };
|
|
22371
|
+
}
|
|
22372
|
+
setChatBatchMessage(id, message, digest) {
|
|
22373
|
+
this.db.prepare("UPDATE chat_batch SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
22374
|
+
}
|
|
22265
22375
|
/** The message now showing this card, and what it shows. */
|
|
22266
22376
|
setChatCardMessage(id, message, digest) {
|
|
22267
22377
|
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
package/dist/teams-chat.js
CHANGED
|
@@ -195,7 +195,7 @@ export async function deliverTeamsPart(options) {
|
|
|
195
195
|
const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
196
196
|
// A teammate's question (v93): its options, then "Answer in words".
|
|
197
197
|
const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
198
|
-
actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link)];
|
|
198
|
+
actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
|
|
199
199
|
}
|
|
200
200
|
const target = content.edit ?? row.message;
|
|
201
201
|
const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export declare function splitParts(text: string): string[];
|
|
|
122
122
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
123
123
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
124
124
|
* flood cannot pin one pass forever.
|
|
125
|
+
*
|
|
126
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
127
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
128
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
129
|
+
* place, in the same order and over the same connection as notifications,
|
|
130
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
125
131
|
*/
|
|
126
132
|
export declare function processTelegramConversations(args: {
|
|
127
133
|
store: Store;
|
|
@@ -134,4 +140,5 @@ export declare function processTelegramConversations(args: {
|
|
|
134
140
|
report: ConversationReport;
|
|
135
141
|
signal?: AbortSignal;
|
|
136
142
|
limit?: number;
|
|
143
|
+
only?: "turns" | "replies";
|
|
137
144
|
}): Promise<void>;
|
package/dist/telegram-mate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
|
|
2
|
-
import { taskInCeiling, mirrorToTaskChat, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
2
|
+
import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
3
3
|
export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
|
|
4
4
|
/**
|
|
5
5
|
* The paired phone as one more way to use the same assistant.
|
|
@@ -299,11 +299,17 @@ export function splitParts(text) {
|
|
|
299
299
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
300
300
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
301
301
|
* flood cannot pin one pass forever.
|
|
302
|
+
*
|
|
303
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
304
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
305
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
306
|
+
* place, in the same order and over the same connection as notifications,
|
|
307
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
302
308
|
*/
|
|
303
309
|
export async function processTelegramConversations(args) {
|
|
304
310
|
const limit = args.limit ?? 20;
|
|
305
311
|
for (let count = 0; count < limit && args.signal?.aborted !== true; count++) {
|
|
306
|
-
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock());
|
|
312
|
+
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock(), args.only);
|
|
307
313
|
if (row === null)
|
|
308
314
|
return;
|
|
309
315
|
try {
|
|
@@ -318,6 +324,8 @@ export async function processTelegramConversations(args) {
|
|
|
318
324
|
}
|
|
319
325
|
class PlanRefused extends Error {
|
|
320
326
|
}
|
|
327
|
+
/** The outcome a planned reply waits under until the bridge's cycle sends it. */
|
|
328
|
+
const ANSWERED_SENDING = "answered:sending";
|
|
321
329
|
/**
|
|
322
330
|
* One claimed message, in order of what is already known about it:
|
|
323
331
|
*
|
|
@@ -334,12 +342,13 @@ class PlanRefused extends Error {
|
|
|
334
342
|
* the ceiling the turn opened under; only a confirmed message id counts.
|
|
335
343
|
*/
|
|
336
344
|
async function runTelegramConversation(row, args) {
|
|
337
|
-
const { store, botId, transport, owner, clock, readProjects, options, report } = args;
|
|
345
|
+
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
338
346
|
const finish = (result) => store.finishTelegramConversation(row.id, owner, result, clock());
|
|
339
347
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
340
348
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
341
|
-
const requeue = (outcome) => {
|
|
349
|
+
const requeue = async (outcome) => {
|
|
342
350
|
if (ageMs > CONVERSATION_MAX_AGE_MS) {
|
|
351
|
+
await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
|
|
343
352
|
finish({ state: "failed", outcome: `${outcome}:gave-up` });
|
|
344
353
|
report.refused++;
|
|
345
354
|
return;
|
|
@@ -384,6 +393,7 @@ async function runTelegramConversation(row, args) {
|
|
|
384
393
|
const bound = store.getTelegramConversation(row.id)?.session ?? null;
|
|
385
394
|
const session = bound === null ? null : store.getMateSession(bound);
|
|
386
395
|
if (session === null) {
|
|
396
|
+
await notify(couldNotAnswerText("the chat session for this reply is gone"));
|
|
387
397
|
finish({ state: "failed", outcome: "unsent:no-session" });
|
|
388
398
|
report.refused++;
|
|
389
399
|
return;
|
|
@@ -407,6 +417,7 @@ async function runTelegramConversation(row, args) {
|
|
|
407
417
|
}
|
|
408
418
|
if (now.getTime() - Date.parse(row.createdAt) > DELIVERY_MAX_AGE_MS) {
|
|
409
419
|
report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
|
|
420
|
+
await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
|
|
410
421
|
finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
|
|
411
422
|
report.refused++;
|
|
412
423
|
return;
|
|
@@ -512,15 +523,24 @@ async function runTelegramConversation(row, args) {
|
|
|
512
523
|
...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
|
|
513
524
|
});
|
|
514
525
|
}
|
|
526
|
+
// The first time the answer itself fails to go out, the person hears so once,
|
|
527
|
+
// in plain words; the reply itself keeps being retried and shows as
|
|
528
|
+
// unsent in status and the console after two minutes.
|
|
529
|
+
const firstFailure = part.kind === "reply" && store.listTelegramConversationParts(row.id).every(one => one.attempts <= (one.state === "sent" ? 1 : 0));
|
|
530
|
+
const failed = async (until, error, uncertain) => {
|
|
531
|
+
if (!store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain, retryAt: until.toISOString() }, clock()))
|
|
532
|
+
return;
|
|
533
|
+
if (firstFailure)
|
|
534
|
+
await notify(couldNotAnswerText(uncertain ? "my reply may not have reached Telegram" : "Telegram didn't accept my reply"));
|
|
535
|
+
defer(until, error);
|
|
536
|
+
};
|
|
515
537
|
let answer;
|
|
516
538
|
try {
|
|
517
539
|
answer = await send();
|
|
518
540
|
}
|
|
519
541
|
catch {
|
|
520
542
|
// The answer was lost: Telegram may or may not have the message. Said so, retried, counted.
|
|
521
|
-
|
|
522
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
523
|
-
defer(backoff, error);
|
|
543
|
+
await failed(backoff, "Telegram transport failed; delivery may be uncertain", true);
|
|
524
544
|
return;
|
|
525
545
|
}
|
|
526
546
|
if (!answer.ok) {
|
|
@@ -535,15 +555,12 @@ async function runTelegramConversation(row, args) {
|
|
|
535
555
|
if (paused > until)
|
|
536
556
|
until = paused;
|
|
537
557
|
}
|
|
538
|
-
|
|
539
|
-
defer(until, error);
|
|
558
|
+
await failed(until, error, uncertain);
|
|
540
559
|
return;
|
|
541
560
|
}
|
|
542
561
|
const id = answer.result?.message_id;
|
|
543
562
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
544
|
-
|
|
545
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
546
|
-
defer(backoff, error);
|
|
563
|
+
await failed(backoff, "Telegram returned no confirmed message identity", true);
|
|
547
564
|
return;
|
|
548
565
|
}
|
|
549
566
|
const messageId = String(id);
|
|
@@ -596,11 +613,11 @@ async function runTelegramConversation(row, args) {
|
|
|
596
613
|
}
|
|
597
614
|
store.bindTelegramConversationTurn(row.id, owner, session, turnId);
|
|
598
615
|
if (turn.state === "queued" || turn.state === "running") {
|
|
599
|
-
requeue("replayed:running");
|
|
616
|
+
await requeue("replayed:running");
|
|
600
617
|
return;
|
|
601
618
|
}
|
|
602
619
|
if (turn.state !== "answered") {
|
|
603
|
-
await notify(`
|
|
620
|
+
await notify(couldNotAnswerText(`the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)})`));
|
|
604
621
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
605
622
|
report.refused++;
|
|
606
623
|
return;
|
|
@@ -609,11 +626,19 @@ async function runTelegramConversation(row, args) {
|
|
|
609
626
|
const proposals = store.listMateProposals(turn.thread, ["pending"]).filter(one => one.turn === turn.id);
|
|
610
627
|
if (!plan(session, turnId, reply, proposals, repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
611
628
|
return;
|
|
612
|
-
await
|
|
629
|
+
await sendOrHandOff("replayed");
|
|
630
|
+
};
|
|
631
|
+
/** Send now, or — for the follower's turn worker — leave it to the bridge's cycle, which sends it next. */
|
|
632
|
+
const sendOrHandOff = async (outcomeWord) => {
|
|
633
|
+
if (only !== "turns") {
|
|
634
|
+
await deliver(outcomeWord);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
finish({ state: "queued", outcome: outcomeWord === "answered" ? ANSWERED_SENDING : "delivering", nextAttemptAt: clock().toISOString() });
|
|
613
638
|
};
|
|
614
639
|
// 2. Parts already planned: the turn answered; only the sending remains.
|
|
615
640
|
if (store.listTelegramConversationParts(row.id).length > 0) {
|
|
616
|
-
await deliver("replayed");
|
|
641
|
+
await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
|
|
617
642
|
return;
|
|
618
643
|
}
|
|
619
644
|
// 3. The ceiling, reloaded now.
|
|
@@ -623,7 +648,7 @@ async function runTelegramConversation(row, args) {
|
|
|
623
648
|
}
|
|
624
649
|
catch {
|
|
625
650
|
report.problems.push("telegram chat could not read the current project records");
|
|
626
|
-
requeue("registry-unavailable");
|
|
651
|
+
await requeue("registry-unavailable");
|
|
627
652
|
return;
|
|
628
653
|
}
|
|
629
654
|
const repos = telegramConversationRepos(store, binding.approver, registry);
|
|
@@ -638,6 +663,10 @@ async function runTelegramConversation(row, args) {
|
|
|
638
663
|
}
|
|
639
664
|
// 5. The session and thread — shared with the console and the CLI.
|
|
640
665
|
const resolved = resolveTelegramMate(store, binding, repos, clock());
|
|
666
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
667
|
+
await requeue("busy");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
641
670
|
if (!resolved.ok) {
|
|
642
671
|
if (resolved.said !== null)
|
|
643
672
|
await notify(resolved.said);
|
|
@@ -676,10 +705,10 @@ async function runTelegramConversation(row, args) {
|
|
|
676
705
|
store.bindTelegramConversationTurn(row.id, owner, session.id, receipt.turn);
|
|
677
706
|
if (!outcome.ok && "refused" in outcome) {
|
|
678
707
|
if (outcome.refused === "concurrent") {
|
|
679
|
-
requeue("busy");
|
|
708
|
+
await requeue("busy");
|
|
680
709
|
return;
|
|
681
710
|
}
|
|
682
|
-
await notify(
|
|
711
|
+
await notify(phoneText(couldNotAnswerText(outcome.message), 1_000));
|
|
683
712
|
finish({ state: "failed", outcome: `refused:${outcome.refused}` });
|
|
684
713
|
report.refused++;
|
|
685
714
|
return;
|
|
@@ -687,7 +716,7 @@ async function runTelegramConversation(row, args) {
|
|
|
687
716
|
if (!outcome.ok) {
|
|
688
717
|
// Truthful: what failed, that nothing was kept, and that a new message
|
|
689
718
|
// is a new turn. A failed turn's drafts are already gone.
|
|
690
|
-
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : `
|
|
719
|
+
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : couldNotAnswerText(`the assistant's reply did not complete (${outcome.message})`);
|
|
691
720
|
await notify(phoneText(words, 1_000));
|
|
692
721
|
finish({ state: "failed", outcome: `failed:${outcome.failed}` });
|
|
693
722
|
report.refused++;
|
|
@@ -703,5 +732,5 @@ async function runTelegramConversation(row, args) {
|
|
|
703
732
|
const proposals = store.listMateProposals(thread.id, ["pending"]).filter(one => one.turn === outcome.turn);
|
|
704
733
|
if (!plan(session.id, outcome.turn, outcome.reply, proposals, who.repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
705
734
|
return;
|
|
706
|
-
await
|
|
735
|
+
await sendOrHandOff("answered");
|
|
707
736
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
2
2
|
import type { Run, Store } from "./store.js";
|
|
3
|
+
import { type TaskStatus, type TaskStatusFacts } from "./task-status.js";
|
|
3
4
|
/** Offsets are UTF-16, as required by Telegram. Only our two headings are bold;
|
|
4
5
|
* user text stays literal, including angle brackets and Markdown characters. */
|
|
5
6
|
export type ProgressEntity = {
|
|
@@ -7,9 +8,13 @@ export type ProgressEntity = {
|
|
|
7
8
|
offset: number;
|
|
8
9
|
length: number;
|
|
9
10
|
};
|
|
10
|
-
|
|
11
|
+
/** `viewer`: the person whose chat shows the card. Their own act is never told back to them. */
|
|
12
|
+
export declare function telegramProgressCard(store: Store, run: Run, taskId: string, project: string, now?: Date, root?: string, viewer?: string): {
|
|
11
13
|
text: string;
|
|
12
14
|
entities: ProgressEntity[];
|
|
13
15
|
link: PhoneTaskLink;
|
|
14
16
|
next: string;
|
|
17
|
+
status: TaskStatus;
|
|
18
|
+
facts: TaskStatusFacts;
|
|
19
|
+
completedByLead: boolean;
|
|
15
20
|
};
|
|
@@ -8,6 +8,7 @@ import { assignmentOf } from "./assignment.js";
|
|
|
8
8
|
import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { chatTitle } from "./chat-voice.js";
|
|
11
12
|
import { manualReviewOnly } from "./proof.js";
|
|
12
13
|
import { failedCheckExit } from "./workspace-ui.js";
|
|
13
14
|
/** The saved assignment for this exact result, or null when the run is not
|
|
@@ -21,7 +22,8 @@ function assignmentFor(store, run, taskId, project, now, root) {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
/** `viewer`: the person whose chat shows the card. Their own act is never told back to them. */
|
|
26
|
+
export function telegramProgressCard(store, run, taskId, project, now = new Date(), root, viewer) {
|
|
25
27
|
const built = run.finishedAt !== null && (run.outcome === "built" || run.outcome === "no-change");
|
|
26
28
|
const assignment = built ? assignmentFor(store, run, taskId, project, now, root) : null;
|
|
27
29
|
const publication = store.publicationForRun(run.id);
|
|
@@ -72,20 +74,25 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
72
74
|
else {
|
|
73
75
|
facts = { stage: "failed", reason: "The build stopped before it finished. Inspect the blocker, then retry." };
|
|
74
76
|
}
|
|
77
|
+
// "Marked complete by <you>" (or by your lead, which signs in as you) is never told back to you.
|
|
78
|
+
const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
|
|
79
|
+
if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
|
|
80
|
+
facts = { ...facts, completedBy: null };
|
|
75
81
|
const status = taskStatusOf(facts);
|
|
76
82
|
const checkProgress = store.checkProgress(run.id);
|
|
77
83
|
const progress = status.headline === "Building" && checkProgress !== null ? [`● ${checkProgress.line}`] : [];
|
|
78
|
-
|
|
84
|
+
// A short human title: never "— revision" or an id (chat-voice.ts).
|
|
85
|
+
const title = chatTitle(store, taskId);
|
|
79
86
|
const heading = `${headlineEmoji(status.headline)} ${status.headline}`;
|
|
80
87
|
const next = phoneText(status.sentence, 200);
|
|
81
88
|
// A person's acceptance is its own recorded decision; it never changes a check.
|
|
82
89
|
const acceptance = store.proofAcceptance(run.id) === null ? [] : [manualReviewOnly(store.proofVerdictFor(run.id))
|
|
83
90
|
? "Accepted by a person · Recorded checks unchanged" : "Accepted with an exception · Recorded checks unchanged"];
|
|
84
91
|
const rows = [...statusDetailLines(status), ...progress, ...acceptance];
|
|
85
|
-
const text = [title, heading, next, ...(rows.length === 0 ? [] : ["", ...rows]), "",
|
|
92
|
+
const text = [title, heading, next, ...(rows.length === 0 ? [] : ["", ...rows]), "", projectLabel(project)].join("\n");
|
|
86
93
|
const failedChecks = status.details.some(one => one.key === "checks" && (one.mark === "failed" || one.mark === "note"));
|
|
87
94
|
const recovery = operatorHold !== undefined || accessBlocked || (!built && run.outcome !== null);
|
|
88
|
-
return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
95
|
+
return { text, next, status, facts, completedByLead: assignment?.completion?.lead === true, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
89
96
|
// Needs you: the button names the action that resolves it.
|
|
90
97
|
link: { label: status.need != null && status.need.key !== "other" && status.need.key !== "review-result" ? status.need.action.label : built ? "Open result" : "Open task", path: recovery
|
|
91
98
|
? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, failedChecks ? "checks" : "summary") : chatControlHref("task", taskId) } };
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* the settings page; this card only mints or revokes the signed-in person's
|
|
4
4
|
* pairing, never anyone else's. */
|
|
5
5
|
import type { Store } from "./store.js";
|
|
6
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
7
|
+
export declare const UNSENT_REPLY_MS: number;
|
|
6
8
|
export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, csrf: string, options?: {
|
|
7
9
|
code?: string;
|
|
8
10
|
problem?: string;
|
|
11
|
+
now?: Date;
|
|
9
12
|
}): string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
2
|
+
export const UNSENT_REPLY_MS = 2 * 60_000;
|
|
1
3
|
const escape = (text) => text
|
|
2
4
|
.replaceAll("&", "&")
|
|
3
5
|
.replaceAll("<", "<")
|
|
@@ -17,6 +19,15 @@ export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
|
|
|
17
19
|
const mine = bindings.filter(binding => binding.approver === who);
|
|
18
20
|
const others = bindings.length - mine.length;
|
|
19
21
|
content = `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`;
|
|
22
|
+
// A reply the assistant wrote that has not reached this person's phone for two minutes.
|
|
23
|
+
const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
|
|
24
|
+
if (unsent.length > 0) {
|
|
25
|
+
const oldest = unsent[0];
|
|
26
|
+
content +=
|
|
27
|
+
`<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
|
|
28
|
+
`<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
|
|
29
|
+
`<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
|
|
30
|
+
}
|
|
20
31
|
if (options.code !== undefined) {
|
|
21
32
|
content +=
|
|
22
33
|
"<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
|
|
@@ -72,4 +72,5 @@ export declare function phoneTaskView(store: Store, repos: readonly string[], id
|
|
|
72
72
|
run: number | null;
|
|
73
73
|
};
|
|
74
74
|
/** Concise display only: authorization uses the stored full project identity. */
|
|
75
|
-
|
|
75
|
+
/** Where a fact belongs: its project and, given one, the task's short title — never the task's id. */
|
|
76
|
+
export declare function notificationIdentity(row: Pick<Notification, "project" | "taskId">, title?: string): string;
|
package/dist/telegram-status.js
CHANGED
|
@@ -271,8 +271,9 @@ export function phoneTaskView(store, repos, id, now) {
|
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
/** Concise display only: authorization uses the stored full project identity. */
|
|
274
|
-
|
|
274
|
+
/** Where a fact belongs: its project and, given one, the task's short title — never the task's id. */
|
|
275
|
+
export function notificationIdentity(row, title) {
|
|
275
276
|
if (row.project === null)
|
|
276
277
|
return "";
|
|
277
|
-
return `${projectLabel(row.project)}${row.taskId === null ? "" : ` / ${plain(
|
|
278
|
+
return `${projectLabel(row.project)}${row.taskId === null || title === undefined ? "" : ` / ${plain(title, 64)}`} · `;
|
|
278
279
|
}
|
package/dist/telegram.d.ts
CHANGED
|
@@ -98,6 +98,20 @@ export type TelegramUpload = {
|
|
|
98
98
|
bytes: Buffer;
|
|
99
99
|
};
|
|
100
100
|
export declare function createTransport(token: string, timeoutMs?: number): TelegramTransport;
|
|
101
|
+
/**
|
|
102
|
+
* Why this process can't reach Telegram the way the service does, or null.
|
|
103
|
+
* A coding agent's sandbox fences the network: Codex says so in its own
|
|
104
|
+
* variables, and Claude Code's sandbox routes traffic through a proxy on
|
|
105
|
+
* this computer, which curl uses and Node's fetch does not — so sends fail
|
|
106
|
+
* "fetch failed" while curl works (Oct 2). Builds, checks and agents also
|
|
107
|
+
* get a database of their own, so none of them can hold the live bridge;
|
|
108
|
+
* this keeps an agent-launched `watch` or `bridge` from holding it either.
|
|
109
|
+
*/
|
|
110
|
+
export declare function networkFence(env: Record<string, string | undefined>): string | null;
|
|
111
|
+
/** The plain line a fenced process says instead of polling or sending. */
|
|
112
|
+
export declare function networkFenceLine(reason: string): string;
|
|
113
|
+
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
114
|
+
export declare function transportError(error: unknown): string;
|
|
101
115
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
102
116
|
export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
|
|
103
117
|
export declare function hashPairingCode(code: string): string;
|
|
@@ -227,4 +241,4 @@ export declare function followBridge(store: Store, options: {
|
|
|
227
241
|
/** The digest text: a header with the count and the window, then one
|
|
228
242
|
* fact per entry — its subject, then its body's first line, indented.
|
|
229
243
|
* Plain text, no buttons: nothing in a digest is tappable. */
|
|
230
|
-
export declare function digestText(rows: readonly Notification[], since: string | null, now: Date): string;
|
|
244
|
+
export declare function digestText(rows: readonly Notification[], since: string | null, now: Date, titleOf?: (taskId: string) => string): string;
|