@msg91comm/m91 0.0.0-stage → 1.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +425 -2
  4. package/dist/src/client.d.ts +165 -0
  5. package/dist/src/client.d.ts.map +1 -0
  6. package/dist/src/client.js +221 -0
  7. package/dist/src/client.js.map +1 -0
  8. package/dist/src/exceptions.d.ts +114 -0
  9. package/dist/src/exceptions.d.ts.map +1 -0
  10. package/dist/src/exceptions.js +199 -0
  11. package/dist/src/exceptions.js.map +1 -0
  12. package/dist/src/http.d.ts +71 -0
  13. package/dist/src/http.d.ts.map +1 -0
  14. package/dist/src/http.js +150 -0
  15. package/dist/src/http.js.map +1 -0
  16. package/dist/src/index.d.ts +49 -0
  17. package/dist/src/index.d.ts.map +1 -0
  18. package/dist/src/index.js +45 -0
  19. package/dist/src/index.js.map +1 -0
  20. package/dist/src/integrations/index.d.ts +22 -0
  21. package/dist/src/integrations/index.d.ts.map +1 -0
  22. package/dist/src/integrations/index.js +21 -0
  23. package/dist/src/integrations/index.js.map +1 -0
  24. package/dist/src/integrations/langchain.d.ts +43 -0
  25. package/dist/src/integrations/langchain.d.ts.map +1 -0
  26. package/dist/src/integrations/langchain.js +75 -0
  27. package/dist/src/integrations/langchain.js.map +1 -0
  28. package/dist/src/integrations/langgraph.d.ts +79 -0
  29. package/dist/src/integrations/langgraph.d.ts.map +1 -0
  30. package/dist/src/integrations/langgraph.js +114 -0
  31. package/dist/src/integrations/langgraph.js.map +1 -0
  32. package/dist/src/integrations/specs.d.ts +131 -0
  33. package/dist/src/integrations/specs.d.ts.map +1 -0
  34. package/dist/src/integrations/specs.js +156 -0
  35. package/dist/src/integrations/specs.js.map +1 -0
  36. package/dist/src/integrations/vercel-ai.d.ts +36 -0
  37. package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
  38. package/dist/src/integrations/vercel-ai.js +67 -0
  39. package/dist/src/integrations/vercel-ai.js.map +1 -0
  40. package/dist/src/models.d.ts +274 -0
  41. package/dist/src/models.d.ts.map +1 -0
  42. package/dist/src/models.js +383 -0
  43. package/dist/src/models.js.map +1 -0
  44. package/dist/src/platform.d.ts +176 -0
  45. package/dist/src/platform.d.ts.map +1 -0
  46. package/dist/src/platform.js +286 -0
  47. package/dist/src/platform.js.map +1 -0
  48. package/dist/src/url-builder.d.ts +65 -0
  49. package/dist/src/url-builder.d.ts.map +1 -0
  50. package/dist/src/url-builder.js +167 -0
  51. package/dist/src/url-builder.js.map +1 -0
  52. package/examples/approval-gate.ts +35 -0
  53. package/examples/basic-alert.ts +53 -0
  54. package/examples/langchain-agent.ts +65 -0
  55. package/examples/langgraph-hitl.ts +70 -0
  56. package/examples/vercel-ai-agent.ts +34 -0
  57. package/examples/webhook-urls.ts +44 -0
  58. package/package.json +83 -4
  59. package/src/client.ts +324 -0
  60. package/src/exceptions.ts +229 -0
  61. package/src/http.ts +174 -0
  62. package/src/index.ts +91 -0
  63. package/src/integrations/index.ts +23 -0
  64. package/src/integrations/langchain.ts +104 -0
  65. package/src/integrations/langgraph.ts +142 -0
  66. package/src/integrations/specs.ts +224 -0
  67. package/src/integrations/vercel-ai.ts +99 -0
  68. package/src/models.ts +660 -0
  69. package/src/platform.ts +343 -0
  70. package/src/url-builder.ts +225 -0
