@amenophis1er/foreman 0.1.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/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications: what reaches a human, and that nothing else does.
|
|
3
|
+
*
|
|
4
|
+
* The hub is tested with a fake transport because the properties that matter
|
|
5
|
+
* are the hub's — curation, the preference gate, dedupe, editing a message
|
|
6
|
+
* when its subject resolves. Telegram is tested against a stub of its Bot API
|
|
7
|
+
* because the linking flow is the one place a wrong assumption would mean
|
|
8
|
+
* talking to the wrong chat.
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import { DEDUPE_TTL_MS, NotifyHub, shape, type Envelope, type Transport } from './notify.js';
|
|
14
|
+
import { getMe, linkByCode, telegramTransport } from './notify/telegram.js';
|
|
15
|
+
|
|
16
|
+
function fakeTransport(name = 'fake') {
|
|
17
|
+
const sent: string[] = [];
|
|
18
|
+
const edits: Array<{ id: string; text: string }> = [];
|
|
19
|
+
let n = 0;
|
|
20
|
+
const t: Transport & { sent: string[]; edits: typeof edits } = {
|
|
21
|
+
name, sent, edits,
|
|
22
|
+
async send(text) { sent.push(text); return String(++n); },
|
|
23
|
+
async edit(id, text) { edits.push({ id, text }); },
|
|
24
|
+
};
|
|
25
|
+
return t;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ctx = (over: Partial<{ needsYou: boolean; done: boolean; budget: boolean }> = {}) => () => ({
|
|
29
|
+
prefs: { needsYou: true, done: true, budget: true, ...over },
|
|
30
|
+
publicUrl: 'http://box.local:4177',
|
|
31
|
+
projectName: (id: string) => (id === 'p1' ? 'shop' : undefined),
|
|
32
|
+
runLabel: (id: string) => (id === 'r1' ? 'Build the checkout page' : undefined),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const env = (event: string, data: Record<string, unknown>, extra: Partial<Envelope> = {}): Envelope =>
|
|
36
|
+
({ event, runId: 'r1', projectId: 'p1', data, ts: 1_000_000, ...extra });
|
|
37
|
+
|
|
38
|
+
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
39
|
+
|
|
40
|
+
test('only events a human should know about are shaped; transcript furniture is not', () => {
|
|
41
|
+
const c = ctx()();
|
|
42
|
+
assert.ok(shape(env('permission_request', { id: 'x', agent: 'director', toolName: 'Bash' }), c));
|
|
43
|
+
assert.ok(shape(env('worker_stalled', { id: 'worker-1', text: 'quiet for 8 minutes' }), c));
|
|
44
|
+
assert.ok(shape(env('run_finished', { status: 'done' }), c));
|
|
45
|
+
assert.equal(shape(env('message', { agent: 'director', msg: {} }), c), null);
|
|
46
|
+
assert.equal(shape(env('cost', { costUsd: 1 }), c), null);
|
|
47
|
+
assert.equal(shape(env('auto_allowed', { toolName: 'Read' }), c), null);
|
|
48
|
+
assert.equal(shape(env('worker_progress', { status: 'building' }), c), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a message names the project, the run, the deadline, and links to the right place', () => {
|
|
52
|
+
const s = shape(env('permission_request', {
|
|
53
|
+
id: 'x', agent: 'director', toolName: 'Write', description: '/Users/x/other/f.txt is outside',
|
|
54
|
+
}), ctx()())!;
|
|
55
|
+
assert.match(s.text, /shop/);
|
|
56
|
+
assert.match(s.text, /Build the checkout page/);
|
|
57
|
+
assert.match(s.text, /Auto-denied if unanswered in 10 min/);
|
|
58
|
+
assert.match(s.text, /http:\/\/box\.local:4177\/#\/p\/p1\/r\/r1/);
|
|
59
|
+
// A planner question links to the project, not to a run it does not have.
|
|
60
|
+
const q = shape(env('chat_question', { id: 'c', questions: [{ question: 'Stack?' }] }, { runId: null, chat: true }), ctx()())!;
|
|
61
|
+
assert.match(q.text, /\/#\/p\/p1"/);
|
|
62
|
+
assert.doesNotMatch(q.text, /\/r\//);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('text from agents is HTML-escaped, so a tool name cannot inject markup', () => {
|
|
66
|
+
const s = shape(env('permission_request', { id: 'x', agent: 'director', toolName: '<b>Bash</b>' }), ctx()())!;
|
|
67
|
+
assert.match(s.text, /<b>Bash<\/b>/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the Settings toggles gate the channel exactly as they gate the tab', async () => {
|
|
71
|
+
const t = fakeTransport();
|
|
72
|
+
const hub = new NotifyHub(ctx({ done: false, budget: false }));
|
|
73
|
+
hub.attach(t);
|
|
74
|
+
hub.handle(env('run_finished', { status: 'done' }));
|
|
75
|
+
hub.handle(env('budget_alert', { level: 'reached', text: 'cap' }));
|
|
76
|
+
hub.handle(env('permission_request', { id: 'x', agent: 'director', toolName: 'Bash' }));
|
|
77
|
+
await tick();
|
|
78
|
+
assert.equal(t.sent.length, 1, 'only the needs-you event passes');
|
|
79
|
+
assert.match(t.sent[0], /Needs you/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an identical announcement inside the TTL is suppressed; a timeout is not', async () => {
|
|
83
|
+
const t = fakeTransport();
|
|
84
|
+
const hub = new NotifyHub(ctx());
|
|
85
|
+
hub.attach(t);
|
|
86
|
+
hub.handle(env('question', { id: 'q1', question: 'Ship it?' }));
|
|
87
|
+
hub.handle(env('question', { id: 'q1', question: 'Ship it?' }, { ts: 1_000_000 + DEDUPE_TTL_MS / 2 }));
|
|
88
|
+
await tick();
|
|
89
|
+
assert.equal(t.sent.length, 1, 'a re-asking agent gets one tap');
|
|
90
|
+
hub.handle(env('question_timeout', { id: 'q1', afterMs: 600_000 }, { ts: 1_000_000 + 1000 }));
|
|
91
|
+
await tick();
|
|
92
|
+
// Same key, so the outcome edits the original rather than adding a message.
|
|
93
|
+
assert.equal(t.sent.length, 1);
|
|
94
|
+
assert.equal(t.edits.length, 1);
|
|
95
|
+
assert.match(t.edits[0].text, /Auto-answered/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('when the thing announced resolves, the message is edited, not followed', async () => {
|
|
99
|
+
const t = fakeTransport();
|
|
100
|
+
const hub = new NotifyHub(ctx());
|
|
101
|
+
hub.attach(t);
|
|
102
|
+
hub.handle(env('permission_request', { id: 'p9', agent: 'director', toolName: 'Bash' }));
|
|
103
|
+
await tick();
|
|
104
|
+
hub.handle(env('permission_resolved', { id: 'p9', behavior: 'allow' }));
|
|
105
|
+
await tick();
|
|
106
|
+
assert.equal(t.sent.length, 1);
|
|
107
|
+
assert.equal(t.edits.length, 1);
|
|
108
|
+
assert.equal(t.edits[0].id, '1');
|
|
109
|
+
assert.match(t.edits[0].text, /Needs you[\s\S]*✓ allow/);
|
|
110
|
+
// A resolution for something never announced is silently nothing.
|
|
111
|
+
hub.handle(env('permission_resolved', { id: 'never', behavior: 'deny' }));
|
|
112
|
+
await tick();
|
|
113
|
+
assert.equal(t.edits.length, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a transport that throws costs a failure count, never an exception on the emitter', async () => {
|
|
117
|
+
const bad: Transport = { name: 'bad', async send() { throw new Error('down'); }, async edit() { throw new Error('down'); } };
|
|
118
|
+
const hub = new NotifyHub(ctx());
|
|
119
|
+
hub.attach(bad);
|
|
120
|
+
assert.doesNotThrow(() => hub.handle(env('run_finished', { status: 'error' })));
|
|
121
|
+
await tick();
|
|
122
|
+
assert.equal(hub.failures, 1);
|
|
123
|
+
assert.equal(hub.delivered, 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Telegram, against a stub of the Bot API
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function stubTelegram(script: { updates?: unknown[][] } = {}) {
|
|
131
|
+
const calls: Array<{ method: string; body: any; token: string }> = [];
|
|
132
|
+
let updateBatch = 0;
|
|
133
|
+
const server = http.createServer((req, res) => {
|
|
134
|
+
const chunks: Buffer[] = [];
|
|
135
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
136
|
+
req.on('end', () => {
|
|
137
|
+
const m = /^\/bot([^/]+)\/(\w+)$/.exec(req.url ?? '');
|
|
138
|
+
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
|
|
139
|
+
calls.push({ token: m?.[1] ?? '', method: m?.[2] ?? '', body });
|
|
140
|
+
let result: unknown = true;
|
|
141
|
+
if (m?.[2] === 'getMe') result = { username: 'foreman_test_bot' };
|
|
142
|
+
if (m?.[2] === 'sendMessage') result = { message_id: 42 };
|
|
143
|
+
if (m?.[2] === 'getUpdates') result = (script.updates ?? [])[updateBatch++] ?? [];
|
|
144
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return new Promise<{ base: string; calls: typeof calls; close(): Promise<void> }>((resolve) => {
|
|
149
|
+
server.listen(0, '127.0.0.1', () => {
|
|
150
|
+
const port = (server.address() as { port: number }).port;
|
|
151
|
+
resolve({ base: `http://127.0.0.1:${port}`, calls, close: () => new Promise((r) => server.close(() => r())) });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test('getMe validates a token and names the bot; a dead endpoint yields null, not a throw', async (t) => {
|
|
157
|
+
const tg = await stubTelegram();
|
|
158
|
+
t.after(() => tg.close());
|
|
159
|
+
assert.deepEqual(await getMe('123:abc', tg.base), { username: 'foreman_test_bot' });
|
|
160
|
+
assert.equal(await getMe('123:abc', 'http://127.0.0.1:1'), null);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('send and edit speak Bot API HTML to the linked chat only', async (t) => {
|
|
164
|
+
const tg = await stubTelegram();
|
|
165
|
+
t.after(() => tg.close());
|
|
166
|
+
const tr = telegramTransport('123:abc', '777', tg.base);
|
|
167
|
+
const id = await tr.send('<b>hi</b>');
|
|
168
|
+
assert.equal(id, '42');
|
|
169
|
+
await tr.edit(id!, 'edited');
|
|
170
|
+
const [send, edit] = tg.calls;
|
|
171
|
+
assert.equal(send.method, 'sendMessage');
|
|
172
|
+
assert.equal(send.body.chat_id, '777');
|
|
173
|
+
assert.equal(send.body.parse_mode, 'HTML');
|
|
174
|
+
assert.equal(edit.method, 'editMessageText');
|
|
175
|
+
assert.equal(edit.body.message_id, 42);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('linking waits for exactly /start <code>; a wrong code from a stranger links nothing', async (t) => {
|
|
179
|
+
const tg = await stubTelegram({ updates: [
|
|
180
|
+
[{ update_id: 1, message: { text: '/start WRONG1', chat: { id: 1, username: 'stranger' } } }],
|
|
181
|
+
[{ update_id: 2, message: { text: 'hello', chat: { id: 2 } } },
|
|
182
|
+
{ update_id: 3, message: { text: '/start ABC234', chat: { id: 555, username: 'amen' } } }],
|
|
183
|
+
] });
|
|
184
|
+
t.after(() => tg.close());
|
|
185
|
+
const link = linkByCode('123:abc', 'ABC234', { apiBase: tg.base, maxMs: 10_000 });
|
|
186
|
+
const got = await link.done;
|
|
187
|
+
assert.deepEqual(got, { chatId: '555', label: '@amen' });
|
|
188
|
+
// The offset advanced past the winning update so it is not replayed later.
|
|
189
|
+
const last = tg.calls.filter((c) => c.method === 'getUpdates').at(-1)!;
|
|
190
|
+
assert.equal(last.body.offset, 4);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('linking gives up at its deadline and can be aborted', async (t) => {
|
|
194
|
+
const tg = await stubTelegram({ updates: [[], [], []] });
|
|
195
|
+
t.after(() => tg.close());
|
|
196
|
+
const link = linkByCode('123:abc', 'ABC234', { apiBase: tg.base, maxMs: 50 });
|
|
197
|
+
assert.equal(await link.done, null);
|
|
198
|
+
const l2 = linkByCode('123:abc', 'ABC234', { apiBase: tg.base, maxMs: 60_000 });
|
|
199
|
+
l2.abort();
|
|
200
|
+
assert.equal(await l2.done, null);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the deep link opens the bot with the code pre-filled, whatever form the username took', async () => {
|
|
204
|
+
const { telegramStartLink } = await import('./notify/telegram.js');
|
|
205
|
+
assert.equal(telegramStartLink('@ForemanAgentBot', 'W55TMZ'), 'https://t.me/ForemanAgentBot?start=W55TMZ');
|
|
206
|
+
assert.equal(telegramStartLink('ForemanAgentBot', 'W55TMZ'), 'https://t.me/ForemanAgentBot?start=W55TMZ');
|
|
207
|
+
// Telegram delivers that payload as the message "/start W55TMZ" — exactly
|
|
208
|
+
// what linkByCode waits for, so scanning is the same act as typing.
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Phase B: answering from the channel
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function buttonTransport() {
|
|
216
|
+
const t = fakeTransport('tg') as ReturnType<typeof fakeTransport> & { buttons: Array<unknown> ; editButtons: Array<unknown> };
|
|
217
|
+
t.buttons = []; t.editButtons = [];
|
|
218
|
+
const send = t.send.bind(t), edit = t.edit.bind(t);
|
|
219
|
+
t.send = async (text, opts) => { t.buttons.push(opts?.buttons ?? null); return send(text); };
|
|
220
|
+
t.edit = async (id, text, opts) => { t.editButtons.push(opts?.buttons ?? null); return edit(id, text); };
|
|
221
|
+
return t;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test('an approval carries Allow/Deny buttons, and a tap resolves it — never "always"', async () => {
|
|
225
|
+
const t = buttonTransport();
|
|
226
|
+
const answers: unknown[] = [];
|
|
227
|
+
const hub = new NotifyHub(ctx()); hub.attach(t); hub.onAnswer((a) => answers.push(a));
|
|
228
|
+
hub.handle(env('permission_request', { id: 'toolu_1', agent: 'director', toolName: 'Bash' }));
|
|
229
|
+
await tick();
|
|
230
|
+
const rows = t.buttons[0] as Array<Array<{ label: string; data: string }>>;
|
|
231
|
+
assert.equal(rows.length, 1);
|
|
232
|
+
assert.deepEqual(rows[0].map((b) => b.data), ['p|toolu_1|allow', 'p|toolu_1|deny']);
|
|
233
|
+
assert.ok(rows.flat().every((b) => !/always/i.test(b.label)));
|
|
234
|
+
assert.equal(hub.handleCallback('p|toolu_1|deny', '1'), true);
|
|
235
|
+
assert.deepEqual(answers, [{ kind: 'perm', id: 'toolu_1', behavior: 'deny' }]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a forged or stale tap is ignored', () => {
|
|
239
|
+
const hub = new NotifyHub(ctx()); hub.attach(fakeTransport());
|
|
240
|
+
assert.equal(hub.handleCallback('p|nope|allow'), false);
|
|
241
|
+
assert.equal(hub.handleCallback('garbage'), false);
|
|
242
|
+
assert.equal(hub.handleCallback('cq|nope|0|0'), false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('planner questions step through one message; the last tap answers them all', async () => {
|
|
246
|
+
const t = buttonTransport();
|
|
247
|
+
const answers: unknown[] = [];
|
|
248
|
+
const hub = new NotifyHub(ctx()); hub.attach(t); hub.onAnswer((a) => answers.push(a));
|
|
249
|
+
hub.handle(env('chat_question', { id: 'q-1', questions: [
|
|
250
|
+
{ question: 'Stack?', options: [{ label: 'Static HTML' }, { label: 'React' }] },
|
|
251
|
+
{ question: 'Imagery?', options: [{ label: 'Stock photos', hint: 'Unsplash' }, { label: 'Placeholders' }] },
|
|
252
|
+
] }, { runId: null, chat: true }));
|
|
253
|
+
await tick();
|
|
254
|
+
assert.match(t.sent[0], /1\/2/);
|
|
255
|
+
const first = t.buttons[0] as Array<Array<{ data: string; label: string }>>;
|
|
256
|
+
assert.deepEqual(first.map((r) => r[0].data), ['cq|q-1|0|0', 'cq|q-1|0|1']);
|
|
257
|
+
assert.match(first[0][0].label, /★/, 'the recommended option is starred');
|
|
258
|
+
|
|
259
|
+
// A tap on step 2's buttons before step 2 exists is stale and ignored.
|
|
260
|
+
assert.equal(hub.handleCallback('cq|q-1|1|0', '1'), false);
|
|
261
|
+
// Tap step 1 → the message is edited to step 2 with step 2's buttons.
|
|
262
|
+
assert.equal(hub.handleCallback('cq|q-1|0|1', '1'), true);
|
|
263
|
+
await tick();
|
|
264
|
+
assert.equal(answers.length, 0, 'not answered until every question has a value');
|
|
265
|
+
assert.match(t.edits.at(-1)!.text, /✓ Stack\? → <b>React<\/b>[\s\S]*2\/2[\s\S]*Imagery\?/);
|
|
266
|
+
const second = t.editButtons.at(-1) as Array<Array<{ data: string }>>;
|
|
267
|
+
assert.deepEqual(second.map((r) => r[0].data), ['cq|q-1|1|0', 'cq|q-1|1|1']);
|
|
268
|
+
// Tap step 2 → the whole set is answered, in the picker's shape.
|
|
269
|
+
hub.handleCallback('cq|q-1|1|0', '1');
|
|
270
|
+
assert.deepEqual(answers, [{ kind: 'cq', projectId: 'p1', id: 'q-1', answers: { 'Stack?': 'React', 'Imagery?': 'Stock photos' } }]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('a typed reply is the picker’s "something else", and a director question takes free text', async () => {
|
|
274
|
+
const answers: unknown[] = [];
|
|
275
|
+
const hub = new NotifyHub(ctx()); hub.attach(fakeTransport()); hub.onAnswer((a) => answers.push(a));
|
|
276
|
+
hub.handle(env('question', { id: 'dq', question: 'Ship to prod?' }));
|
|
277
|
+
await tick();
|
|
278
|
+
assert.equal(hub.handleText(' yes, but after the tests '), true);
|
|
279
|
+
assert.deepEqual(answers.at(-1), { kind: 'q', id: 'dq', text: 'yes, but after the tests' });
|
|
280
|
+
// With nothing pending, text is not an answer to anything.
|
|
281
|
+
assert.equal(hub.handleText('hello?'), false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('the bot dispatches taps and texts from the linked chat only, and acknowledges every tap', async (t) => {
|
|
285
|
+
const { TelegramBot } = await import('./notify/telegram.js');
|
|
286
|
+
const tg = await stubTelegram({ updates: [[
|
|
287
|
+
{ update_id: 10, callback_query: { id: 'cb1', data: 'p|x|allow', from: { id: 999 }, message: { message_id: 5, chat: { id: 999 } } } },
|
|
288
|
+
{ update_id: 11, callback_query: { id: 'cb2', data: 'p|x|deny', from: { id: 555 }, message: { message_id: 6, chat: { id: 555 } } } },
|
|
289
|
+
{ update_id: 12, message: { message_id: 7, text: 'from a stranger', chat: { id: 999 } } },
|
|
290
|
+
{ update_id: 13, message: { message_id: 8, text: 'from me', chat: { id: 555 }, reply_to_message: { message_id: 6 } } },
|
|
291
|
+
]] });
|
|
292
|
+
t.after(() => tg.close());
|
|
293
|
+
const taps: unknown[] = []; const texts: unknown[] = [];
|
|
294
|
+
const bot = new TelegramBot('123:abc', tg.base, '555', {
|
|
295
|
+
onCallback: (d, m) => taps.push([d, m]), onText: (x, r) => texts.push([x, r]),
|
|
296
|
+
});
|
|
297
|
+
bot.start();
|
|
298
|
+
for (let i = 0; i < 40 && taps.length + texts.length < 2; i++) await tick();
|
|
299
|
+
bot.stop();
|
|
300
|
+
assert.deepEqual(taps, [['p|x|deny', '6']], 'only the linked chat’s tap is dispatched');
|
|
301
|
+
assert.deepEqual(texts, [['from me', '6']]);
|
|
302
|
+
const acks = tg.calls.filter((c) => c.method === 'answerCallbackQuery').map((c) => c.body.callback_query_id);
|
|
303
|
+
assert.deepEqual(acks.sort(), ['cb1', 'cb2'], 'a stranger’s tap is acknowledged too, so their spinner stops');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('a stepped planner question resolves onto head and link — never under a stale option list', async () => {
|
|
307
|
+
// Seen on the first live run: the finished message showed step one's
|
|
308
|
+
// options again above the two answers, because the base text was never
|
|
309
|
+
// updated while stepping.
|
|
310
|
+
const t = buttonTransport();
|
|
311
|
+
const hub = new NotifyHub(ctx()); hub.attach(t); hub.onAnswer(() => {});
|
|
312
|
+
hub.handle(env('chat_question', { id: 'q-9', questions: [
|
|
313
|
+
{ question: 'Hero?', options: [{ label: 'Dark gym' }, { label: 'Track' }] },
|
|
314
|
+
{ question: 'CTA?', options: [{ label: 'Orange' }, { label: 'Silver' }] },
|
|
315
|
+
] }, { runId: null, chat: true }));
|
|
316
|
+
await tick();
|
|
317
|
+
hub.handleCallback('cq|q-9|0|0', '1');
|
|
318
|
+
await tick();
|
|
319
|
+
hub.handleCallback('cq|q-9|1|1', '1');
|
|
320
|
+
hub.handle(env('chat_answered', { id: 'q-9', answers: { 'Hero?': 'Dark gym', 'CTA?': 'Silver' }, source: 'telegram' }, { runId: null, chat: true }));
|
|
321
|
+
await tick();
|
|
322
|
+
const final = t.edits.at(-1)!.text;
|
|
323
|
+
assert.doesNotMatch(final, /Tap an option/);
|
|
324
|
+
assert.doesNotMatch(final, /1\. <b>Dark gym<\/b>/, 'no option list survives into the finished message');
|
|
325
|
+
assert.match(final, /Needs you — the planner asks/);
|
|
326
|
+
assert.match(final, /✓ Hero\? → <b>Dark gym<\/b>/);
|
|
327
|
+
assert.match(final, /✓ CTA\? → <b>Silver<\/b> · via telegram/);
|
|
328
|
+
assert.match(final, /Open in Foreman/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a director question with options gets buttons, and a tap answers with the label', async () => {
|
|
332
|
+
const t = buttonTransport();
|
|
333
|
+
const answers: unknown[] = [];
|
|
334
|
+
const hub = new NotifyHub(ctx()); hub.attach(t); hub.onAnswer((a) => answers.push(a));
|
|
335
|
+
hub.handle(env('question', { id: 'dq2', question: 'Deploy target?', options: ['Staging', 'Production'] }));
|
|
336
|
+
await tick();
|
|
337
|
+
const rows = t.buttons[0] as Array<Array<{ label: string; data: string }>>;
|
|
338
|
+
assert.deepEqual(rows.map((r) => r[0].data), ['q|dq2|0', 'q|dq2|1']);
|
|
339
|
+
assert.match(rows[0][0].label, /★/);
|
|
340
|
+
assert.equal(hub.handleCallback('q|dq2|1', '1'), true);
|
|
341
|
+
assert.deepEqual(answers, [{ kind: 'q', id: 'dq2', text: 'Production' }]);
|
|
342
|
+
assert.equal(hub.handleCallback('q|dq2|1', '1'), false, 'a second tap finds nothing pending');
|
|
343
|
+
});
|