@msg91comm/m91 0.0.0-stage → 1.2.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/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +647 -2
- package/dist/src/client.d.ts +165 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +221 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/exceptions.d.ts +114 -0
- package/dist/src/exceptions.d.ts.map +1 -0
- package/dist/src/exceptions.js +199 -0
- package/dist/src/exceptions.js.map +1 -0
- package/dist/src/http.d.ts +71 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +150 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +49 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/claude-agent-sdk.d.ts +63 -0
- package/dist/src/integrations/claude-agent-sdk.d.ts.map +1 -0
- package/dist/src/integrations/claude-agent-sdk.js +109 -0
- package/dist/src/integrations/claude-agent-sdk.js.map +1 -0
- package/dist/src/integrations/google-adk.d.ts +47 -0
- package/dist/src/integrations/google-adk.d.ts.map +1 -0
- package/dist/src/integrations/google-adk.js +84 -0
- package/dist/src/integrations/google-adk.js.map +1 -0
- package/dist/src/integrations/index.d.ts +26 -0
- package/dist/src/integrations/index.d.ts.map +1 -0
- package/dist/src/integrations/index.js +25 -0
- package/dist/src/integrations/index.js.map +1 -0
- package/dist/src/integrations/langchain.d.ts +94 -0
- package/dist/src/integrations/langchain.d.ts.map +1 -0
- package/dist/src/integrations/langchain.js +129 -0
- package/dist/src/integrations/langchain.js.map +1 -0
- package/dist/src/integrations/langgraph.d.ts +79 -0
- package/dist/src/integrations/langgraph.d.ts.map +1 -0
- package/dist/src/integrations/langgraph.js +114 -0
- package/dist/src/integrations/langgraph.js.map +1 -0
- package/dist/src/integrations/mastra.d.ts +75 -0
- package/dist/src/integrations/mastra.d.ts.map +1 -0
- package/dist/src/integrations/mastra.js +109 -0
- package/dist/src/integrations/mastra.js.map +1 -0
- package/dist/src/integrations/openai-agents.d.ts +61 -0
- package/dist/src/integrations/openai-agents.d.ts.map +1 -0
- package/dist/src/integrations/openai-agents.js +127 -0
- package/dist/src/integrations/openai-agents.js.map +1 -0
- package/dist/src/integrations/specs.d.ts +173 -0
- package/dist/src/integrations/specs.d.ts.map +1 -0
- package/dist/src/integrations/specs.js +217 -0
- package/dist/src/integrations/specs.js.map +1 -0
- package/dist/src/integrations/vercel-ai.d.ts +74 -0
- package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
- package/dist/src/integrations/vercel-ai.js +102 -0
- package/dist/src/integrations/vercel-ai.js.map +1 -0
- package/dist/src/integrations/zod-shapes.d.ts +19 -0
- package/dist/src/integrations/zod-shapes.d.ts.map +1 -0
- package/dist/src/integrations/zod-shapes.js +44 -0
- package/dist/src/integrations/zod-shapes.js.map +1 -0
- package/dist/src/models.d.ts +274 -0
- package/dist/src/models.d.ts.map +1 -0
- package/dist/src/models.js +383 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/platform.d.ts +176 -0
- package/dist/src/platform.d.ts.map +1 -0
- package/dist/src/platform.js +286 -0
- package/dist/src/platform.js.map +1 -0
- package/dist/src/url-builder.d.ts +65 -0
- package/dist/src/url-builder.d.ts.map +1 -0
- package/dist/src/url-builder.js +167 -0
- package/dist/src/url-builder.js.map +1 -0
- package/examples/approval-gate.ts +35 -0
- package/examples/basic-alert.ts +53 -0
- package/examples/claude-agent-sdk.ts +23 -0
- package/examples/deep-agents.ts +31 -0
- package/examples/google-adk.ts +39 -0
- package/examples/langchain-agent.ts +65 -0
- package/examples/langgraph-hitl.ts +70 -0
- package/examples/mastra.ts +41 -0
- package/examples/openai-agents.ts +33 -0
- package/examples/vercel-ai-agent.ts +34 -0
- package/examples/webhook-urls.ts +44 -0
- package/package.json +135 -4
- package/src/client.ts +324 -0
- package/src/exceptions.ts +229 -0
- package/src/http.ts +174 -0
- package/src/index.ts +91 -0
- package/src/integrations/claude-agent-sdk.ts +158 -0
- package/src/integrations/google-adk.ts +122 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/langchain.ts +207 -0
- package/src/integrations/langgraph.ts +142 -0
- package/src/integrations/mastra.ts +178 -0
- package/src/integrations/openai-agents.ts +173 -0
- package/src/integrations/specs.ts +328 -0
- package/src/integrations/vercel-ai.ts +151 -0
- package/src/integrations/zod-shapes.ts +55 -0
- package/src/models.ts +660 -0
- package/src/platform.ts +343 -0
- 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
|
+
}
|