@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
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The API-key client: alerting **one person, by phone number**.
3
+ *
4
+ * The narrowness is the point, and worth understanding before reaching for
5
+ * this. A team is already solved — somebody creates the channel in the app and
6
+ * the integration posts to its send link. What a link cannot do is address a
7
+ * person a platform is discovering at runtime: a CRM signing up its
8
+ * five-thousandth salesman cannot mint and store five thousand bearer
9
+ * credentials, each able to wake somebody through Do Not Disturb, sitting in a
10
+ * user table with no rotation. So for one person, the secret is one header and
11
+ * the address is a phone number.
12
+ *
13
+ * **An API key cannot alert a team channel.** It can *provision* one — create
14
+ * it, staff it, read its send link — because that is the same authority its
15
+ * owner has in the app. Raising the alert is still the send link's job. A
16
+ * leaked key can rename or restaff a channel; it cannot ring a single phone in
17
+ * it, and that split is the entire reason the two capabilities sit on two
18
+ * credentials.
19
+ */
20
+
21
+ import { M91ForbiddenError, M91NotFoundError, M91ValidationError } from './exceptions.js';
22
+ import { Transport, type TransportOptions } from './http.js';
23
+ import {
24
+ type AlertDetail,
25
+ type AlertResponse,
26
+ type ApiKeyInfo,
27
+ CHANNEL_NAME_MAX,
28
+ type InviteResult,
29
+ MAX_MEMBERS_PER_CALL,
30
+ type ProvisionedChannel,
31
+ type Recipient,
32
+ buildAlertPayload,
33
+ collapse,
34
+ parseAlertDetail,
35
+ parseAlertResponse,
36
+ parseApiKeyInfo,
37
+ parseInviteResult,
38
+ parseProvisionedChannel,
39
+ parseRecipient,
40
+ validatePhone,
41
+ } from './models.js';
42
+ import { type SendOptions, checkDelivery } from './client.js';
43
+
44
+ export const DEFAULT_BASE_URL = 'https://m91.msg91.com';
45
+ export const API_KEY_PREFIX = 'm91sk_';
46
+
47
+ export interface PlatformOptions extends TransportOptions {
48
+ baseUrl?: string;
49
+ /**
50
+ * Throw `M91DeliveryError` when an alert reaches nobody. On by default, for
51
+ * the same reason as on `M91Client` — and it bites harder here. A direct
52
+ * alert has a roster of exactly one, so one stale device token is the
53
+ * difference between waking somebody and waking nobody, and the API still
54
+ * answers 201.
55
+ */
56
+ throwOnZeroDelivery?: boolean;
57
+ }
58
+
59
+ /** Invite people, alert them one at a time, and provision team channels. */
60
+ export class M91Platform {
61
+ readonly baseUrl: string;
62
+ readonly throwOnZeroDelivery: boolean;
63
+ private readonly apiKey: string;
64
+ private readonly http: Transport;
65
+
66
+ constructor(apiKey: string, options: PlatformOptions = {}) {
67
+ this.apiKey = checkApiKey(apiKey);
68
+ this.throwOnZeroDelivery = options.throwOnZeroDelivery !== false;
69
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
70
+ this.http = new Transport({
71
+ ...options,
72
+ headers: { authorization: `Bearer ${this.apiKey}`, ...options.headers },
73
+ });
74
+ }
75
+
76
+ /** The key is a live credential that can wake people. Only its prefix. */
77
+ toString(): string {
78
+ return `M91Platform(${this.apiKey.slice(0, 10)}…, ${this.baseUrl})`;
79
+ }
80
+
81
+ private url(path: string): string {
82
+ return `${this.baseUrl}/api/v1${path}`;
83
+ }
84
+
85
+ /**
86
+ * Check the key works, and see which key it is. **Alerts nobody.**
87
+ *
88
+ * The only route on this surface that is safe to call just to find out
89
+ * whether a credential works — everything else writes something or needs an
90
+ * id that already exists. On an alerting product, "test the connection" must
91
+ * never mean "wake somebody", so do not verify a key by sending an alert.
92
+ *
93
+ * A revoked key never reaches this route, so a success means *live*, not
94
+ * merely well-formed.
95
+ *
96
+ * Note what it does **not** prove: a perfectly good key with nobody accepted
97
+ * behind it still alerts nobody. Invite a real number and check the state
98
+ * before you believe the integration works.
99
+ */
100
+ async verify(): Promise<ApiKeyInfo> {
101
+ return parseApiKeyInfo(await this.http.request({ method: 'GET', url: this.url('/me') }));
102
+ }
103
+
104
+ /**
105
+ * Invite somebody to accept direct alerts. **Idempotent.**
106
+ *
107
+ * Keyed on the phone number, so calling it on every login is safe and is the
108
+ * intended usage — retries cannot produce a second channel.
109
+ *
110
+ * The returned `state` needs different handling in each case, and the
111
+ * important one is that only `accepted` can actually be woken. A `pending`
112
+ * recipient has been asked and has not answered: nag them in your own UI, do
113
+ * not re-invite them in a loop. `declined` is terminal — no API re-invites a
114
+ * declined number, and only they can undo it from their own app.
115
+ *
116
+ * Inviting is deliberately separate from alerting: **an alert must never be
117
+ * what invites somebody**, because an invited person is not an M91 user yet
118
+ * and the urgent thing would arrive as an ordinary message that does not
119
+ * pierce Do Not Disturb, does not repeat and does not escalate.
120
+ *
121
+ * `label` is **the name the person sees on the alert**, not a name for the
122
+ * person. It is the only thing on their screen saying where the alert came
123
+ * from, so it should be your product — "Acme CRM", not "Priya" and not an id.
124
+ * Woken at 3am, "Acme CRM" answers the question they have and their own name
125
+ * does not.
126
+ *
127
+ * Two things follow, and both bite: it defaults to `"Direct alerts"` when
128
+ * omitted, and **every invite overwrites it**. Since this call belongs in
129
+ * your login handler, omitting the label on the second login resets a good
130
+ * name back to the default. Pass the same constant every time.
131
+ */
132
+ async inviteRecipient(phone: string, label?: string): Promise<Recipient> {
133
+ const body: Record<string, unknown> = { phone: validatePhone(phone) };
134
+ if (label !== undefined && label !== null && collapse(label)) body.label = collapse(label);
135
+ return parseRecipient(await this.http.request({ method: 'POST', url: this.url('/recipients'), body }));
136
+ }
137
+
138
+ /**
139
+ * Stop alerting somebody. `true` when they are no longer a recipient.
140
+ *
141
+ * Worth wiring to whatever your system calls offboarding. Nothing in an
142
+ * integration is as routinely forgotten, and forgetting it means somebody who
143
+ * left the company is still woken at 3am.
144
+ */
145
+ async removeRecipient(phone: string): Promise<boolean> {
146
+ return this.tolerateMissing({
147
+ method: 'DELETE',
148
+ url: this.url(`/recipients/${encodeURIComponent(validatePhone(phone))}`),
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Wake one person. They must have been invited **and have accepted**.
154
+ *
155
+ * Alerting a number nobody invited throws `M91NotFoundError`, never a silent
156
+ * invitation.
157
+ *
158
+ * A direct alert *is* a channel with one member, so responses, escalation,
159
+ * deduplication and the consent gate behave exactly as they do for a team —
160
+ * there is no second implementation to keep in step.
161
+ */
162
+ async alertPerson(phone: string, title: string, options: SendOptions = {}): Promise<AlertResponse> {
163
+ const body = buildAlertPayload(title, options);
164
+ body.phone = validatePhone(phone);
165
+ const alert = parseAlertResponse(
166
+ await this.http.request({ method: 'POST', url: this.url('/alerts'), body }),
167
+ );
168
+ // The same guard the send link gets. A person who reinstalled their phone
169
+ // still has an account and still shows as an accepted recipient, so this
170
+ // path answers 201 with delivered: 0 and looks exactly like success — and
171
+ // with one recipient there is nobody else it reached.
172
+ checkDelivery(alert, this.throwOnZeroDelivery);
173
+ return alert;
174
+ }
175
+
176
+ /**
177
+ * Read back what one person decided. `null` if there is no such alert.
178
+ * `alertId` is the `customId` you invented or the `_id` a send returned.
179
+ */
180
+ async getAlert(alertId: string, phone: string): Promise<AlertDetail | null> {
181
+ try {
182
+ const raw = await this.http.request({
183
+ method: 'GET',
184
+ url: this.url(`/alerts/${encodeURIComponent(String(alertId))}`),
185
+ query: { phone: validatePhone(phone) },
186
+ });
187
+ return parseAlertDetail(raw);
188
+ } catch (error) {
189
+ if (error instanceof M91NotFoundError) return null;
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Close one person's alert.
196
+ *
197
+ * **404 and 403 are success**, as on the send link: "no such alert" and
198
+ * "already resolved" both mean it is not open, which is what was asked for.
199
+ */
200
+ async closeAlert(
201
+ phone: string,
202
+ options: { customId?: string; alertId?: string },
203
+ ): Promise<boolean> {
204
+ const { customId, alertId } = options ?? {};
205
+ if (Boolean(customId) === Boolean(alertId)) {
206
+ throw new M91ValidationError(
207
+ `closeAlert() needs exactly one of customId or alertId — got ${customId ? 'both' : 'neither'}`,
208
+ );
209
+ }
210
+ try {
211
+ await this.http.request({
212
+ method: 'POST',
213
+ url: this.url('/alerts/close'),
214
+ body: { phone: validatePhone(phone), ...(customId ? { customId } : { alertId }) },
215
+ });
216
+ } catch (error) {
217
+ // 403 as well as 404, matching M91Client.close(). Production answers 403
218
+ // `already_resolved` when the alert is closed already, and "it is closed"
219
+ // is the state the caller asked for. Only close gets this: on a REMOVE a
220
+ // 403 means the channel is not yours, which must not be swallowed.
221
+ if (error instanceof M91NotFoundError || error instanceof M91ForbiddenError) return true;
222
+ throw error;
223
+ }
224
+ return true;
225
+ }
226
+
227
+ /**
228
+ * Create a team channel and get back the send link that alerts it.
229
+ *
230
+ * **Not idempotent, and deliberately takes no id of your own to make it so.**
231
+ * Calling this twice makes two channels. Store the returned `id` and
232
+ * `sendLink` against your own team the moment you get them — a lost response
233
+ * is an orphaned channel, not something to retry.
234
+ *
235
+ * `alertOwner: false` skips adding the key's owner as a member: a platform
236
+ * minting one channel per customer team has no reason for its own account to
237
+ * be paged for every one of them. They stay the owner either way.
238
+ */
239
+ /**
240
+ * Every team channel this key can raise an alert on.
241
+ *
242
+ * The missing half of {@link createChannel}, which is **not idempotent**: a
243
+ * platform that lost the response to a create had an orphaned channel and no
244
+ * way to find it. Now it can read back what it already has.
245
+ *
246
+ * **Scoped by send access, not by ownership.** Every row carries a
247
+ * `sendLink`, so the list answers "what can I alert?" — which includes a
248
+ * channel whose owner granted this person send access, not only the ones they
249
+ * created. Creating a channel grants its owner send access too, so owned
250
+ * channels are a subset of this rather than a separate case.
251
+ *
252
+ * Channels they merely joined stay absent: being a member means you get
253
+ * woken, not that you may wake others. Archived channels are left out, since
254
+ * a link that cannot alert anybody is worse than no link.
255
+ */
256
+ async listChannels(): Promise<ProvisionedChannel[]> {
257
+ const rows = await this.http.request({ method: 'GET', url: this.url('/channels') });
258
+ return (Array.isArray(rows) ? rows : []).map(parseProvisionedChannel);
259
+ }
260
+
261
+ async createChannel(name: string, alertOwner = true): Promise<ProvisionedChannel> {
262
+ const clean = collapse(name ?? '');
263
+ if (!clean) throw new M91ValidationError('channel name is required');
264
+ if (clean.length > CHANNEL_NAME_MAX) {
265
+ throw new M91ValidationError(
266
+ `channel name must be ${CHANNEL_NAME_MAX} characters or fewer, got ${clean.length}`,
267
+ );
268
+ }
269
+ return parseProvisionedChannel(
270
+ await this.http.request({
271
+ method: 'POST',
272
+ url: this.url('/channels'),
273
+ body: { name: clean, alertOwner: alertOwner !== false },
274
+ }),
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Invite people into a team channel. Up to 500 per call.
280
+ *
281
+ * Adding somebody to the roster is not the same as being able to alert them:
282
+ * they are invited, and an invitation is theirs to accept.
283
+ */
284
+ async addMembers(channelId: string, phones: string[]): Promise<InviteResult> {
285
+ return parseInviteResult(
286
+ await this.http.request({
287
+ method: 'POST',
288
+ url: this.url(`/channels/${encodeURIComponent(String(channelId))}/members`),
289
+ body: { phones: checkPhones(phones) },
290
+ }),
291
+ );
292
+ }
293
+
294
+ /** Take somebody off a team channel's roster. */
295
+ async removeMember(channelId: string, phone: string): Promise<boolean> {
296
+ return this.tolerateMissing({
297
+ method: 'DELETE',
298
+ url: this.url(
299
+ `/channels/${encodeURIComponent(String(channelId))}/members/${encodeURIComponent(validatePhone(phone))}`,
300
+ ),
301
+ });
302
+ }
303
+
304
+ /** Already-not-there is the state the caller asked for. */
305
+ private async tolerateMissing(request: Parameters<Transport['request']>[0]): Promise<boolean> {
306
+ try {
307
+ await this.http.request(request);
308
+ } catch (error) {
309
+ if (error instanceof M91NotFoundError) return true;
310
+ throw error;
311
+ }
312
+ return true;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * An API key is told apart from a mobile JWT by its prefix, server-side. So a
318
+ * key without it never reaches the key verifier at all, and comes back as a
319
+ * bare 401 that looks like revocation. Checked here to say what is actually
320
+ * wrong.
321
+ */
322
+ export function checkApiKey(apiKey: string): string {
323
+ const key = String(apiKey ?? '').trim();
324
+ if (!key) throw new M91ValidationError('apiKey is required');
325
+ if (!key.startsWith(API_KEY_PREFIX)) {
326
+ throw new M91ValidationError(
327
+ `apiKey must start with '${API_KEY_PREFIX}' — mint one in the M91 app. ` +
328
+ 'A mobile JWT will not work here.',
329
+ );
330
+ }
331
+ return key;
332
+ }
333
+
334
+ export function checkPhones(phones: string[]): string[] {
335
+ if (!phones?.length) throw new M91ValidationError('phones needs at least one number');
336
+ if (phones.length > MAX_MEMBERS_PER_CALL) {
337
+ throw new M91ValidationError(
338
+ `cannot invite more than ${MAX_MEMBERS_PER_CALL} numbers at once, got ${phones.length}. ` +
339
+ 'Send them in batches.',
340
+ );
341
+ }
342
+ return phones.map(validatePhone);
343
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The GET form of a send: an alert spelled as a URL.
3
+ *
4
+ * This is the integration surface that needs no client at all. It goes in a
5
+ * browser bar, a phone shortcut, an `<img>` tag, a cron `curl`, a smart button,
6
+ * or any field in any tool labelled "URL to call". Somebody who has never made
7
+ * an HTTP request in their life can raise an alert by tapping a bookmark, and
8
+ * that is the difference between M91 being wired into something today and being
9
+ * wired in never.
10
+ *
11
+ * **A bare send link raises nothing.** It is the connection test: it answers
12
+ * with the channel's name so whoever pasted it can see they picked the right
13
+ * one. Only a URL with `?title=` fires. That is not a default nobody got round
14
+ * to — the token is in the path, so link previews, crawlers and mail scanners
15
+ * fetch these routinely, and `title` having no default is the only thing
16
+ * between a pasted link and a woken team.
17
+ */
18
+
19
+ import { M91ValidationError } from './exceptions.js';
20
+ import {
21
+ CUSTOM_ID_MAX,
22
+ DESCRIPTION_MAX,
23
+ LABEL_MAX,
24
+ MAX_LINK_RESPONSE_OPTIONS,
25
+ type ResponseOption,
26
+ type Severity,
27
+ TITLE_MAX,
28
+ TITLE_MIN,
29
+ checkSeverity,
30
+ collapse,
31
+ } from './models.js';
32
+
33
+ /**
34
+ * Validate a send link and strip anything after the token.
35
+ *
36
+ * Callers paste these out of the app, out of a create-channel response, or out
37
+ * of a colleague's message, so a trailing slash, a stray query string or an
38
+ * already-built alert URL all arrive here. All of them normalise to the bare
39
+ * link.
40
+ */
41
+ export function normalizeSendLink(sendLink: string): string {
42
+ if (!sendLink || typeof sendLink !== 'string') {
43
+ throw new M91ValidationError('sendLink is required — the full https://…/s/<token> URL');
44
+ }
45
+
46
+ let parsed: URL;
47
+ try {
48
+ parsed = new URL(sendLink.trim());
49
+ } catch {
50
+ throw new M91ValidationError(
51
+ `sendLink must be a full URL beginning http:// or https://, got '${sendLink}'`,
52
+ );
53
+ }
54
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
55
+ throw new M91ValidationError(
56
+ `sendLink must be a full URL beginning http:// or https://, got '${sendLink}'`,
57
+ );
58
+ }
59
+
60
+ const segments = parsed.pathname.split('/').filter(Boolean);
61
+ // Checked because the failure it prevents is silent: a URL without a token
62
+ // authenticates as nothing and every call 401s, which reads like a revoked
63
+ // link rather than a mistyped one.
64
+ if (segments.length < 2 || segments[segments.length - 2] !== 's') {
65
+ throw new M91ValidationError(
66
+ `sendLink must look like https://m91.msg91.com/s/<token> — ` +
67
+ `no /s/<token> found in '${parsed.pathname}'`,
68
+ );
69
+ }
70
+
71
+ return `${parsed.origin}/${segments.join('/')}`;
72
+ }
73
+
74
+ /**
75
+ * Render `respond=` from labels or response options.
76
+ *
77
+ * The compact syntax exists because `[{label, effect}]` cannot survive a query
78
+ * string without becoming unreadable. So: a comma-separated list where the
79
+ * **first label is SHARED and the rest are PERSONAL** — the shape almost every
80
+ * real alert has, one button that takes the job for the whole group and some
81
+ * number of "seen" buttons that do not. A trailing `!` forces SHARED on any
82
+ * label, for the caller who wants two people able to claim.
83
+ *
84
+ * Two things a link cannot express, both of which throw here rather than
85
+ * producing a URL that quietly does something else: a **PERSONAL first option**,
86
+ * and a label that genuinely **ends in `!`**. Both are reachable with a POST.
87
+ */
88
+ export function buildRespond(labels: Array<ResponseOption | string>): string {
89
+ const rendered = labels.map((entry, index) => {
90
+ let label: string;
91
+ let marker = '';
92
+
93
+ if (typeof entry === 'string') {
94
+ label = collapse(entry);
95
+ } else {
96
+ label = collapse(entry?.label ?? '');
97
+ const shared = (entry?.effect ?? 'SHARED') === 'SHARED';
98
+ if (index === 0 && !shared) {
99
+ throw new M91ValidationError(
100
+ `the first response option in a link is always SHARED — '${label}' cannot be ` +
101
+ 'PERSONAL here. Use POST with responseOptions.',
102
+ );
103
+ }
104
+ marker = shared && index > 0 ? '!' : '';
105
+ }
106
+
107
+ if (!label) throw new M91ValidationError('respond labels cannot be empty');
108
+ if (label.includes(',')) {
109
+ // The separator is a comma, so a label containing one would silently
110
+ // become two buttons — an alert offering "Restart" and "then page Ana"
111
+ // as separate answers.
112
+ throw new M91ValidationError(
113
+ `a respond label cannot contain a comma — it is the separator. ` +
114
+ `Use POST with responseOptions for '${label}'.`,
115
+ );
116
+ }
117
+ const bare = label.replace(/!+$/, '').trim();
118
+ if (bare.length > LABEL_MAX) {
119
+ throw new M91ValidationError(
120
+ `response label must be ${LABEL_MAX} characters or fewer, got '${label}'`,
121
+ );
122
+ }
123
+ return { rendered: label + marker, key: bare.toLowerCase() };
124
+ });
125
+
126
+ if (rendered.length > MAX_LINK_RESPONSE_OPTIONS) {
127
+ throw new M91ValidationError(
128
+ `a link can carry at most ${MAX_LINK_RESPONSE_OPTIONS} response labels, ` +
129
+ `got ${rendered.length}. Use POST for more.`,
130
+ );
131
+ }
132
+ const seen = new Set<string>();
133
+ for (const { rendered: text, key } of rendered) {
134
+ if (seen.has(key)) {
135
+ throw new M91ValidationError(`cannot repeat the same response label twice: '${text}'`);
136
+ }
137
+ seen.add(key);
138
+ }
139
+ return rendered.map((r) => r.rendered).join(',');
140
+ }
141
+
142
+ export interface BuildUrlOptions {
143
+ description?: string;
144
+ severity?: Severity;
145
+ customId?: string;
146
+ /** First label is SHARED, the rest PERSONAL. Up to 6. */
147
+ respond?: Array<ResponseOption | string>;
148
+ isAutoClose?: boolean;
149
+ }
150
+
151
+ export interface BuildManySpec extends BuildUrlOptions {
152
+ title: string;
153
+ }
154
+
155
+ /** Builds ready-to-paste alert URLs for tools whose only field is "URL to call". */
156
+ export class M91URLBuilder {
157
+ readonly sendLink: string;
158
+
159
+ constructor(sendLink: string) {
160
+ this.sendLink = normalizeSendLink(sendLink);
161
+ }
162
+
163
+ /** The bare link. Answers with the channel name and **raises nothing**. */
164
+ testUrl(): string {
165
+ return this.sendLink;
166
+ }
167
+
168
+ /** A full URL that raises this alert when anything opens it. */
169
+ build(title: string, options: BuildUrlOptions = {}): string {
170
+ const cleanTitle = collapse(title ?? '');
171
+ if (!cleanTitle || cleanTitle.length < TITLE_MIN) {
172
+ throw new M91ValidationError(`title must be at least ${TITLE_MIN} characters, got '${title}'`);
173
+ }
174
+ if (cleanTitle.length > TITLE_MAX) {
175
+ throw new M91ValidationError(
176
+ `title must be ${TITLE_MAX} characters or fewer, got ${cleanTitle.length}`,
177
+ );
178
+ }
179
+
180
+ const severity = checkSeverity(options.severity ?? 'MEDIUM');
181
+ const query = new URLSearchParams();
182
+ query.set('title', cleanTitle);
183
+
184
+ if (options.description !== undefined && options.description !== null) {
185
+ const description = collapse(options.description);
186
+ if (description.length > DESCRIPTION_MAX) {
187
+ throw new M91ValidationError(
188
+ `description must be ${DESCRIPTION_MAX} characters or fewer, got ${description.length}`,
189
+ );
190
+ }
191
+ query.set('description', description);
192
+ }
193
+
194
+ // MEDIUM is the server's default, so spelling it costs URL length and buys
195
+ // nothing. These get pasted into text boxes and retyped by hand.
196
+ if (severity !== 'MEDIUM') query.set('severity', severity);
197
+
198
+ if (options.customId !== undefined && options.customId !== null) {
199
+ const customId = collapse(String(options.customId));
200
+ if (customId.length > CUSTOM_ID_MAX) {
201
+ throw new M91ValidationError(
202
+ `customId must be ${CUSTOM_ID_MAX} characters or fewer, got ${customId.length}`,
203
+ );
204
+ }
205
+ query.set('customId', customId);
206
+ }
207
+
208
+ if (options.respond?.length) query.set('respond', buildRespond(options.respond));
209
+ if (options.isAutoClose !== undefined) {
210
+ query.set('isAutoClose', options.isAutoClose ? 'true' : 'false');
211
+ }
212
+
213
+ return `${this.sendLink}?${query.toString()}`;
214
+ }
215
+
216
+ /** One URL per spec. Each takes the same options as `build`. */
217
+ buildMany(alerts: BuildManySpec[]): string[] {
218
+ return alerts.map(({ title, ...options }) => {
219
+ if (title === undefined) {
220
+ throw new M91ValidationError("every alert in buildMany needs a 'title'");
221
+ }
222
+ return this.build(title, options);
223
+ });
224
+ }
225
+ }