@bill10/agent-007 0.9.3000 → 0.11.0
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/.env.example +5 -0
- package/README.md +8 -5
- package/VERSION +1 -1
- package/bin/agent-007.js +1 -0
- package/package.json +1 -1
- package/public/app.js +9 -4
- package/public/index.html +5 -0
- package/public/modules/explorer.js +1 -27
- package/public/modules/jobs.js +3 -0
- package/public/modules/state.js +8 -2
- package/public/modules/terminal.js +32 -2
- package/public/modules/waiting.js +164 -0
- package/public/style.css +116 -6
- package/server/http.js +6 -3
- package/server/jobs.js +13 -4
- package/server/mcp.js +48 -8
- package/server/owner.js +159 -31
- package/server/ws.js +181 -90
- package/server.js +5 -2
- package/templates/billion/charter.md +17 -6
package/server/jobs.js
CHANGED
|
@@ -19,7 +19,7 @@ import { safeFilename, expandHome } from '../lib/helpers.js';
|
|
|
19
19
|
import { sendNotice } from './messages.js';
|
|
20
20
|
import { liveBillion } from './billion.js';
|
|
21
21
|
import {
|
|
22
|
-
createJob, selectDispatchableJobs, buildJobCommand, deriveJobStatus,
|
|
22
|
+
createJob, selectDispatchableJobs, countInFlightByRepo, buildJobCommand, deriveJobStatus,
|
|
23
23
|
parsePrList, parseMergedPr, openPrListArgs, mergedPrListArgs, closedPrViewArgs, parseClosedPr, prCiViewArgs, parsePrCi,
|
|
24
24
|
branchSlugFromTitle, isValidPermissionMode, resolveJobPermissionMode, dispatchPermissionMode,
|
|
25
25
|
JOB_STATES,
|
|
@@ -1134,6 +1134,12 @@ function liveSessionIds() {
|
|
|
1134
1134
|
return live;
|
|
1135
1135
|
}
|
|
1136
1136
|
|
|
1137
|
+
// Whether the board's cap is already full in this repo. A re-spawned worker
|
|
1138
|
+
// counts like a dispatched one once relinkSessionToJob ties it to its card.
|
|
1139
|
+
export function repoAtCap(repoPath) {
|
|
1140
|
+
return (countInFlightByRepo(allJobs(), liveSessionIds()).get(repoPath) || 0) >= boardSettings().maxPerRepo;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1137
1143
|
// Repos we can actually spawn into right now. A repo removed from the sidebar
|
|
1138
1144
|
// (or whose directory has gone missing) leaves its jobs queued rather than
|
|
1139
1145
|
// failing them — the path may well come back.
|
|
@@ -2123,7 +2129,7 @@ let scanInFlight = false;
|
|
|
2123
2129
|
// findPr/findMerged are forwarded rather than left to their defaults so the
|
|
2124
2130
|
// order of the scan — PRs found, then merges swept, then dispatch — is
|
|
2125
2131
|
// reachable from a test without talking to GitHub.
|
|
2126
|
-
export async function runScan(createSession, broadcast, { onSessionCreated, killSession, findPr, findMerged, findClosed } = {}) {
|
|
2132
|
+
export async function runScan(createSession, broadcast, { onSessionCreated, killSession, respawnWorkers, findPr, findMerged, findClosed } = {}) {
|
|
2127
2133
|
if (scanInFlight) return { skipped: true };
|
|
2128
2134
|
scanInFlight = true;
|
|
2129
2135
|
try {
|
|
@@ -2132,6 +2138,9 @@ export async function runScan(createSession, broadcast, { onSessionCreated, kill
|
|
|
2132
2138
|
await checkMergedPullRequests(broadcast, { killSession, findMerged, findClosed, findPr });
|
|
2133
2139
|
pruneFinishedRuns(broadcast);
|
|
2134
2140
|
fireSchedules(broadcast);
|
|
2141
|
+
// Before dispatch: a worker parked by a restart has its slot first, ahead
|
|
2142
|
+
// of a new card in the same repo.
|
|
2143
|
+
if (respawnWorkers) await respawnWorkers();
|
|
2135
2144
|
await dispatchOnce(createSession, broadcast, { onSessionCreated, killSession });
|
|
2136
2145
|
return { skipped: false };
|
|
2137
2146
|
} finally {
|
|
@@ -2174,13 +2183,13 @@ let loopGeneration = 0;
|
|
|
2174
2183
|
|
|
2175
2184
|
// Self-rescheduling rather than setInterval so a slow git/gh pass can never
|
|
2176
2185
|
// overlap the next tick (same reasoning as startTreeScanLoop in git.js).
|
|
2177
|
-
export function startDispatcher(createSession, broadcast, { onSessionCreated, killSession } = {}) {
|
|
2186
|
+
export function startDispatcher(createSession, broadcast, { onSessionCreated, killSession, respawnWorkers } = {}) {
|
|
2178
2187
|
stopDispatcher();
|
|
2179
2188
|
const generation = loopGeneration;
|
|
2180
2189
|
const tick = async () => {
|
|
2181
2190
|
try {
|
|
2182
2191
|
if (boardSettings().running) {
|
|
2183
|
-
await runScan(createSession, broadcast, { onSessionCreated, killSession });
|
|
2192
|
+
await runScan(createSession, broadcast, { onSessionCreated, killSession, respawnWorkers });
|
|
2184
2193
|
}
|
|
2185
2194
|
} catch (err) {
|
|
2186
2195
|
console.error('Job dispatcher tick failed:', err.message);
|
package/server/mcp.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { JOB_STATES, STATE_LABELS, JOB_AGENTS } from '../lib/jobs.js';
|
|
23
23
|
import { APPROVAL_WAIT_MS } from './agent-mcp.js';
|
|
24
24
|
import { SCREEN_LINES_DEFAULT, SCREEN_LINES_MAX, quoteLines, oneLine } from './messages.js';
|
|
25
|
+
import { MAX_CHOICES, MAX_CHOICE_CHARS } from './owner.js';
|
|
25
26
|
|
|
26
27
|
// Echoed back from the client's own initialize when it sends one. MCP clients
|
|
27
28
|
// negotiate this, and answering with whatever the client asked for is the
|
|
@@ -326,15 +327,23 @@ export const NOTIFY_OWNER_TOOL = {
|
|
|
326
327
|
name: 'notify_owner',
|
|
327
328
|
description:
|
|
328
329
|
'Put a question or a decision in front of the owner when they may be away '
|
|
329
|
-
+ 'from the terminal: it
|
|
330
|
-
+ '
|
|
331
|
-
+ 'up. One short message: the question, why, and what you recommend.
|
|
332
|
-
+ '
|
|
333
|
-
+ '
|
|
330
|
+
+ 'from the terminal: it goes in the "Waiting on you" tab of the owner\'s '
|
|
331
|
+
+ 'browser, numbered (Q3), and to their phone over Telegram when that is set '
|
|
332
|
+
+ 'up. One short message: the question, why, and what you recommend. When the '
|
|
333
|
+
+ 'answer is a pick, pass choices (yes/no, maybe one alternative) and mark the '
|
|
334
|
+
+ 'one you recommend: the owner answers with one tap. Their answer arrives in '
|
|
335
|
+
+ 'this terminal as "[Owner via app] Q3: <answer>" or "[Owner via Telegram] Q3: '
|
|
336
|
+
+ '<answer>". At most a few per minute.',
|
|
334
337
|
inputSchema: {
|
|
335
338
|
type: 'object',
|
|
336
339
|
properties: {
|
|
337
340
|
text: { type: 'string', description: 'The message, written to be read on a phone.' },
|
|
341
|
+
choices: {
|
|
342
|
+
type: 'array', minItems: 2, maxItems: MAX_CHOICES,
|
|
343
|
+
items: { type: 'string', minLength: 1, maxLength: MAX_CHOICE_CHARS },
|
|
344
|
+
description: `2 to ${MAX_CHOICES} short answers the owner can tap. The owner can still type something else.`,
|
|
345
|
+
},
|
|
346
|
+
recommended: { type: 'string', description: 'The choice you recommend; must be one of choices.' },
|
|
338
347
|
},
|
|
339
348
|
required: ['text'],
|
|
340
349
|
additionalProperties: false,
|
|
@@ -366,8 +375,28 @@ export const READ_AGENT_SCREEN_TOOL = {
|
|
|
366
375
|
},
|
|
367
376
|
};
|
|
368
377
|
|
|
378
|
+
// Billion's too, on read_agent_screen's rule: only the workers on its own
|
|
379
|
+
// cards (server/ws.js, respawnAgent).
|
|
380
|
+
export const RESPAWN_AGENT_TOOL = {
|
|
381
|
+
name: 'respawn_agent',
|
|
382
|
+
description:
|
|
383
|
+
'Bring back an orphaned worker on one of your cards: it resumes its own '
|
|
384
|
+
+ 'worktree and conversation and gets its card back. Use it when a worker on '
|
|
385
|
+
+ 'your card was parked in the orphans list (closed, or a restart it was not '
|
|
386
|
+
+ 'brought back from). Only workers on cards you posted; never a new worktree, '
|
|
387
|
+
+ 'and the board\'s per-repo cap holds. Names come from list_jobs.',
|
|
388
|
+
inputSchema: {
|
|
389
|
+
type: 'object',
|
|
390
|
+
properties: {
|
|
391
|
+
name: { type: 'string', description: 'The orphaned worker\'s name, as list_jobs shows it on its card.' },
|
|
392
|
+
},
|
|
393
|
+
required: ['name'],
|
|
394
|
+
additionalProperties: false,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
369
398
|
export const TOOLS = [POST_JOB_TOOL, LIST_JOBS_TOOL, READ_JOB_TOOL, EDIT_JOB_TOOL, FINISH_JOB_TOOL, LIST_AGENTS_TOOL, SEND_MESSAGE_TOOL];
|
|
370
|
-
const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL, NOTIFY_OWNER_TOOL, READ_AGENT_SCREEN_TOOL];
|
|
399
|
+
const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL, NOTIFY_OWNER_TOOL, READ_AGENT_SCREEN_TOOL, RESPAWN_AGENT_TOOL];
|
|
371
400
|
|
|
372
401
|
export function toolsFor(session) {
|
|
373
402
|
return session?.isBillion ? [...TOOLS, ...BILLION_TOOLS] : TOOLS;
|
|
@@ -568,9 +597,11 @@ const CALLS = {
|
|
|
568
597
|
},
|
|
569
598
|
|
|
570
599
|
[NOTIFY_OWNER_TOOL.name]: async (args, ctx) => {
|
|
571
|
-
const result = ctx.notifyOwner
|
|
600
|
+
const result = ctx.notifyOwner
|
|
601
|
+
? await ctx.notifyOwner(args.text, { choices: args.choices, recommended: args.recommended })
|
|
602
|
+
: { error: 'Only Billion can notify the owner.' };
|
|
572
603
|
if (result.error) return toolText(result.error, true);
|
|
573
|
-
return toolText(
|
|
604
|
+
return toolText(`Sent to the owner on Telegram and put under "Waiting on you" as Q${result.n}. Keep working on everything else; their answer, if any, arrives here as [Owner via app] Q${result.n}: … or [Owner via Telegram] Q${result.n}: ….`);
|
|
574
605
|
},
|
|
575
606
|
|
|
576
607
|
// Quoted line by line, like a message body, so the screen cannot pass for
|
|
@@ -584,6 +615,15 @@ const CALLS = {
|
|
|
584
615
|
+ `${result.text ? quoteLines(result.text).join('\n') : '(nothing on screen)'}\n[End of screen]`);
|
|
585
616
|
},
|
|
586
617
|
|
|
618
|
+
[RESPAWN_AGENT_TOOL.name]: async (args, ctx) => {
|
|
619
|
+
const result = ctx.respawnAgent
|
|
620
|
+
? await ctx.respawnAgent({ name: args.name })
|
|
621
|
+
: { error: 'Only Billion can re-spawn agents.' };
|
|
622
|
+
if (result.error) return toolText(result.error, true);
|
|
623
|
+
return toolText(`${result.name} is back on "${result.card.title}", resuming its own conversation`
|
|
624
|
+
+ (result.card.state === 'in-progress' ? ' with a nudge to continue the card.' : '.'));
|
|
625
|
+
},
|
|
626
|
+
|
|
587
627
|
[LIST_AGENTS_TOOL.name]: (args, ctx) => {
|
|
588
628
|
const agents = ctx.listAgents();
|
|
589
629
|
if (!agents.length) return toolText('No other agents are running that you can message.');
|
package/server/owner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Reaching the owner when they are away from the terminal: Billion's
|
|
2
|
-
// notify_owner tool, the "Waiting on you"
|
|
3
|
-
// Telegram bot that carries both ways (docs/BILLION.md, "Telegram").
|
|
2
|
+
// notify_owner tool, the "Waiting on you" tab it fills in the browser (where
|
|
3
|
+
// the owner can answer too), and a Telegram bot that carries both ways (docs/BILLION.md, "Telegram").
|
|
4
4
|
//
|
|
5
5
|
// Telegram is optional: with TELEGRAM_BOT_TOKEN unset nothing here talks to
|
|
6
6
|
// the network and the list still works. Plain fetch against the Bot API, no
|
|
@@ -27,6 +27,11 @@ const MAX_BACKOFF_MS = 60 * 1000;
|
|
|
27
27
|
const WAITING_CAP = 50;
|
|
28
28
|
export const OWNER_PREFIX = '[Owner via Telegram]';
|
|
29
29
|
export const OWNER_VOICE_PREFIX = '[Owner via Telegram, voice]';
|
|
30
|
+
export const APP_PREFIX = '[Owner via app]';
|
|
31
|
+
export const MAX_CHOICES = 5;
|
|
32
|
+
export const MAX_CHOICE_CHARS = 40;
|
|
33
|
+
export const MAX_ANSWER_CHARS = 2000;
|
|
34
|
+
const CLOSED_KEPT = 30; // answered and dismissed items kept; open ones always are
|
|
30
35
|
|
|
31
36
|
export function telegramSettings(env = process.env) {
|
|
32
37
|
const token = (env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
@@ -62,12 +67,13 @@ async function call(method, params, { env = process.env, signal } = {}) {
|
|
|
62
67
|
return body.result;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
// extra: more sendMessage fields (reply_markup). Returns { ok, messageId } or { error }.
|
|
71
|
+
export async function sendTelegram(text, { env = process.env, extra } = {}) {
|
|
66
72
|
const { token, chatId } = telegramSettings(env);
|
|
67
73
|
if (!token || !chatId) return { error: 'Telegram is not configured' };
|
|
68
74
|
try {
|
|
69
|
-
await call('sendMessage', { chat_id: chatId, text }, { env });
|
|
70
|
-
return { ok: true };
|
|
75
|
+
const sent = await call('sendMessage', { chat_id: chatId, text, ...extra }, { env });
|
|
76
|
+
return { ok: true, messageId: sent?.message_id };
|
|
71
77
|
} catch (err) {
|
|
72
78
|
return { error: err.message };
|
|
73
79
|
}
|
|
@@ -95,7 +101,7 @@ function saveOwnerMode(mode) {
|
|
|
95
101
|
|
|
96
102
|
// text spoken, with text as the caption so links stay tappable. Returns
|
|
97
103
|
// { ok } or { error }; the caller sends text instead on an error.
|
|
98
|
-
export async function sendVoice(text, { env = process.env } = {}) {
|
|
104
|
+
export async function sendVoice(text, { env = process.env, extra } = {}) {
|
|
99
105
|
const { chatId } = telegramSettings(env);
|
|
100
106
|
try {
|
|
101
107
|
const ogg = await synthesize(text, env);
|
|
@@ -103,8 +109,9 @@ export async function sendVoice(text, { env = process.env } = {}) {
|
|
|
103
109
|
form.append('chat_id', chatId);
|
|
104
110
|
form.append('caption', text); // under Telegram's 1024: voice is for texts of 900 or fewer
|
|
105
111
|
form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'billion.ogg');
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
if (extra?.reply_markup) form.append('reply_markup', JSON.stringify(extra.reply_markup));
|
|
113
|
+
const sent = await call('sendVoice', form, { env });
|
|
114
|
+
return { ok: true, messageId: sent?.message_id, voice: true };
|
|
108
115
|
} catch (err) {
|
|
109
116
|
return { error: redact(err.message, env) };
|
|
110
117
|
}
|
|
@@ -113,17 +120,17 @@ export async function sendVoice(text, { env = process.env } = {}) {
|
|
|
113
120
|
let voiceOffLogged = false;
|
|
114
121
|
|
|
115
122
|
// A message to the owner, as voice or text by TELEGRAM_VOICE (docs/BILLION.md, "Voice").
|
|
116
|
-
export async function sendToOwner(text, { env = process.env, platform = process.platform } = {}) {
|
|
123
|
+
export async function sendToOwner(text, { env = process.env, platform = process.platform, extra } = {}) {
|
|
117
124
|
if (chooseMode(text, { env, lastMode: lastOwnerMode() }).mode === 'voice') {
|
|
118
125
|
const off = speechUnavailable(env, platform);
|
|
119
|
-
const result = off ? { error: off } : await sendVoice(text, { env });
|
|
126
|
+
const result = off ? { error: off } : await sendVoice(text, { env, extra });
|
|
120
127
|
if (!result.error) return result;
|
|
121
128
|
if (!voiceOffLogged) {
|
|
122
129
|
voiceOffLogged = true;
|
|
123
130
|
console.log(` Telegram: sending text, not voice: ${result.error}`);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
|
-
return sendTelegram(text, { env });
|
|
133
|
+
return sendTelegram(text, { env, extra });
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
// A voice note's bytes, or throws a redacted Error.
|
|
@@ -160,62 +167,151 @@ async function transcribeNote(note, env) {
|
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
// --- The "Waiting on you" list, in the config dir so it survives restarts ---
|
|
170
|
+
//
|
|
171
|
+
// An item: { id, n, text, at, choices?, recommended?, status, answer?,
|
|
172
|
+
// answeredAt?, answeredVia?, tgMessageId?, tgVoice? }. n is the short number
|
|
173
|
+
// the owner sees (Q3). status is open, answered or dismissed. Items written
|
|
174
|
+
// before v0.10 have neither n nor status: they read as open, numbered in order.
|
|
163
175
|
|
|
164
176
|
const waitingPath = () => join(CONFIG_DIR, 'waiting.json');
|
|
165
177
|
|
|
166
178
|
export function waitingItems() {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
let items;
|
|
180
|
+
try { items = JSON.parse(readFileSync(waitingPath(), 'utf8')); } catch { return []; }
|
|
181
|
+
if (!Array.isArray(items)) return [];
|
|
182
|
+
return items.map((item, i) => ({ ...item, n: item.n ?? i + 1, status: item.status || 'open' }));
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
function saveWaiting(items) {
|
|
186
|
+
// The newest open questions, and of the rest the newest few.
|
|
187
|
+
let open = items.filter(item => item.status === 'open').length - WAITING_CAP;
|
|
188
|
+
let closed = items.length - (open + WAITING_CAP) - CLOSED_KEPT;
|
|
189
|
+
const kept = items.filter(item => (item.status === 'open' ? open-- <= 0 : closed-- <= 0));
|
|
174
190
|
const tmp = `${waitingPath()}.tmp`;
|
|
175
|
-
writeFileSync(tmp, JSON.stringify(
|
|
191
|
+
writeFileSync(tmp, JSON.stringify(kept, null, 2));
|
|
176
192
|
renameSync(tmp, waitingPath());
|
|
177
193
|
}
|
|
178
194
|
|
|
179
|
-
export const waitingPayload = () => ({ type: 'waiting-list', items: waitingItems() });
|
|
195
|
+
export const waitingPayload = () => ({ type: 'waiting-list', items: waitingItems().filter(item => item.status !== 'dismissed') });
|
|
180
196
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
// choices: 2-5 short distinct strings, or absent; recommended: one of them.
|
|
198
|
+
export function checkChoices(choices, recommended) {
|
|
199
|
+
if (choices === undefined || choices === null) {
|
|
200
|
+
return recommended === undefined || recommended === null ? null : 'recommended needs choices to pick from.';
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(choices) || choices.length < 2 || choices.length > MAX_CHOICES) return `choices must be a list of 2 to ${MAX_CHOICES} options.`;
|
|
203
|
+
if (choices.some(c => typeof c !== 'string' || !c.trim() || c.trim().length > MAX_CHOICE_CHARS)) return `Each choice must be text of 1 to ${MAX_CHOICE_CHARS} characters.`;
|
|
204
|
+
if (new Set(choices.map(c => c.trim())).size !== choices.length) return 'The choices must all differ.';
|
|
205
|
+
if (recommended !== undefined && recommended !== null && !(typeof recommended === 'string' && choices.map(c => c.trim()).includes(recommended.trim()))) return 'recommended must be one of the choices.';
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function addWaiting(text, broadcast, now = Date.now(), { choices, recommended } = {}) {
|
|
210
|
+
const items = waitingItems();
|
|
211
|
+
const item = { id: randomUUID(), n: Math.max(0, ...items.map(i => i.n)) + 1, text, at: new Date(now).toISOString(), status: 'open' };
|
|
212
|
+
if (choices) item.choices = choices.map(c => c.trim());
|
|
213
|
+
if (recommended) item.recommended = recommended.trim();
|
|
214
|
+
saveWaiting([...items, item]);
|
|
184
215
|
broadcast?.(waitingPayload());
|
|
216
|
+
return item;
|
|
185
217
|
}
|
|
186
218
|
|
|
187
|
-
|
|
219
|
+
function updateWaiting(id, change) {
|
|
188
220
|
const items = waitingItems();
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
221
|
+
const item = items.find(i => i.id === id);
|
|
222
|
+
if (!item) return null;
|
|
223
|
+
Object.assign(item, change);
|
|
224
|
+
saveWaiting(items);
|
|
225
|
+
return item;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function dismissWaiting(id, broadcast) {
|
|
229
|
+
if (!waitingItems().some(item => item.id === id && item.status !== 'dismissed')) return false;
|
|
230
|
+
updateWaiting(id, { status: 'dismissed' });
|
|
192
231
|
broadcast?.(waitingPayload());
|
|
193
232
|
return true;
|
|
194
233
|
}
|
|
195
234
|
|
|
235
|
+
// The line Billion reads: who answered, which question, the answer, and the
|
|
236
|
+
// start of the question so it knows what "yes" is to.
|
|
237
|
+
export function answerLine(prefix, item, answer) {
|
|
238
|
+
const flat = item.text.replace(/\s+/g, ' ').trim();
|
|
239
|
+
const context = flat.length > 60 ? `${flat.slice(0, 60).trimEnd()}…` : flat;
|
|
240
|
+
return `${prefix} Q${item.n}: ${answer} (re: "${context}")`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// What the owner's Telegram shows for a question.
|
|
244
|
+
const questionText = (item) => `Billion (Q${item.n}): ${item.text}`;
|
|
245
|
+
|
|
246
|
+
// An answer from the app or Telegram (via 'app' or 'telegram'): into Billion's
|
|
247
|
+
// terminal, then the item is answered everywhere. { ok, item } or { error };
|
|
248
|
+
// on an error the item stays open.
|
|
249
|
+
export async function answerWaiting(id, answer, via, { broadcast, env = process.env } = {}) {
|
|
250
|
+
const body = typeof answer === 'string' ? answer.replace(/\s+/g, ' ').trim() : '';
|
|
251
|
+
if (!body) return { error: 'The answer is empty.' };
|
|
252
|
+
if (body.length > MAX_ANSWER_CHARS) return { error: `Keep the answer under ${MAX_ANSWER_CHARS} characters.` };
|
|
253
|
+
const item = waitingItems().find(i => i.id === id);
|
|
254
|
+
if (!item || item.status === 'dismissed') return { error: 'That question is gone.' };
|
|
255
|
+
if (item.status === 'answered') return { error: `Q${item.n} was answered already: ${item.answer}` };
|
|
256
|
+
const billion = liveBillion();
|
|
257
|
+
if (!billion) return { error: 'Billion is not running' };
|
|
258
|
+
if (!sendText(billion, answerLine(via === 'app' ? APP_PREFIX : OWNER_PREFIX, item, body))) {
|
|
259
|
+
return { error: 'Billion has too much waiting for it; try again in a while.' };
|
|
260
|
+
}
|
|
261
|
+
const done = updateWaiting(id, { status: 'answered', answer: body, answeredAt: new Date().toISOString(), answeredVia: via });
|
|
262
|
+
broadcast?.(waitingPayload());
|
|
263
|
+
if (done.tgMessageId) await showAnswerOnPhone(done, env);
|
|
264
|
+
return { ok: true, item: done };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The phone's copy of an answered question shows the answer, and loses its buttons.
|
|
268
|
+
async function showAnswerOnPhone(item, env) {
|
|
269
|
+
const { chatId } = telegramSettings(env);
|
|
270
|
+
const shown = `${questionText(item)}\n\nAnswered${item.answeredVia === 'app' ? ' in app' : ''}: ${item.answer}`;
|
|
271
|
+
const edit = item.tgVoice
|
|
272
|
+
? call('editMessageCaption', { chat_id: chatId, message_id: item.tgMessageId, caption: shown.slice(0, 1024) }, { env })
|
|
273
|
+
: call('editMessageText', { chat_id: chatId, message_id: item.tgMessageId, text: shown.slice(0, 4096) }, { env });
|
|
274
|
+
await edit.catch(err => console.error('Telegram: could not mark a question answered:', redact(err.message, env)));
|
|
275
|
+
}
|
|
276
|
+
|
|
196
277
|
// --- notify_owner ---
|
|
197
278
|
|
|
198
279
|
let sent = []; // times of recent notify_owner calls
|
|
199
280
|
|
|
200
|
-
export async function notifyOwner(text, { broadcast, env = process.env, now = Date.now(), platform = process.platform } = {}) {
|
|
281
|
+
export async function notifyOwner(text, { choices, recommended, broadcast, env = process.env, now = Date.now(), platform = process.platform } = {}) {
|
|
201
282
|
const body = typeof text === 'string' ? text.trim() : '';
|
|
202
283
|
if (!body) return { error: 'The message is empty.' };
|
|
203
284
|
if (body.length > MAX_NOTIFY_CHARS) return { error: `The message is ${body.length} characters; keep it under ${MAX_NOTIFY_CHARS}.` };
|
|
285
|
+
const bad = checkChoices(choices, recommended);
|
|
286
|
+
if (bad) return { error: bad };
|
|
204
287
|
sent = sent.filter(t => now - t < NOTIFY_WINDOW_MS);
|
|
205
288
|
if (sent.length >= NOTIFY_LIMIT) {
|
|
206
289
|
return { error: `Not sent: you have notified the owner ${NOTIFY_LIMIT} times in the last minute. Put the rest in one message later, or under Waiting on you in STATE.md.` };
|
|
207
290
|
}
|
|
208
291
|
sent.push(now);
|
|
209
|
-
|
|
292
|
+
let item;
|
|
293
|
+
try { item = addWaiting(body, broadcast, now, { choices, recommended }); } catch (err) {
|
|
210
294
|
console.error('Could not save the Waiting on you list:', err.message);
|
|
211
295
|
}
|
|
296
|
+
const n = item ? ` as Q${item.n}` : '';
|
|
212
297
|
const { token, chatId } = telegramSettings(env);
|
|
213
298
|
if (!token || !chatId) {
|
|
214
|
-
return { pinned: true, error:
|
|
299
|
+
return { pinned: true, n: item?.n, error: `Pinned under "Waiting on you" in the owner's browser${n}, but not sent to their phone: Telegram is not configured (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID). Say it in your terminal as well.` };
|
|
215
300
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
301
|
+
// A button per choice; callback_data is "<id>:<index>", 38 bytes of Telegram's 64.
|
|
302
|
+
const keyboard = item?.choices && {
|
|
303
|
+
reply_markup: { inline_keyboard: item.choices.map((c, i) => [{ text: c === item.recommended ? `${c} (recommended)` : c, callback_data: `${item.id}:${i}` }]) },
|
|
304
|
+
};
|
|
305
|
+
const result = await sendToOwner(item ? questionText(item) : `Billion: ${body}`, { env, platform, extra: keyboard || undefined });
|
|
306
|
+
if (result.error) return { pinned: true, n: item?.n, error: `Pinned under "Waiting on you" in the owner's browser${n}, but the Telegram send failed: ${result.error}` };
|
|
307
|
+
// Kept so a reply to this message, or a tap on its buttons, finds the question.
|
|
308
|
+
if (item && result.messageId) {
|
|
309
|
+
let saved;
|
|
310
|
+
try { saved = updateWaiting(item.id, { tgMessageId: result.messageId, ...(result.voice ? { tgVoice: true } : {}) }); } catch {}
|
|
311
|
+
// Answered in the app while the send was on its way.
|
|
312
|
+
if (saved?.status === 'answered') await showAnswerOnPhone(saved, env);
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, n: item?.n };
|
|
219
315
|
}
|
|
220
316
|
|
|
221
317
|
// --- Replies: long-polling getUpdates ---
|
|
@@ -224,6 +320,7 @@ const discovered = new Set(); // chat ids already shown, so a stranger's first
|
|
|
224
320
|
|
|
225
321
|
// One update. Returns what happened, for the tests and the log.
|
|
226
322
|
export async function handleUpdate(update, { broadcast, env = process.env } = {}) {
|
|
323
|
+
if (update?.callback_query) return handleButton(update.callback_query, { broadcast, env });
|
|
227
324
|
const msg = update?.message;
|
|
228
325
|
const chat = msg?.chat?.id;
|
|
229
326
|
if (chat === undefined || chat === null) return 'ignored';
|
|
@@ -244,6 +341,17 @@ export async function handleUpdate(update, { broadcast, env = process.env } = {}
|
|
|
244
341
|
const typed = typeof msg.text === 'string' && msg.text.trim() ? msg.text : null;
|
|
245
342
|
if (!note && !typed) return 'ignored';
|
|
246
343
|
saveOwnerMode(note ? 'voice' : 'text');
|
|
344
|
+
// A typed reply to one of Billion's questions answers that question.
|
|
345
|
+
const repliedTo = typed && msg.reply_to_message?.message_id;
|
|
346
|
+
const question = repliedTo && waitingItems().find(i => i.tgMessageId === repliedTo && i.status === 'open');
|
|
347
|
+
if (question) {
|
|
348
|
+
const result = await answerWaiting(question.id, typed, 'telegram', { broadcast, env });
|
|
349
|
+
if (result.error) {
|
|
350
|
+
await sendTelegram(result.error, { env });
|
|
351
|
+
return result.error === 'Billion is not running' ? 'not-running' : 'full';
|
|
352
|
+
}
|
|
353
|
+
return 'answered';
|
|
354
|
+
}
|
|
247
355
|
const billion = liveBillion();
|
|
248
356
|
if (!billion) {
|
|
249
357
|
await sendTelegram('Billion is not running', { env });
|
|
@@ -266,9 +374,29 @@ export async function handleUpdate(update, { broadcast, env = process.env } = {}
|
|
|
266
374
|
return 'delivered';
|
|
267
375
|
}
|
|
268
376
|
|
|
377
|
+
// A tap on a question's button: "<item id>:<choice index>".
|
|
378
|
+
async function handleButton(query, { broadcast, env }) {
|
|
379
|
+
const { chatId } = telegramSettings(env);
|
|
380
|
+
// The owner's chat only, and nothing said to anyone else.
|
|
381
|
+
if (!chatId || String(query.message?.chat?.id) !== chatId) return 'ignored';
|
|
382
|
+
const [id, index] = String(query.data || '').split(':');
|
|
383
|
+
const item = waitingItems().find(i => i.id === id);
|
|
384
|
+
const choice = item?.choices?.[Number(index)];
|
|
385
|
+
const ack = (text) => call('answerCallbackQuery', { callback_query_id: query.id, text }, { env })
|
|
386
|
+
.catch(err => console.error('Telegram: could not answer a button:', redact(err.message, env)));
|
|
387
|
+
if (!choice || item.status !== 'open') {
|
|
388
|
+
await ack(item?.status === 'answered' ? `Already answered: ${item.answer}` : 'That question is gone.');
|
|
389
|
+
return 'stale';
|
|
390
|
+
}
|
|
391
|
+
const result = await answerWaiting(id, choice, 'telegram', { broadcast, env });
|
|
392
|
+
await ack(result.error || `Sent: ${choice}`);
|
|
393
|
+
if (result.error) return result.error === 'Billion is not running' ? 'not-running' : 'full';
|
|
394
|
+
return 'answered';
|
|
395
|
+
}
|
|
396
|
+
|
|
269
397
|
// One getUpdates round. Returns the next offset.
|
|
270
398
|
export async function pollOnce(offset, { broadcast, env = process.env, signal } = {}) {
|
|
271
|
-
const params = { timeout: POLL_TIMEOUT_S, allowed_updates: ['message'] };
|
|
399
|
+
const params = { timeout: POLL_TIMEOUT_S, allowed_updates: ['message', 'callback_query'] };
|
|
272
400
|
if (offset) params.offset = offset;
|
|
273
401
|
// Longer than Telegram's own wait, so a dead connection still gives up.
|
|
274
402
|
const deadline = AbortSignal.timeout((POLL_TIMEOUT_S + 15) * 1000);
|