@celilo/cli 0.14.4 → 0.16.1
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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +19 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +3 -30
- package/src/cli/completion.ts +20 -1
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +14 -0
- package/src/db/schema.ts +5 -3
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +17 -2
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +235 -8
- package/src/services/alerting/inbound-poller.ts +95 -34
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- package/src/templates/generator.ts +119 -16
|
@@ -20,17 +20,48 @@ import { loadCapabilityFunctions } from '../../hooks/capability-loader';
|
|
|
20
20
|
import { createCapturingLogger } from '../../hooks/logger';
|
|
21
21
|
import { type AckResult, acknowledgeAlert } from './ack';
|
|
22
22
|
import { interpretInbound } from './inbound';
|
|
23
|
-
import { parseInterviewAnswer } from './interview-responder';
|
|
24
23
|
import type { NotificationTransport } from './notifier';
|
|
25
24
|
import { composeAckBroadcastBody } from './notifier';
|
|
26
25
|
import { consumeDelivery } from './tokens';
|
|
27
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What one attempt to read a transport produced.
|
|
29
|
+
*
|
|
30
|
+
* `unidirectional` and `failed` used to collapse into a bare null, and that
|
|
31
|
+
* cost a week: a transport whose read call was being REFUSED reported the same
|
|
32
|
+
* "0 message(s)" as a transport nobody had replied on. An operator could not
|
|
33
|
+
* tell "my reply never arrived" from "celilo cannot read this transport at
|
|
34
|
+
* all", and neither could anyone debugging it.
|
|
35
|
+
*/
|
|
36
|
+
export type TransportReceive =
|
|
37
|
+
| { status: 'received'; messages: InboundMessage[]; cursor: string | null }
|
|
38
|
+
| { status: 'unidirectional' }
|
|
39
|
+
| { status: 'failed'; error: string };
|
|
40
|
+
|
|
41
|
+
/** A transport that could not be read, and why. */
|
|
42
|
+
export interface TransportFailure {
|
|
43
|
+
transportModuleId: string;
|
|
44
|
+
error: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A message that was read but not acted on, and why. */
|
|
48
|
+
export interface UnheardMessage {
|
|
49
|
+
senderAddress: string;
|
|
50
|
+
reason:
|
|
51
|
+
| 'unknown_sender'
|
|
52
|
+
| 'unknown_token'
|
|
53
|
+
| 'wrong_sender'
|
|
54
|
+
| 'ambiguous'
|
|
55
|
+
| 'unrecognised'
|
|
56
|
+
/** A deploy question was named, but with no value to answer it with. */
|
|
57
|
+
| 'needs_value'
|
|
58
|
+
/** The token was valid, but the alert it names no longer exists. */
|
|
59
|
+
| 'stale_target';
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
export interface InboundPollDeps {
|
|
29
|
-
/** Receive from one transport module
|
|
30
|
-
receiveFrom(
|
|
31
|
-
transportModuleId: string,
|
|
32
|
-
cursor: string | null,
|
|
33
|
-
): Promise<{ messages: InboundMessage[]; cursor: string | null } | null>;
|
|
63
|
+
/** Receive from one transport module. */
|
|
64
|
+
receiveFrom(transportModuleId: string, cursor: string | null): Promise<TransportReceive>;
|
|
34
65
|
/** Persisted receive cursor per transport. */
|
|
35
66
|
readCursor(transportModuleId: string): string | null;
|
|
36
67
|
writeCursor(transportModuleId: string, cursor: string | null): void;
|
|
@@ -53,12 +84,14 @@ export interface InboundPollReport {
|
|
|
53
84
|
transportsPolled: number;
|
|
54
85
|
messagesRead: number;
|
|
55
86
|
acked: number;
|
|
56
|
-
ignored: number;
|
|
57
|
-
rejected: number;
|
|
58
87
|
/** Routes told that someone else took the alert. */
|
|
59
88
|
broadcast: number;
|
|
60
89
|
/** Deploy questions answered from a phone. */
|
|
61
90
|
answered: number;
|
|
91
|
+
/** Transports that could not be read at all. Never silent — see above. */
|
|
92
|
+
failures: TransportFailure[];
|
|
93
|
+
/** Messages read and then discarded, with the reason each was discarded. */
|
|
94
|
+
unheard: UnheardMessage[];
|
|
62
95
|
}
|
|
63
96
|
|
|
64
97
|
/** Transports that have at least one route pointing at them. */
|
|
@@ -89,19 +122,24 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
|
|
|
89
122
|
transportsPolled: 0,
|
|
90
123
|
messagesRead: 0,
|
|
91
124
|
acked: 0,
|
|
92
|
-
ignored: 0,
|
|
93
|
-
rejected: 0,
|
|
94
125
|
broadcast: 0,
|
|
95
126
|
answered: 0,
|
|
127
|
+
failures: [],
|
|
128
|
+
unheard: [],
|
|
96
129
|
};
|
|
97
130
|
|
|
98
131
|
for (const transportId of transportsWithRoutes(db)) {
|
|
99
132
|
const received = await deps.receiveFrom(transportId, deps.readCursor(transportId));
|
|
100
133
|
report.transportsPolled++;
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// the
|
|
104
|
-
|
|
134
|
+
// One dead transport must not stop the others being read — but it is
|
|
135
|
+
// RECORDED rather than skipped in silence, because "cannot read" and
|
|
136
|
+
// "nothing to read" are the two things an operator most needs to tell
|
|
137
|
+
// apart at 3am.
|
|
138
|
+
if (received.status === 'failed') {
|
|
139
|
+
report.failures.push({ transportModuleId: transportId, error: received.error });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (received.status === 'unidirectional') continue;
|
|
105
143
|
|
|
106
144
|
for (const message of received.messages) {
|
|
107
145
|
report.messagesRead++;
|
|
@@ -113,25 +151,26 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
|
|
|
113
151
|
outstandingForRoute: (routeId) => outstandingAlertDeliveries(db, routeId, deps.now()),
|
|
114
152
|
});
|
|
115
153
|
|
|
116
|
-
if (outcome.action === 'ignored') {
|
|
117
|
-
report.
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (outcome.action === 'rejected') {
|
|
121
|
-
report.rejected++;
|
|
154
|
+
if (outcome.action === 'ignored' || outcome.action === 'rejected') {
|
|
155
|
+
report.unheard.push({ senderAddress: message.senderAddress, reason: outcome.reason });
|
|
122
156
|
continue;
|
|
123
157
|
}
|
|
124
158
|
|
|
125
159
|
// An interview reply IS the answer rather than an acknowledgement —
|
|
126
160
|
// same token table, same sender check, different meaning for the body.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
161
|
+
// The value was extracted by `interpretInbound`, which is the only place
|
|
162
|
+
// that knows the delivery's kind; re-parsing it here is what left the
|
|
163
|
+
// real grammar unreachable for months (#533).
|
|
164
|
+
if (outcome.action === 'answer') {
|
|
165
|
+
if (!deps.answerInterview) {
|
|
166
|
+
// Nothing is attached to publish the answer against, so the question
|
|
167
|
+
// is still unanswered. Say so rather than consuming the token: the
|
|
168
|
+
// operator's next attempt has to be able to work.
|
|
169
|
+
report.unheard.push({ senderAddress: message.senderAddress, reason: 'unrecognised' });
|
|
131
170
|
continue;
|
|
132
171
|
}
|
|
133
172
|
consumeDelivery(db, outcome.delivery.id, deps.now());
|
|
134
|
-
deps.answerInterview(outcome.delivery.targetId, value);
|
|
173
|
+
deps.answerInterview(outcome.delivery.targetId, outcome.value);
|
|
135
174
|
report.answered++;
|
|
136
175
|
continue;
|
|
137
176
|
}
|
|
@@ -145,12 +184,23 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
|
|
|
145
184
|
outcome.route.personId,
|
|
146
185
|
deps.now(),
|
|
147
186
|
);
|
|
187
|
+
|
|
188
|
+
// A live token can outlive the alert it names. When the row is gone
|
|
189
|
+
// there is nothing to acknowledge, and saying "1 acked" anyway is a lie
|
|
190
|
+
// the operator has no way to catch — observed live: a reply reported
|
|
191
|
+
// `1 acked` while every alert still read `ackedBy: null`. Count what
|
|
192
|
+
// HAPPENED, never what was attempted.
|
|
193
|
+
if (!ack) {
|
|
194
|
+
report.unheard.push({ senderAddress: message.senderAddress, reason: 'stale_target' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
148
198
|
report.acked++;
|
|
149
199
|
|
|
150
200
|
// Everyone else who was paged is still expecting to act. Telling them is
|
|
151
201
|
// the entire point of a per-delivery token — it is what makes the reply
|
|
152
202
|
// identify a PERSON rather than just an alert.
|
|
153
|
-
|
|
203
|
+
report.broadcast += await broadcastAck(db, ack, deps);
|
|
154
204
|
}
|
|
155
205
|
|
|
156
206
|
deps.writeCursor(transportId, received.cursor);
|
|
@@ -213,12 +263,23 @@ function outstandingAlertDeliveries(db: DbClient, routeId: string, now: Date) {
|
|
|
213
263
|
*
|
|
214
264
|
* A transport with no `receive` is unidirectional — that is not an error, it
|
|
215
265
|
* just means replies cannot arrive, which the route's `can_ack` already
|
|
216
|
-
* records.
|
|
266
|
+
* records. Anything else going wrong IS an error and is returned as one.
|
|
267
|
+
*
|
|
268
|
+
* This used to be a bare `catch {}` returning null, on the reasoning that the
|
|
269
|
+
* transport's own health check would report an unreachable daemon. That was
|
|
270
|
+
* wrong twice over: a health check that only proves the daemon answers cannot
|
|
271
|
+
* see a read call being REFUSED by a daemon that is otherwise perfectly
|
|
272
|
+
* healthy, and the swallowed error was the only place the reason existed. The
|
|
273
|
+
* live signal-cli case was exactly that shape — `receive` refused with
|
|
274
|
+
* "Receive command cannot be used if messages are already being received."
|
|
275
|
+
* while every health check passed and every poll reported zero messages.
|
|
217
276
|
*/
|
|
218
277
|
export function makeReceiver(db: DbClient) {
|
|
219
|
-
return async (transportModuleId: string, cursor: string | null) => {
|
|
278
|
+
return async (transportModuleId: string, cursor: string | null): Promise<TransportReceive> => {
|
|
220
279
|
const module = db.select().from(modules).where(eq(modules.id, transportModuleId)).get();
|
|
221
|
-
if (!module)
|
|
280
|
+
if (!module) {
|
|
281
|
+
return { status: 'failed', error: `no module '${transportModuleId}' is installed` };
|
|
282
|
+
}
|
|
222
283
|
|
|
223
284
|
try {
|
|
224
285
|
const { logger } = createCapturingLogger();
|
|
@@ -226,11 +287,11 @@ export function makeReceiver(db: DbClient) {
|
|
|
226
287
|
const notification = (capabilities as Record<string, unknown>).notification as
|
|
227
288
|
| NotificationCapability
|
|
228
289
|
| undefined;
|
|
229
|
-
if (!notification?.receive) return
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
290
|
+
if (!notification?.receive) return { status: 'unidirectional' };
|
|
291
|
+
const result = await notification.receive(cursor);
|
|
292
|
+
return { status: 'received', messages: result.messages, cursor: result.cursor };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { status: 'failed', error: error instanceof Error ? error.message : String(error) };
|
|
234
295
|
}
|
|
235
296
|
};
|
|
236
297
|
}
|
|
@@ -33,8 +33,37 @@ describe('parseInbound', () => {
|
|
|
33
33
|
expect(parseInbound('K7QM2X ack')).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test.each(['', ' ', 'what is going on', '
|
|
37
|
-
|
|
36
|
+
test.each(['', ' ', 'what is going on', 'K7QM2XY', 'ack now please'])(
|
|
37
|
+
'%p is unrecognised',
|
|
38
|
+
(body) => {
|
|
39
|
+
expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// The bug that started this: the page says "reply BPRJEH", and a human types
|
|
44
|
+
// "ack BPRJEH". The old grammar read the VERB as the token, failed the length
|
|
45
|
+
// check, and rejected a real acknowledgement over word order.
|
|
46
|
+
test.each([
|
|
47
|
+
['ack K7QM2X', 'K7QM2X'],
|
|
48
|
+
['ACK K7QM2X', 'K7QM2X'],
|
|
49
|
+
['ok k7qm2x', 'K7QM2X'],
|
|
50
|
+
['K7QM2X ack', 'K7QM2X'],
|
|
51
|
+
['k7qm2x OK', 'K7QM2X'],
|
|
52
|
+
['👍 K7QM2X', 'K7QM2X'],
|
|
53
|
+
[' ack k7qm2x ', 'K7QM2X'],
|
|
54
|
+
])('%p yields token %p — the verb may lead, trail, or be absent', (body, token) => {
|
|
55
|
+
expect(parseInbound(body)).toEqual({ kind: 'ack', token });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// A prefix is legal INPUT; whether it identifies anything is the delivery
|
|
59
|
+
// table's question, not the parser's.
|
|
60
|
+
test.each([
|
|
61
|
+
['bp', 'BP'],
|
|
62
|
+
['ack bp', 'BP'],
|
|
63
|
+
['BP ack', 'BP'],
|
|
64
|
+
['b', 'B'],
|
|
65
|
+
])('%p parses as the prefix %p', (body, token) => {
|
|
66
|
+
expect(parseInbound(body)).toEqual({ kind: 'ack', token });
|
|
38
67
|
});
|
|
39
68
|
});
|
|
40
69
|
|
|
@@ -128,6 +157,69 @@ describe('interpretInbound', () => {
|
|
|
128
157
|
expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
|
|
129
158
|
});
|
|
130
159
|
|
|
160
|
+
describe('an abbreviated token', () => {
|
|
161
|
+
// How much an operator must type depends on what is actually outstanding.
|
|
162
|
+
test('resolves when the prefix is unique among live deliveries', () => {
|
|
163
|
+
const delivery = mintFor('r-peter');
|
|
164
|
+
const prefix = delivery.token.slice(0, 2);
|
|
165
|
+
const outcome = interpretInbound(db, context(PETER, `ack ${prefix.toLowerCase()}`));
|
|
166
|
+
expect(outcome).toMatchObject({ action: 'ack' });
|
|
167
|
+
expect(outcome.action === 'ack' && outcome.delivery.id).toBe(delivery.id);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a single character is enough when only one alert is live', () => {
|
|
171
|
+
const delivery = mintFor('r-peter');
|
|
172
|
+
// One character in 32 is `K`, which is ALSO an ack synonym — so that
|
|
173
|
+
// token parses as a bare ack rather than as a prefix. Either reading
|
|
174
|
+
// must land on the single live delivery, so the harness reflects it as
|
|
175
|
+
// outstanding as well; without this the test failed ~3% of runs with
|
|
176
|
+
// `unknown_token`, because the bare-ack path saw nothing outstanding.
|
|
177
|
+
outstanding.push(delivery);
|
|
178
|
+
const outcome = interpretInbound(db, context(PETER, delivery.token.slice(0, 1)));
|
|
179
|
+
expect(outcome).toMatchObject({ action: 'ack' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Never a guess: acknowledging the wrong alert is worse than asking again.
|
|
183
|
+
test('is refused as ambiguous when two live deliveries share the prefix', () => {
|
|
184
|
+
const a = mintFor('r-peter', 'alert-a');
|
|
185
|
+
let b = mintFor('r-peter', 'alert-b');
|
|
186
|
+
// Force a shared prefix rather than hoping two random tokens collide.
|
|
187
|
+
for (let i = 0; i < 40 && b.token[0] !== a.token[0]; i++) {
|
|
188
|
+
b = mintFor('r-peter', `alert-b-${i}`);
|
|
189
|
+
}
|
|
190
|
+
if (b.token[0] !== a.token[0]) return; // vanishingly unlikely; do not flake
|
|
191
|
+
// As above: a shared first character of `K` parses as a bare ack, and
|
|
192
|
+
// that path reads `outstandingForRoute` rather than the prefix search.
|
|
193
|
+
// Both readings must answer `ambiguous` here, so both are given two
|
|
194
|
+
// candidates to be ambiguous between.
|
|
195
|
+
outstanding.push(a, b);
|
|
196
|
+
expect(interpretInbound(db, context(PETER, a.token[0]))).toEqual({
|
|
197
|
+
action: 'rejected',
|
|
198
|
+
reason: 'ambiguous',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Uniqueness is scoped to what is LIVE, so yesterday's tokens cannot make
|
|
203
|
+
// today's prefix ambiguous.
|
|
204
|
+
test('an expired delivery does not make a prefix ambiguous', () => {
|
|
205
|
+
const delivery = mintFor('r-peter');
|
|
206
|
+
const late = {
|
|
207
|
+
...context(PETER, delivery.token.slice(0, 2)),
|
|
208
|
+
now: new Date(NOW.getTime() + TTL_MS + 1),
|
|
209
|
+
};
|
|
210
|
+
expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// The second factor still holds for a prefix.
|
|
214
|
+
test('a prefix from the WRONG sender is still refused', () => {
|
|
215
|
+
const delivery = mintFor('r-peter');
|
|
216
|
+
expect(interpretInbound(db, context(WIFE, delivery.token.slice(0, 3)))).toEqual({
|
|
217
|
+
action: 'rejected',
|
|
218
|
+
reason: 'wrong_sender',
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
131
223
|
test('gibberish from a known sender is rejected as unrecognised', () => {
|
|
132
224
|
expect(interpretInbound(db, context(PETER, 'what is going on'))).toEqual({
|
|
133
225
|
action: 'rejected',
|
|
@@ -168,6 +260,125 @@ describe('interpretInbound', () => {
|
|
|
168
260
|
});
|
|
169
261
|
});
|
|
170
262
|
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Answering a deploy question.
|
|
266
|
+
*
|
|
267
|
+
* These ran against `parseInterviewAnswer` in isolation and all passed while
|
|
268
|
+
* the feature was dead in production (#533) — the alert grammar rejected
|
|
269
|
+
* every one of these bodies before the answer path was ever reached. They
|
|
270
|
+
* live here now, against `interpretInbound`, because the wiring is the part
|
|
271
|
+
* that was broken and the part worth guarding.
|
|
272
|
+
*/
|
|
273
|
+
describe('a deploy question', () => {
|
|
274
|
+
const askFor = (routeId: string, targetId = 'evt-1') =>
|
|
275
|
+
mintDelivery(db, { kind: 'interview', targetId, routeId, now: NOW, ttlMs: TTL_MS });
|
|
276
|
+
|
|
277
|
+
test('takes everything after the token as the answer', () => {
|
|
278
|
+
const q = askFor('r-peter');
|
|
279
|
+
expect(interpretInbound(db, context(PETER, `${q.token} www.example.com`))).toMatchObject({
|
|
280
|
+
action: 'answer',
|
|
281
|
+
value: 'www.example.com',
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('is case-insensitive on the token', () => {
|
|
286
|
+
const q = askFor('r-peter');
|
|
287
|
+
expect(
|
|
288
|
+
interpretInbound(db, context(PETER, `${q.token.toLowerCase()} www.example.com`)),
|
|
289
|
+
).toMatchObject({ action: 'answer', value: 'www.example.com' });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// An answer may legitimately contain spaces and punctuation. Second-guessing
|
|
293
|
+
// it would corrupt exactly the values that are painful to retype. This is
|
|
294
|
+
// the shape that was refused as `unrecognised` for months.
|
|
295
|
+
test('preserves an answer containing spaces and punctuation verbatim', () => {
|
|
296
|
+
const q = askFor('r-peter');
|
|
297
|
+
expect(
|
|
298
|
+
interpretInbound(db, context(PETER, `${q.token} my value: with, punctuation`)),
|
|
299
|
+
).toMatchObject({ action: 'answer', value: 'my value: with, punctuation' });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// The word that must NOT acknowledge an alert is a perfectly good answer to
|
|
303
|
+
// a question. That the same text means different things is the whole reason
|
|
304
|
+
// the token is resolved before the grammar is chosen.
|
|
305
|
+
test('an answer that looks like a terminal verb is still an answer', () => {
|
|
306
|
+
const q = askFor('r-peter');
|
|
307
|
+
expect(interpretInbound(db, context(PETER, `${q.token} resolve`))).toMatchObject({
|
|
308
|
+
action: 'answer',
|
|
309
|
+
value: 'resolve',
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('a token with no value says so, rather than being unrecognised', () => {
|
|
314
|
+
const q = askFor('r-peter');
|
|
315
|
+
for (const body of [q.token, `${q.token} `]) {
|
|
316
|
+
expect(interpretInbound(db, context(PETER, body))).toEqual({
|
|
317
|
+
action: 'rejected',
|
|
318
|
+
reason: 'needs_value',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// A verb-led message names no value, so it is not an answer.
|
|
324
|
+
test('a leading ack verb is not an answer', () => {
|
|
325
|
+
const q = askFor('r-peter');
|
|
326
|
+
expect(interpretInbound(db, context(PETER, `ack ${q.token}`))).toEqual({
|
|
327
|
+
action: 'rejected',
|
|
328
|
+
reason: 'unrecognised',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Both factors still apply — a question is no less sensitive than a page.
|
|
333
|
+
test('an answer from the wrong number is refused', () => {
|
|
334
|
+
const q = askFor('r-peter');
|
|
335
|
+
expect(interpretInbound(db, context(WIFE, `${q.token} www.example.com`))).toEqual({
|
|
336
|
+
action: 'rejected',
|
|
337
|
+
reason: 'wrong_sender',
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Reported as `unrecognised` rather than `unknown_token`, and that is
|
|
342
|
+
// deliberate: `ZZZZZZ value` and `what is going on` are the same shape to
|
|
343
|
+
// celilo — a token-shaped word followed by text — because the token
|
|
344
|
+
// alphabet is most of the Latin one. Nothing corroborates that the leading
|
|
345
|
+
// word was meant as a token, so claiming the operator got a TOKEN wrong
|
|
346
|
+
// would send them hunting for a typo they may not have made.
|
|
347
|
+
test('an unknown leading token is not answerable', () => {
|
|
348
|
+
askFor('r-peter');
|
|
349
|
+
expect(interpretInbound(db, context(PETER, 'ZZZZZZ value'))).toEqual({
|
|
350
|
+
action: 'rejected',
|
|
351
|
+
reason: 'unrecognised',
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The two grammars share a table, so the same body must mean different
|
|
358
|
+
* things depending on what the token names. Asserting both readings side by
|
|
359
|
+
* side is the guard against one of them quietly swallowing the other again.
|
|
360
|
+
*/
|
|
361
|
+
describe('the two grammars do not bleed into each other', () => {
|
|
362
|
+
test('`<token> resolve` answers a question and REFUSES an alert', () => {
|
|
363
|
+
const question = mintDelivery(db, {
|
|
364
|
+
kind: 'interview',
|
|
365
|
+
targetId: 'evt-1',
|
|
366
|
+
routeId: 'r-peter',
|
|
367
|
+
now: NOW,
|
|
368
|
+
ttlMs: TTL_MS,
|
|
369
|
+
});
|
|
370
|
+
const alert = mintFor('r-peter');
|
|
371
|
+
|
|
372
|
+
expect(interpretInbound(db, context(PETER, `${question.token} resolve`))).toMatchObject({
|
|
373
|
+
action: 'answer',
|
|
374
|
+
value: 'resolve',
|
|
375
|
+
});
|
|
376
|
+
expect(interpretInbound(db, context(PETER, `${alert.token} resolve`))).toEqual({
|
|
377
|
+
action: 'rejected',
|
|
378
|
+
reason: 'unrecognised',
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
171
382
|
});
|
|
172
383
|
|
|
173
384
|
/**
|