package/src/models.ts ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Every shape the API returns, plus the local validation that stops a doomed
3
+ * request before it leaves the process.
4
+ *
5
+ * Two rules govern this file:
6
+ *
7
+ * - **Parse defensively.** The server omits a field rather than sending it
8
+ * false or zero — `deduplicated`, `noDevice`, `pending`, `reopened` and
9
+ * `message` are present only when they have something to say.
10
+ * - **Emit exactly.** Every request schema server-side is `.strict()`: an
11
+ * unknown field is a 400, and so is `null` where a string was declared. So
12
+ * `buildAlertPayload` *omits* what the caller left unset.
13
+ */
14
+
15
+ import { M91ValidationError } from './exceptions.js';
16
+
17
+ export type Severity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
18
+ export type Effect = 'SHARED' | 'PERSONAL';
19
+ export type AlertStatus = 'OPEN' | 'RESOLVED';
20
+ export type RecipientState = 'pending' | 'accepted' | 'declined';
21
+
22
+ export const SEVERITIES: readonly Severity[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] as const;
23
+ export const EFFECTS: readonly Effect[] = ['SHARED', 'PERSONAL'] as const;
24
+
25
+ /** Severities that place a voice call after 45s with no answer. */
26
+ export const ESCALATING_SEVERITIES: readonly Severity[] = ['HIGH', 'CRITICAL'] as const;
27
+
28
+ export const TITLE_MIN = 5;
29
+ export const TITLE_MAX = 2000;
30
+ export const DESCRIPTION_MAX = 2000;
31
+ export const CUSTOM_ID_MAX = 200;
32
+ export const LABEL_MAX = 24;
33
+ export const ATTACHMENT_URL_MAX = 2000;
34
+ const CALLBACK_URL_MAX = 2000;
35
+ const MAX_RESPONSE_OPTIONS = 20;
36
+ export const MAX_LINK_RESPONSE_OPTIONS = 6;
37
+ export const CHANNEL_NAME_MAX = 80;
38
+ export const MAX_MEMBERS_PER_CALL = 500;
39
+
40
+ /** Mirrors `validation/common.js` — digits only, country code included, no `+`. */
41
+ const PHONE_RE = /^\d{8,15}$/;
42
+
43
+ /** The only two things an option may ask somebody to supply. */
44
+ export const COLLECTABLE = ['note', 'attachment'] as const;
45
+ export type Collectable = (typeof COLLECTABLE)[number];
46
+
47
+ /**
48
+ * One button on the alert screen.
49
+ *
50
+ * `label` is capped at 24 characters because it has to read as a button at 3am,
51
+ * not for any storage reason. `effect` defaults to SHARED, as it does
52
+ * server-side.
53
+ */
54
+ export interface ResponseOption {
55
+ label: string;
56
+ effect?: Effect;
57
+ /**
58
+ * What answering this option MUST supply. The person cannot choose it until
59
+ * they have.
60
+ */
61
+ required?: Collectable[];
62
+ /** Offered and skippable, shown with "(optional)" on its own field. */
63
+ optional?: Collectable[];
64
+ }
65
+
66
+ /**
67
+ * The SHARED response that claimed the alert — the one field an agent reads.
68
+ *
69
+ * **Only a SHARED response sets this.** A PERSONAL one is an acknowledgement,
70
+ * and reading it as an answer would let "Seen" mean "Approved". Never reduce
71
+ * `responses` yourself to work out who decided.
72
+ *
73
+ * `label` can be null: the server returns null when an alert was claimed
74
+ * without an option being chosen. `by` is an opaque user id — never a phone
75
+ * number, never a name.
76
+ */
77
+ export interface Decision {
78
+ label: string | null;
79
+ effect: string;
80
+ at: string | null;
81
+ by: string | null;
82
+ }
83
+
84
+ /** What a send returns. 201 for a new alert, 200 for a suppressed repeat. */
85
+ export interface AlertResponse {
86
+ id: string;
87
+ channelId: string | null;
88
+ channelName: string | null;
89
+ status: AlertStatus;
90
+ severity: Severity;
91
+ customId: string | null;
92
+ isAutoClose: boolean;
93
+ responseOptions: ResponseOption[];
94
+ /**
95
+ * Phones actually sent to — **not** the size of the roster. This reported the
96
+ * roster count until a reinstalled phone with a stale token showed the gap.
97
+ */
98
+ delivered: number;
99
+ createdAt: string | null;
100
+ /** The same customId was already open, or an identical payload arrived within 5s. */
101
+ deduplicated: boolean;
102
+ reopened: boolean;
103
+ /** Invited numbers with no M91 account at all. */
104
+ unregistered: string[];
105
+ /** Accepted and signed in, but with no device registered. */
106
+ noDevice: number;
107
+ /** Invited and never answered. */
108
+ pending: number;
109
+ message: string | null;
110
+ /**
111
+ * The check to assert on, rather than "the request succeeded". A deduplicated
112
+ * send delivers to nobody *because the alert is already open and ringing*.
113
+ */
114
+ actuallyReachedSomeone: boolean;
115
+ raw: Record<string, unknown>;
116
+ }
117
+
118
+ /**
119
+ * A file on an alert or on somebody's answer.
120
+ *
121
+ * `url` is a plain, permanent link — the backend stores responders' files and
122
+ * serves them unsigned, so it can be cached, logged and opened long after the
123
+ * alert is closed. A sender's own attachments are the URLs it supplied,
124
+ * unchanged and never copied.
125
+ */
126
+ export interface Attachment {
127
+ url: string;
128
+ name: string | null;
129
+ type: string | null;
130
+ size: number | null;
131
+ }
132
+
133
+ /**
134
+ * One person's answer, with whatever they supplied alongside it.
135
+ *
136
+ * TYPED, because `note` and `attachments` were unreachable otherwise. A sender
137
+ * can require a note or a file before an option may be chosen, and the server
138
+ * returns both here — but `responses` was `unknown[]`, so the only way to read
139
+ * the thing you had demanded was to cast `raw`. A field a caller is told to
140
+ * require and then cannot read is not a feature.
141
+ *
142
+ * `by` is an opaque user id — never a phone number, never a name.
143
+ */
144
+ export interface Response {
145
+ label: string | null;
146
+ effect: string;
147
+ optionIndex: number | null;
148
+ at: string | null;
149
+ by: string | null;
150
+ /** What they typed, when the option asked for it. */
151
+ note: string | null;
152
+ /** What they attached. Empty when none was asked for, or none was given. */
153
+ attachments: Attachment[];
154
+ }
155
+
156
+ /** What a read-back returns. Poll `decision`, never `responses[0]`. */
157
+ export interface AlertDetail {
158
+ id: string;
159
+ customId: string | null;
160
+ status: AlertStatus;
161
+ severity: Severity;
162
+ title: string | null;
163
+ isAutoClose: boolean;
164
+ reopenCount: number;
165
+ responseOptions: ResponseOption[];
166
+ /** Every response, personal and shared. Not how you decide anything. */
167
+ responses: Response[];
168
+ /** The sender's own attachments, exactly as supplied. Links, never copies. */
169
+ attachments: Attachment[];
170
+ /** Recipients who have not answered yet — reached minus answered. */
171
+ pending: number;
172
+ decision: Decision | null;
173
+ resolvedAt: string | null;
174
+ createdAt: string | null;
175
+ isDecided: boolean;
176
+ isClosed: boolean;
177
+ raw: Record<string, unknown>;
178
+ }
179
+
180
+ /**
181
+ * What a bare GET on a send link answers with. The channel *name* is the point:
182
+ * it lets whoever pasted the URL see they picked the right channel.
183
+ */
184
+ export interface ChannelInfo {
185
+ id: string;
186
+ name: string;
187
+ visibility: string;
188
+ /** Which send link fired, by label. A channel can hold several, one per system. */
189
+ senderLabel: string | null;
190
+ senderId: string | null;
191
+ }
192
+
193
+ /**
194
+ * What an API key is, without doing anything with it.
195
+ *
196
+ * The answer to the question somebody actually has on a connection screen — not
197
+ * "is this valid" but **which** key did I just paste and whose authority does it
198
+ * carry. Enough to render "Connected as +91 98765 43210 · Production" rather
199
+ * than a tick.
200
+ *
201
+ * `prefix` is the first characters of the key, which is what the app shows so a
202
+ * person can tell two keys apart. The key itself is never returned by anything.
203
+ */
204
+ export interface ApiKeyInfo {
205
+ keyId: string;
206
+ prefix: string | null;
207
+ label: string | null;
208
+ createdAt: string | null;
209
+ /**
210
+ * The owner's phone. No user id on purpose: it is an internal handle, and a
211
+ * platform storing it would be storing something it can never use.
212
+ */
213
+ ownerPhone: string | null;
214
+ }
215
+
216
+ export interface Recipient {
217
+ phone: string;
218
+ state: RecipientState;
219
+ /** Only an accepted recipient can be woken through Do Not Disturb. */
220
+ reachable: boolean;
221
+ }
222
+
223
+ /**
224
+ * A team channel created by API key. `sendLink` is how it is *alerted* — an API
225
+ * key never can be. Store it the moment you get it: creating a channel is not
226
+ * idempotent, so a lost response is an orphaned channel, not something to retry.
227
+ */
228
+ export interface ProvisionedChannel {
229
+ id: string;
230
+ name: string;
231
+ sendLink: string | null;
232
+ }
233
+
234
+ /** The four buckets an invite call sorts phones into. */
235
+ export interface InviteResult {
236
+ invited: string[];
237
+ already: string[];
238
+ pending: string[];
239
+ declined: string[];
240
+ raw: Record<string, unknown>;
241
+ }
242
+
243
+ // ── parsing ───────────────────────────────────────────────────────────────────
244
+
245
+ type Raw = Record<string, unknown>;
246
+
247
+ const asRaw = (value: unknown): Raw => (value && typeof value === 'object' ? (value as Raw) : {});
248
+ const str = (value: unknown): string | null => (value === null || value === undefined ? null : String(value));
249
+ const num = (value: unknown): number => (Number.isFinite(Number(value)) ? Number(value) : 0);
250
+ const arr = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
251
+
252
+ function parseOptions(value: unknown): ResponseOption[] {
253
+ return arr(value).map((o) => {
254
+ const raw = asRaw(o);
255
+ const option: ResponseOption = {
256
+ label: String(raw.label ?? ''),
257
+ effect: (raw.effect as Effect) ?? 'SHARED',
258
+ };
259
+ // Absent means "asks for nothing", and the server omits the key then — so
260
+ // these are only set when they are real, matching what goes out.
261
+ const required = arr(raw.required).map(String) as Collectable[];
262
+ const optional = arr(raw.optional).map(String) as Collectable[];
263
+ if (required.length) option.required = required;
264
+ if (optional.length) option.optional = optional;
265
+ return option;
266
+ });
267
+ }
268
+
269
+ export function parseDecision(value: unknown): Decision | null {
270
+ if (!value || typeof value !== 'object') return null;
271
+ const raw = asRaw(value);
272
+ return {
273
+ label: str(raw.label),
274
+ effect: String(raw.effect ?? 'SHARED'),
275
+ at: str(raw.at),
276
+ by: str(raw.by),
277
+ };
278
+ }
279
+
280
+ export function parseAlertResponse(value: unknown): AlertResponse {
281
+ const raw = asRaw(value);
282
+ const delivered = num(raw.delivered);
283
+ const deduplicated = raw.deduplicated === true;
284
+ return {
285
+ id: String(raw._id ?? raw.id ?? ''),
286
+ channelId: str(raw.channelId),
287
+ channelName: str(raw.channelName),
288
+ status: (raw.status as AlertStatus) ?? 'OPEN',
289
+ severity: (raw.severity as Severity) ?? 'MEDIUM',
290
+ customId: str(raw.customId),
291
+ isAutoClose: raw.isAutoClose === true,
292
+ responseOptions: parseOptions(raw.responseOptions),
293
+ delivered,
294
+ createdAt: str(raw.createdAt),
295
+ deduplicated,
296
+ reopened: raw.reopened === true,
297
+ unregistered: arr(raw.unregistered).map(String),
298
+ noDevice: num(raw.noDevice),
299
+ pending: num(raw.pending),
300
+ message: str(raw.message),
301
+ actuallyReachedSomeone: deduplicated || delivered > 0,
302
+ raw,
303
+ };
304
+ }
305
+
306
+ /** A sender's attachments arrive as bare strings; a responder's as objects. */
307
+ export function parseAttachment(value: unknown): Attachment {
308
+ if (typeof value === 'string') return { url: value, name: null, type: null, size: null };
309
+ const raw = asRaw(value);
310
+ return {
311
+ url: String(raw.url ?? ''),
312
+ name: str(raw.name),
313
+ type: str(raw.type),
314
+ size: raw.size === undefined || raw.size === null ? null : num(raw.size),
315
+ };
316
+ }
317
+
318
+ export function parseResponse(value: unknown): Response {
319
+ const raw = asRaw(value);
320
+ return {
321
+ label: str(raw.label),
322
+ effect: String(raw.effect ?? 'PERSONAL'),
323
+ optionIndex: raw.optionIndex === undefined || raw.optionIndex === null ? null : num(raw.optionIndex),
324
+ at: str(raw.at),
325
+ by: str(raw.by),
326
+ note: str(raw.note),
327
+ attachments: arr(raw.attachments).map(parseAttachment),
328
+ };
329
+ }
330
+
331
+ export function parseAlertDetail(value: unknown): AlertDetail {
332
+ const raw = asRaw(value);
333
+ const decision = parseDecision(raw.decision);
334
+ const status = (raw.status as AlertStatus) ?? 'OPEN';
335
+ return {
336
+ id: String(raw._id ?? raw.id ?? ''),
337
+ customId: str(raw.customId),
338
+ status,
339
+ severity: (raw.severity as Severity) ?? 'MEDIUM',
340
+ title: str(raw.title),
341
+ isAutoClose: raw.isAutoClose === true,
342
+ reopenCount: num(raw.reopenCount),
343
+ responseOptions: parseOptions(raw.responseOptions),
344
+ responses: arr(raw.responses).map(parseResponse),
345
+ attachments: arr(raw.attachments).map(parseAttachment),
346
+ pending: num(raw.pending),
347
+ decision,
348
+ resolvedAt: str(raw.resolvedAt),
349
+ createdAt: str(raw.createdAt),
350
+ isDecided: decision !== null,
351
+ isClosed: status === 'RESOLVED',
352
+ raw,
353
+ };
354
+ }
355
+
356
+ export function parseChannelInfo(value: unknown): ChannelInfo {
357
+ const raw = asRaw(value);
358
+ const channel = asRaw(raw.channel);
359
+ const sender = asRaw(raw.sender);
360
+ return {
361
+ id: String(channel.id ?? ''),
362
+ name: String(channel.name ?? ''),
363
+ visibility: String(channel.visibility ?? 'PRIVATE'),
364
+ senderLabel: str(sender.label),
365
+ senderId: str(sender.id),
366
+ };
367
+ }
368
+
369
+ export function parseApiKeyInfo(value: unknown): ApiKeyInfo {
370
+ const raw = asRaw(value);
371
+ const key = asRaw(raw.key);
372
+ const owner = asRaw(raw.owner);
373
+ return {
374
+ keyId: String(key._id ?? key.id ?? ''),
375
+ prefix: str(key.prefix),
376
+ label: str(key.label),
377
+ createdAt: str(key.createdAt),
378
+ ownerPhone: str(owner.phone),
379
+ };
380
+ }
381
+
382
+ export function parseRecipient(value: unknown): Recipient {
383
+ const raw = asRaw(value);
384
+ const state = (raw.state as RecipientState) ?? 'pending';
385
+ return {
386
+ phone: String(raw.phone ?? ''),
387
+ state,
388
+ reachable: raw.reachable === undefined ? state === 'accepted' : raw.reachable === true,
389
+ };
390
+ }
391
+
392
+ export function parseProvisionedChannel(value: unknown): ProvisionedChannel {
393
+ const raw = asRaw(value);
394
+ return {
395
+ id: String(raw._id ?? raw.id ?? ''),
396
+ name: String(raw.name ?? ''),
397
+ sendLink: str(raw.sendLink),
398
+ };
399
+ }
400
+
401
+ export function parseInviteResult(value: unknown): InviteResult {
402
+ const raw = asRaw(value);
403
+ return {
404
+ invited: arr(raw.invited).map(String),
405
+ already: arr(raw.already).map(String),
406
+ pending: arr(raw.pending).map(String),
407
+ declined: arr(raw.declined).map(String),
408
+ raw,
409
+ };
410
+ }
411
+
412
+ /** True when this decision is the approval, compared case-insensitively.
413
+ *
414
+ * A missing label is not an approval. The server can hand back `label: null`,
415
+ * and treating an unknown answer as a yes is the wrong way to be wrong about a
416
+ * payment. */
417
+ export function isApproved(decision: Decision | null, approveLabel = 'Approve'): boolean {
418
+ if (!decision?.label) return false;
419
+ return collapse(decision.label).toLowerCase() === collapse(approveLabel).toLowerCase();
420
+ }
421
+
422
+ // ── outbound ──────────────────────────────────────────────────────────────────
423
+
424
+ /**
425
+ * Trim and collapse runs of whitespace, as `validateRequest` does server-side.
426
+ * Done here too so a length check measures the string the server will measure.
427
+ */
428
+ export function collapse(value: string): string {
429
+ return String(value).replace(/\s+/g, ' ').trim();
430
+ }
431
+
432
+ export interface AlertFields {
433
+ description?: string;
434
+ severity?: Severity;
435
+ customId?: string;
436
+ isAutoClose?: boolean;
437
+ responseOptions?: Array<ResponseOption | string>;
438
+ /**
439
+ * Whether the free-text "Something else" reply is offered.
440
+ *
441
+ * Defaults ON at the server, so this is tri-state: leave it undefined and
442
+ * the server's default stands; only an explicit `false` turns it off.
443
+ */
444
+ other?: boolean;
445
+ /** https links to files the alert is about. URLs, never bytes — see below. */
446
+ attachments?: string[];
447
+ /** Where the outcome is POSTed when somebody answers. */
448
+ callbackUrl?: string;
449
+ }
450
+
451
+ /**
452
+ * Validate an alert locally and render the JSON body the server accepts.
453
+ *
454
+ * Every limit checked here is one the server enforces. Checking twice is not
455
+ * duplication for its own sake: a 400 costs a round trip, and the caller is
456
+ * often a cron or an agent that will simply do it again.
457
+ */
458
+ export function buildAlertPayload(
459
+ title: string,
460
+ fields: AlertFields = {},
461
+ maxOptions = MAX_RESPONSE_OPTIONS,
462
+ ): Record<string, unknown> {
463
+ const cleanTitle = collapse(title ?? '');
464
+ if (!cleanTitle) throw new M91ValidationError('title is required');
465
+ if (cleanTitle.length < TITLE_MIN) {
466
+ throw new M91ValidationError(
467
+ `title must be at least ${TITLE_MIN} characters, got ${cleanTitle.length}`,
468
+ );
469
+ }
470
+ if (cleanTitle.length > TITLE_MAX) {
471
+ throw new M91ValidationError(
472
+ `title must be ${TITLE_MAX} characters or fewer, got ${cleanTitle.length}`,
473
+ );
474
+ }
475
+
476
+ const severity = checkSeverity(fields.severity ?? 'MEDIUM');
477
+ const payload: Record<string, unknown> = { title: cleanTitle, severity };
478
+
479
+ if (fields.other !== undefined && fields.other !== null) {
480
+ payload.other = Boolean(fields.other);
481
+ }
482
+
483
+ /*
484
+ * URLs, never bytes.
485
+ *
486
+ * The server takes links and not multipart, because every automation a send
487
+ * link is pasted into — viasocket, Zapier, Make, a spreadsheet — has a URL
488
+ * field and little else. Checked here so a typo costs nothing: a cron that
489
+ * gets a 400 will simply send it again.
490
+ */
491
+ if (fields.attachments?.length) {
492
+ const urls: string[] = [];
493
+ for (const item of fields.attachments) {
494
+ const url = collapse(String(item ?? ''));
495
+ if (!url) continue;
496
+ if (url.length > ATTACHMENT_URL_MAX) {
497
+ throw new M91ValidationError(
498
+ `attachment URL must be ${ATTACHMENT_URL_MAX} characters or fewer, got ${url.length}`,
499
+ );
500
+ }
501
+ if (!/^https:\/\//i.test(url)) {
502
+ throw new M91ValidationError(
503
+ `attachment must be an https link, got ${JSON.stringify(url)}`,
504
+ );
505
+ }
506
+ urls.push(url);
507
+ }
508
+ if (urls.length) payload.attachments = urls;
509
+ }
510
+
511
+ if (fields.callbackUrl !== undefined && fields.callbackUrl !== null) {
512
+ const callbackUrl = collapse(fields.callbackUrl);
513
+ if (callbackUrl.length > CALLBACK_URL_MAX) {
514
+ throw new M91ValidationError(
515
+ `callbackUrl must be ${CALLBACK_URL_MAX} characters or fewer, got ${callbackUrl.length}`,
516
+ );
517
+ }
518
+ if (callbackUrl) payload.callbackUrl = callbackUrl;
519
+ }
520
+
521
+ if (fields.description !== undefined && fields.description !== null) {
522
+ const description = collapse(fields.description);
523
+ if (description.length > DESCRIPTION_MAX) {
524
+ throw new M91ValidationError(
525
+ `description must be ${DESCRIPTION_MAX} characters or fewer, got ${description.length}`,
526
+ );
527
+ }
528
+ payload.description = description;
529
+ }
530
+
531
+ if (fields.customId !== undefined && fields.customId !== null) {
532
+ const customId = collapse(String(fields.customId));
533
+ if (!customId) throw new M91ValidationError('customId cannot be empty — omit it instead');
534
+ if (customId.length > CUSTOM_ID_MAX) {
535
+ throw new M91ValidationError(
536
+ `customId must be ${CUSTOM_ID_MAX} characters or fewer, got ${customId.length}`,
537
+ );
538
+ }
539
+ payload.customId = customId;
540
+ }
541
+
542
+ // Always sent, never omitted. The default is false on both sides, but an
543
+ // explicit false is what lets a code review of the call site tell "stays open
544
+ // until somebody closes it" from "nobody thought about it".
545
+ payload.isAutoClose = fields.isAutoClose === true;
546
+
547
+ if (fields.responseOptions !== undefined) {
548
+ payload.responseOptions = checkOptions(normalizeOptions(fields.responseOptions), maxOptions);
549
+ }
550
+
551
+ return payload;
552
+ }
553
+
554
+ export function normalizeOptions(options: Array<ResponseOption | string>): ResponseOption[] {
555
+ return options.map((option) => {
556
+ const raw = typeof option === 'string' ? { label: option } : option;
557
+ const label = collapse(raw?.label ?? '');
558
+ if (!label) throw new M91ValidationError('response option label cannot be empty');
559
+ if (label.length > LABEL_MAX) {
560
+ throw new M91ValidationError(
561
+ `response option label must be ${LABEL_MAX} characters or fewer, got ${label.length}: '${label}'`,
562
+ );
563
+ }
564
+ const effect = (raw as ResponseOption).effect ?? 'SHARED';
565
+ if (!EFFECTS.includes(effect)) {
566
+ throw new M91ValidationError(`effect must be SHARED or PERSONAL, got '${effect}'`);
567
+ }
568
+
569
+ const built: ResponseOption = { label, effect };
570
+ const required = collectFields((raw as ResponseOption).required, 'required');
571
+ const optional = collectFields((raw as ResponseOption).optional, 'optional');
572
+ const both = required.filter((f) => optional.includes(f));
573
+ if (both.length) {
574
+ // The server takes the stricter of the two, so this is a statement the
575
+ // sender did not mean. Saying so beats silently picking one.
576
+ throw new M91ValidationError(
577
+ `${both.join(', ')} cannot be both required and optional`,
578
+ );
579
+ }
580
+
581
+ /*
582
+ * Only what was set. A field in NEITHER list is not rendered at all, which
583
+ * is what stops an option configured for a photo putting an unwanted note
584
+ * box in front of somebody at 3am.
585
+ */
586
+ if (required.length) built.required = required;
587
+ if (optional.length) built.optional = optional;
588
+ return built;
589
+ });
590
+ }
591
+
592
+ /**
593
+ * Normalise and check a `required` / `optional` list.
594
+ *
595
+ * Checked here because the server's enum error — "must be note or
596
+ * attachment" — arrives after a round trip, and the caller is usually a cron
597
+ * or an agent that will just send it again.
598
+ */
599
+ function collectFields(value: unknown, which: string): Collectable[] {
600
+ if (!value) return [];
601
+ const items = Array.isArray(value) ? value : [value];
602
+ const out: Collectable[] = [];
603
+ for (const item of items) {
604
+ const name = collapse(String(item ?? '')).toLowerCase() as Collectable;
605
+ if (!COLLECTABLE.includes(name)) {
606
+ throw new M91ValidationError(
607
+ `${which} must contain only ${COLLECTABLE.join(' or ')}, got ${JSON.stringify(item)}`,
608
+ );
609
+ }
610
+ if (!out.includes(name)) out.push(name);
611
+ }
612
+ return out;
613
+ }
614
+
615
+ function checkOptions(options: ResponseOption[], maxOptions: number): ResponseOption[] {
616
+ if (options.length > maxOptions) {
617
+ throw new M91ValidationError(
618
+ `cannot offer more than ${maxOptions} response options, got ${options.length}`,
619
+ );
620
+ }
621
+ const seen = new Set<string>();
622
+ for (const option of options) {
623
+ const key = option.label.toLowerCase();
624
+ // The server rejects this too. Two buttons reading the same thing with
625
+ // different effects is an alert nobody can answer correctly at 3am.
626
+ if (seen.has(key)) {
627
+ throw new M91ValidationError(`cannot repeat the same response label twice: '${option.label}'`);
628
+ }
629
+ seen.add(key);
630
+ }
631
+ return options;
632
+ }
633
+
634
+ export function checkSeverity(severity: string): Severity {
635
+ const value = String(severity).toUpperCase() as Severity;
636
+ if (!SEVERITIES.includes(value)) {
637
+ throw new M91ValidationError(`severity must be one of: ${SEVERITIES.join(', ')}, got '${severity}'`);
638
+ }
639
+ return value;
640
+ }
641
+
642
+ /**
643
+ * Digits only, 8–15, country code included, no `+` and no spaces.
644
+ * The `+` is the one everybody gets wrong, so it gets its own message.
645
+ */
646
+ export function validatePhone(phone: string): string {
647
+ const raw = String(phone ?? '').trim();
648
+ if (raw.startsWith('+')) {
649
+ throw new M91ValidationError(
650
+ `phone must not start with '+': send digits only, e.g. '${raw.slice(1)}' not '${raw}'`,
651
+ );
652
+ }
653
+ if (!PHONE_RE.test(raw)) {
654
+ throw new M91ValidationError(
655
+ `phone must be 8-15 digits including the country code, with no '+' or spaces ` +
656
+ `(e.g. 919876543210), got '${phone}'`,
657
+ );
658
+ }
659
+ return raw;
660
+ }