@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
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
import { M91ForbiddenError, M91NotFoundError, M91ValidationError } from './exceptions.js';
|
|
21
|
+
import { Transport } from './http.js';
|
|
22
|
+
import { CHANNEL_NAME_MAX, MAX_MEMBERS_PER_CALL, buildAlertPayload, collapse, parseAlertDetail, parseAlertResponse, parseApiKeyInfo, parseInviteResult, parseProvisionedChannel, parseRecipient, validatePhone, } from './models.js';
|
|
23
|
+
import { checkDelivery } from './client.js';
|
|
24
|
+
export const DEFAULT_BASE_URL = 'https://m91-api.msg91.com';
|
|
25
|
+
export const API_KEY_PREFIX = 'm91sk_';
|
|
26
|
+
/** Invite people, alert them one at a time, and provision team channels. */
|
|
27
|
+
export class M91Platform {
|
|
28
|
+
baseUrl;
|
|
29
|
+
throwOnZeroDelivery;
|
|
30
|
+
apiKey;
|
|
31
|
+
http;
|
|
32
|
+
constructor(apiKey, options = {}) {
|
|
33
|
+
this.apiKey = checkApiKey(apiKey);
|
|
34
|
+
this.throwOnZeroDelivery = options.throwOnZeroDelivery !== false;
|
|
35
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
36
|
+
this.http = new Transport({
|
|
37
|
+
...options,
|
|
38
|
+
headers: { authorization: `Bearer ${this.apiKey}`, ...options.headers },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** The key is a live credential that can wake people. Only its prefix. */
|
|
42
|
+
toString() {
|
|
43
|
+
return `M91Platform(${this.apiKey.slice(0, 10)}…, ${this.baseUrl})`;
|
|
44
|
+
}
|
|
45
|
+
url(path) {
|
|
46
|
+
return `${this.baseUrl}/api/v1${path}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check the key works, and see which key it is. **Alerts nobody.**
|
|
50
|
+
*
|
|
51
|
+
* The only route on this surface that is safe to call just to find out
|
|
52
|
+
* whether a credential works — everything else writes something or needs an
|
|
53
|
+
* id that already exists. On an alerting product, "test the connection" must
|
|
54
|
+
* never mean "wake somebody", so do not verify a key by sending an alert.
|
|
55
|
+
*
|
|
56
|
+
* A revoked key never reaches this route, so a success means *live*, not
|
|
57
|
+
* merely well-formed.
|
|
58
|
+
*
|
|
59
|
+
* Note what it does **not** prove: a perfectly good key with nobody accepted
|
|
60
|
+
* behind it still alerts nobody. Invite a real number and check the state
|
|
61
|
+
* before you believe the integration works.
|
|
62
|
+
*/
|
|
63
|
+
async verify() {
|
|
64
|
+
return parseApiKeyInfo(await this.http.request({ method: 'GET', url: this.url('/me') }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Invite somebody to accept direct alerts. **Idempotent.**
|
|
68
|
+
*
|
|
69
|
+
* Keyed on the phone number, so calling it on every login is safe and is the
|
|
70
|
+
* intended usage — retries cannot produce a second channel.
|
|
71
|
+
*
|
|
72
|
+
* The returned `state` needs different handling in each case, and the
|
|
73
|
+
* important one is that only `accepted` can actually be woken. A `pending`
|
|
74
|
+
* recipient has been asked and has not answered: nag them in your own UI, do
|
|
75
|
+
* not re-invite them in a loop. `declined` is terminal — no API re-invites a
|
|
76
|
+
* declined number, and only they can undo it from their own app.
|
|
77
|
+
*
|
|
78
|
+
* Inviting is deliberately separate from alerting: **an alert must never be
|
|
79
|
+
* what invites somebody**, because an invited person is not an M91 user yet
|
|
80
|
+
* and the urgent thing would arrive as an ordinary message that does not
|
|
81
|
+
* pierce Do Not Disturb, does not repeat and does not escalate.
|
|
82
|
+
*
|
|
83
|
+
* `label` is **the name the person sees on the alert**, not a name for the
|
|
84
|
+
* person. It is the only thing on their screen saying where the alert came
|
|
85
|
+
* from, so it should be your product — "Acme CRM", not "Priya" and not an id.
|
|
86
|
+
* Woken at 3am, "Acme CRM" answers the question they have and their own name
|
|
87
|
+
* does not.
|
|
88
|
+
*
|
|
89
|
+
* Two things follow, and both bite: it defaults to `"Direct alerts"` when
|
|
90
|
+
* omitted, and **every invite overwrites it**. Since this call belongs in
|
|
91
|
+
* your login handler, omitting the label on the second login resets a good
|
|
92
|
+
* name back to the default. Pass the same constant every time.
|
|
93
|
+
*/
|
|
94
|
+
async inviteRecipient(phone, label) {
|
|
95
|
+
const body = { phone: validatePhone(phone) };
|
|
96
|
+
if (label !== undefined && label !== null && collapse(label))
|
|
97
|
+
body.label = collapse(label);
|
|
98
|
+
return parseRecipient(await this.http.request({ method: 'POST', url: this.url('/recipients'), body }));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Stop alerting somebody. `true` when they are no longer a recipient.
|
|
102
|
+
*
|
|
103
|
+
* Worth wiring to whatever your system calls offboarding. Nothing in an
|
|
104
|
+
* integration is as routinely forgotten, and forgetting it means somebody who
|
|
105
|
+
* left the company is still woken at 3am.
|
|
106
|
+
*/
|
|
107
|
+
async removeRecipient(phone) {
|
|
108
|
+
return this.tolerateMissing({
|
|
109
|
+
method: 'DELETE',
|
|
110
|
+
url: this.url(`/recipients/${encodeURIComponent(validatePhone(phone))}`),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Wake one person. They must have been invited **and have accepted**.
|
|
115
|
+
*
|
|
116
|
+
* Alerting a number nobody invited throws `M91NotFoundError`, never a silent
|
|
117
|
+
* invitation.
|
|
118
|
+
*
|
|
119
|
+
* A direct alert *is* a channel with one member, so responses, escalation,
|
|
120
|
+
* deduplication and the consent gate behave exactly as they do for a team —
|
|
121
|
+
* there is no second implementation to keep in step.
|
|
122
|
+
*/
|
|
123
|
+
async alertPerson(phone, title, options = {}) {
|
|
124
|
+
const body = buildAlertPayload(title, options);
|
|
125
|
+
body.phone = validatePhone(phone);
|
|
126
|
+
const alert = parseAlertResponse(await this.http.request({ method: 'POST', url: this.url('/alerts'), body }));
|
|
127
|
+
// The same guard the send link gets. A person who reinstalled their phone
|
|
128
|
+
// still has an account and still shows as an accepted recipient, so this
|
|
129
|
+
// path answers 201 with delivered: 0 and looks exactly like success — and
|
|
130
|
+
// with one recipient there is nobody else it reached.
|
|
131
|
+
checkDelivery(alert, this.throwOnZeroDelivery);
|
|
132
|
+
return alert;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Read back what one person decided. `null` if there is no such alert.
|
|
136
|
+
* `alertId` is the `customId` you invented or the `_id` a send returned.
|
|
137
|
+
*/
|
|
138
|
+
async getAlert(alertId, phone) {
|
|
139
|
+
try {
|
|
140
|
+
const raw = await this.http.request({
|
|
141
|
+
method: 'GET',
|
|
142
|
+
url: this.url(`/alerts/${encodeURIComponent(String(alertId))}`),
|
|
143
|
+
query: { phone: validatePhone(phone) },
|
|
144
|
+
});
|
|
145
|
+
return parseAlertDetail(raw);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof M91NotFoundError)
|
|
149
|
+
return null;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Close one person's alert.
|
|
155
|
+
*
|
|
156
|
+
* **404 and 403 are success**, as on the send link: "no such alert" and
|
|
157
|
+
* "already resolved" both mean it is not open, which is what was asked for.
|
|
158
|
+
*/
|
|
159
|
+
async closeAlert(phone, options) {
|
|
160
|
+
const { customId, alertId } = options ?? {};
|
|
161
|
+
if (Boolean(customId) === Boolean(alertId)) {
|
|
162
|
+
throw new M91ValidationError(`closeAlert() needs exactly one of customId or alertId — got ${customId ? 'both' : 'neither'}`);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await this.http.request({
|
|
166
|
+
method: 'POST',
|
|
167
|
+
url: this.url('/alerts/close'),
|
|
168
|
+
body: { phone: validatePhone(phone), ...(customId ? { customId } : { alertId }) },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// 403 as well as 404, matching M91Client.close(). Production answers 403
|
|
173
|
+
// `already_resolved` when the alert is closed already, and "it is closed"
|
|
174
|
+
// is the state the caller asked for. Only close gets this: on a REMOVE a
|
|
175
|
+
// 403 means the channel is not yours, which must not be swallowed.
|
|
176
|
+
if (error instanceof M91NotFoundError || error instanceof M91ForbiddenError)
|
|
177
|
+
return true;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Create a team channel and get back the send link that alerts it.
|
|
184
|
+
*
|
|
185
|
+
* **Not idempotent, and deliberately takes no id of your own to make it so.**
|
|
186
|
+
* Calling this twice makes two channels. Store the returned `id` and
|
|
187
|
+
* `sendLink` against your own team the moment you get them — a lost response
|
|
188
|
+
* is an orphaned channel, not something to retry.
|
|
189
|
+
*
|
|
190
|
+
* `alertOwner: false` skips adding the key's owner as a member: a platform
|
|
191
|
+
* minting one channel per customer team has no reason for its own account to
|
|
192
|
+
* be paged for every one of them. They stay the owner either way.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* Every team channel this key can raise an alert on.
|
|
196
|
+
*
|
|
197
|
+
* The missing half of {@link createChannel}, which is **not idempotent**: a
|
|
198
|
+
* platform that lost the response to a create had an orphaned channel and no
|
|
199
|
+
* way to find it. Now it can read back what it already has.
|
|
200
|
+
*
|
|
201
|
+
* **Scoped by send access, not by ownership.** Every row carries a
|
|
202
|
+
* `sendLink`, so the list answers "what can I alert?" — which includes a
|
|
203
|
+
* channel whose owner granted this person send access, not only the ones they
|
|
204
|
+
* created. Creating a channel grants its owner send access too, so owned
|
|
205
|
+
* channels are a subset of this rather than a separate case.
|
|
206
|
+
*
|
|
207
|
+
* Channels they merely joined stay absent: being a member means you get
|
|
208
|
+
* woken, not that you may wake others. Archived channels are left out, since
|
|
209
|
+
* a link that cannot alert anybody is worse than no link.
|
|
210
|
+
*/
|
|
211
|
+
async listChannels() {
|
|
212
|
+
const rows = await this.http.request({ method: 'GET', url: this.url('/channels') });
|
|
213
|
+
return (Array.isArray(rows) ? rows : []).map(parseProvisionedChannel);
|
|
214
|
+
}
|
|
215
|
+
async createChannel(name, alertOwner = true) {
|
|
216
|
+
const clean = collapse(name ?? '');
|
|
217
|
+
if (!clean)
|
|
218
|
+
throw new M91ValidationError('channel name is required');
|
|
219
|
+
if (clean.length > CHANNEL_NAME_MAX) {
|
|
220
|
+
throw new M91ValidationError(`channel name must be ${CHANNEL_NAME_MAX} characters or fewer, got ${clean.length}`);
|
|
221
|
+
}
|
|
222
|
+
return parseProvisionedChannel(await this.http.request({
|
|
223
|
+
method: 'POST',
|
|
224
|
+
url: this.url('/channels'),
|
|
225
|
+
body: { name: clean, alertOwner: alertOwner !== false },
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Invite people into a team channel. Up to 500 per call.
|
|
230
|
+
*
|
|
231
|
+
* Adding somebody to the roster is not the same as being able to alert them:
|
|
232
|
+
* they are invited, and an invitation is theirs to accept.
|
|
233
|
+
*/
|
|
234
|
+
async addMembers(channelId, phones) {
|
|
235
|
+
return parseInviteResult(await this.http.request({
|
|
236
|
+
method: 'POST',
|
|
237
|
+
url: this.url(`/channels/${encodeURIComponent(String(channelId))}/members`),
|
|
238
|
+
body: { phones: checkPhones(phones) },
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
/** Take somebody off a team channel's roster. */
|
|
242
|
+
async removeMember(channelId, phone) {
|
|
243
|
+
return this.tolerateMissing({
|
|
244
|
+
method: 'DELETE',
|
|
245
|
+
url: this.url(`/channels/${encodeURIComponent(String(channelId))}/members/${encodeURIComponent(validatePhone(phone))}`),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Already-not-there is the state the caller asked for. */
|
|
249
|
+
async tolerateMissing(request) {
|
|
250
|
+
try {
|
|
251
|
+
await this.http.request(request);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error instanceof M91NotFoundError)
|
|
255
|
+
return true;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* An API key is told apart from a mobile JWT by its prefix, server-side. So a
|
|
263
|
+
* key without it never reaches the key verifier at all, and comes back as a
|
|
264
|
+
* bare 401 that looks like revocation. Checked here to say what is actually
|
|
265
|
+
* wrong.
|
|
266
|
+
*/
|
|
267
|
+
export function checkApiKey(apiKey) {
|
|
268
|
+
const key = String(apiKey ?? '').trim();
|
|
269
|
+
if (!key)
|
|
270
|
+
throw new M91ValidationError('apiKey is required');
|
|
271
|
+
if (!key.startsWith(API_KEY_PREFIX)) {
|
|
272
|
+
throw new M91ValidationError(`apiKey must start with '${API_KEY_PREFIX}' — mint one in the M91 app. ` +
|
|
273
|
+
'A mobile JWT will not work here.');
|
|
274
|
+
}
|
|
275
|
+
return key;
|
|
276
|
+
}
|
|
277
|
+
export function checkPhones(phones) {
|
|
278
|
+
if (!phones?.length)
|
|
279
|
+
throw new M91ValidationError('phones needs at least one number');
|
|
280
|
+
if (phones.length > MAX_MEMBERS_PER_CALL) {
|
|
281
|
+
throw new M91ValidationError(`cannot invite more than ${MAX_MEMBERS_PER_CALL} numbers at once, got ${phones.length}. ` +
|
|
282
|
+
'Send them in batches.');
|
|
283
|
+
}
|
|
284
|
+
return phones.map(validatePhone);
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/platform.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
import { type ResponseOption, type Severity } from './models.js';
|
|
19
|
+
/**
|
|
20
|
+
* Validate a send link and strip anything after the token.
|
|
21
|
+
*
|
|
22
|
+
* Callers paste these out of the app, out of a create-channel response, or out
|
|
23
|
+
* of a colleague's message, so a trailing slash, a stray query string or an
|
|
24
|
+
* already-built alert URL all arrive here. All of them normalise to the bare
|
|
25
|
+
* link.
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizeSendLink(sendLink: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Render `respond=` from labels or response options.
|
|
30
|
+
*
|
|
31
|
+
* The compact syntax exists because `[{label, effect}]` cannot survive a query
|
|
32
|
+
* string without becoming unreadable. So: a comma-separated list where the
|
|
33
|
+
* **first label is SHARED and the rest are PERSONAL** — the shape almost every
|
|
34
|
+
* real alert has, one button that takes the job for the whole group and some
|
|
35
|
+
* number of "seen" buttons that do not. A trailing `!` forces SHARED on any
|
|
36
|
+
* label, for the caller who wants two people able to claim.
|
|
37
|
+
*
|
|
38
|
+
* Two things a link cannot express, both of which throw here rather than
|
|
39
|
+
* producing a URL that quietly does something else: a **PERSONAL first option**,
|
|
40
|
+
* and a label that genuinely **ends in `!`**. Both are reachable with a POST.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildRespond(labels: Array<ResponseOption | string>): string;
|
|
43
|
+
export interface BuildUrlOptions {
|
|
44
|
+
description?: string;
|
|
45
|
+
severity?: Severity;
|
|
46
|
+
customId?: string;
|
|
47
|
+
/** First label is SHARED, the rest PERSONAL. Up to 6. */
|
|
48
|
+
respond?: Array<ResponseOption | string>;
|
|
49
|
+
isAutoClose?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface BuildManySpec extends BuildUrlOptions {
|
|
52
|
+
title: string;
|
|
53
|
+
}
|
|
54
|
+
/** Builds ready-to-paste alert URLs for tools whose only field is "URL to call". */
|
|
55
|
+
export declare class M91URLBuilder {
|
|
56
|
+
readonly sendLink: string;
|
|
57
|
+
constructor(sendLink: string);
|
|
58
|
+
/** The bare link. Answers with the channel name and **raises nothing**. */
|
|
59
|
+
testUrl(): string;
|
|
60
|
+
/** A full URL that raises this alert when anything opens it. */
|
|
61
|
+
build(title: string, options?: BuildUrlOptions): string;
|
|
62
|
+
/** One URL per spec. Each takes the same options as `build`. */
|
|
63
|
+
buildMany(alerts: BuildManySpec[]): string[];
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=url-builder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-builder.d.ts","sourceRoot":"","sources":["../../src/url-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,QAAQ,EAKd,MAAM,aAAa,CAAC;AAErB;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA+B1D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,cAAc,GAAG,MAAM,CAAC,GAAG,MAAM,CAoD3E;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,OAAO,CAAC,EAAE,KAAK,CAAC,cAAc,GAAG,MAAM,CAAC,CAAC;IACzC,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,oFAAoF;AACpF,qBAAa,aAAa;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAEd,QAAQ,EAAE,MAAM;IAI5B,2EAA2E;IAC3E,OAAO,IAAI,MAAM;IAIjB,gEAAgE;IAChE,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM;IA+C3D,gEAAgE;IAChE,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,MAAM,EAAE;CAQ7C"}
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
import { M91ValidationError } from './exceptions.js';
|
|
19
|
+
import { CUSTOM_ID_MAX, DESCRIPTION_MAX, LABEL_MAX, MAX_LINK_RESPONSE_OPTIONS, TITLE_MAX, TITLE_MIN, checkSeverity, collapse, } from './models.js';
|
|
20
|
+
/**
|
|
21
|
+
* Validate a send link and strip anything after the token.
|
|
22
|
+
*
|
|
23
|
+
* Callers paste these out of the app, out of a create-channel response, or out
|
|
24
|
+
* of a colleague's message, so a trailing slash, a stray query string or an
|
|
25
|
+
* already-built alert URL all arrive here. All of them normalise to the bare
|
|
26
|
+
* link.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeSendLink(sendLink) {
|
|
29
|
+
if (!sendLink || typeof sendLink !== 'string') {
|
|
30
|
+
throw new M91ValidationError('sendLink is required — the full https://…/s/<token> URL');
|
|
31
|
+
}
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(sendLink.trim());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new M91ValidationError(`sendLink must be a full URL beginning http:// or https://, got '${sendLink}'`);
|
|
38
|
+
}
|
|
39
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
40
|
+
throw new M91ValidationError(`sendLink must be a full URL beginning http:// or https://, got '${sendLink}'`);
|
|
41
|
+
}
|
|
42
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
43
|
+
// Checked because the failure it prevents is silent: a URL without a token
|
|
44
|
+
// authenticates as nothing and every call 401s, which reads like a revoked
|
|
45
|
+
// link rather than a mistyped one.
|
|
46
|
+
if (segments.length < 2 || segments[segments.length - 2] !== 's') {
|
|
47
|
+
throw new M91ValidationError(`sendLink must look like https://m91-api.msg91.com/s/<token> — ` +
|
|
48
|
+
`no /s/<token> found in '${parsed.pathname}'`);
|
|
49
|
+
}
|
|
50
|
+
return `${parsed.origin}/${segments.join('/')}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Render `respond=` from labels or response options.
|
|
54
|
+
*
|
|
55
|
+
* The compact syntax exists because `[{label, effect}]` cannot survive a query
|
|
56
|
+
* string without becoming unreadable. So: a comma-separated list where the
|
|
57
|
+
* **first label is SHARED and the rest are PERSONAL** — the shape almost every
|
|
58
|
+
* real alert has, one button that takes the job for the whole group and some
|
|
59
|
+
* number of "seen" buttons that do not. A trailing `!` forces SHARED on any
|
|
60
|
+
* label, for the caller who wants two people able to claim.
|
|
61
|
+
*
|
|
62
|
+
* Two things a link cannot express, both of which throw here rather than
|
|
63
|
+
* producing a URL that quietly does something else: a **PERSONAL first option**,
|
|
64
|
+
* and a label that genuinely **ends in `!`**. Both are reachable with a POST.
|
|
65
|
+
*/
|
|
66
|
+
export function buildRespond(labels) {
|
|
67
|
+
const rendered = labels.map((entry, index) => {
|
|
68
|
+
let label;
|
|
69
|
+
let marker = '';
|
|
70
|
+
if (typeof entry === 'string') {
|
|
71
|
+
label = collapse(entry);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
label = collapse(entry?.label ?? '');
|
|
75
|
+
const shared = (entry?.effect ?? 'SHARED') === 'SHARED';
|
|
76
|
+
if (index === 0 && !shared) {
|
|
77
|
+
throw new M91ValidationError(`the first response option in a link is always SHARED — '${label}' cannot be ` +
|
|
78
|
+
'PERSONAL here. Use POST with responseOptions.');
|
|
79
|
+
}
|
|
80
|
+
marker = shared && index > 0 ? '!' : '';
|
|
81
|
+
}
|
|
82
|
+
if (!label)
|
|
83
|
+
throw new M91ValidationError('respond labels cannot be empty');
|
|
84
|
+
if (label.includes(',')) {
|
|
85
|
+
// The separator is a comma, so a label containing one would silently
|
|
86
|
+
// become two buttons — an alert offering "Restart" and "then page Ana"
|
|
87
|
+
// as separate answers.
|
|
88
|
+
throw new M91ValidationError(`a respond label cannot contain a comma — it is the separator. ` +
|
|
89
|
+
`Use POST with responseOptions for '${label}'.`);
|
|
90
|
+
}
|
|
91
|
+
const bare = label.replace(/!+$/, '').trim();
|
|
92
|
+
if (bare.length > LABEL_MAX) {
|
|
93
|
+
throw new M91ValidationError(`response label must be ${LABEL_MAX} characters or fewer, got '${label}'`);
|
|
94
|
+
}
|
|
95
|
+
return { rendered: label + marker, key: bare.toLowerCase() };
|
|
96
|
+
});
|
|
97
|
+
if (rendered.length > MAX_LINK_RESPONSE_OPTIONS) {
|
|
98
|
+
throw new M91ValidationError(`a link can carry at most ${MAX_LINK_RESPONSE_OPTIONS} response labels, ` +
|
|
99
|
+
`got ${rendered.length}. Use POST for more.`);
|
|
100
|
+
}
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const { rendered: text, key } of rendered) {
|
|
103
|
+
if (seen.has(key)) {
|
|
104
|
+
throw new M91ValidationError(`cannot repeat the same response label twice: '${text}'`);
|
|
105
|
+
}
|
|
106
|
+
seen.add(key);
|
|
107
|
+
}
|
|
108
|
+
return rendered.map((r) => r.rendered).join(',');
|
|
109
|
+
}
|
|
110
|
+
/** Builds ready-to-paste alert URLs for tools whose only field is "URL to call". */
|
|
111
|
+
export class M91URLBuilder {
|
|
112
|
+
sendLink;
|
|
113
|
+
constructor(sendLink) {
|
|
114
|
+
this.sendLink = normalizeSendLink(sendLink);
|
|
115
|
+
}
|
|
116
|
+
/** The bare link. Answers with the channel name and **raises nothing**. */
|
|
117
|
+
testUrl() {
|
|
118
|
+
return this.sendLink;
|
|
119
|
+
}
|
|
120
|
+
/** A full URL that raises this alert when anything opens it. */
|
|
121
|
+
build(title, options = {}) {
|
|
122
|
+
const cleanTitle = collapse(title ?? '');
|
|
123
|
+
if (!cleanTitle || cleanTitle.length < TITLE_MIN) {
|
|
124
|
+
throw new M91ValidationError(`title must be at least ${TITLE_MIN} characters, got '${title}'`);
|
|
125
|
+
}
|
|
126
|
+
if (cleanTitle.length > TITLE_MAX) {
|
|
127
|
+
throw new M91ValidationError(`title must be ${TITLE_MAX} characters or fewer, got ${cleanTitle.length}`);
|
|
128
|
+
}
|
|
129
|
+
const severity = checkSeverity(options.severity ?? 'MEDIUM');
|
|
130
|
+
const query = new URLSearchParams();
|
|
131
|
+
query.set('title', cleanTitle);
|
|
132
|
+
if (options.description !== undefined && options.description !== null) {
|
|
133
|
+
const description = collapse(options.description);
|
|
134
|
+
if (description.length > DESCRIPTION_MAX) {
|
|
135
|
+
throw new M91ValidationError(`description must be ${DESCRIPTION_MAX} characters or fewer, got ${description.length}`);
|
|
136
|
+
}
|
|
137
|
+
query.set('description', description);
|
|
138
|
+
}
|
|
139
|
+
// MEDIUM is the server's default, so spelling it costs URL length and buys
|
|
140
|
+
// nothing. These get pasted into text boxes and retyped by hand.
|
|
141
|
+
if (severity !== 'MEDIUM')
|
|
142
|
+
query.set('severity', severity);
|
|
143
|
+
if (options.customId !== undefined && options.customId !== null) {
|
|
144
|
+
const customId = collapse(String(options.customId));
|
|
145
|
+
if (customId.length > CUSTOM_ID_MAX) {
|
|
146
|
+
throw new M91ValidationError(`customId must be ${CUSTOM_ID_MAX} characters or fewer, got ${customId.length}`);
|
|
147
|
+
}
|
|
148
|
+
query.set('customId', customId);
|
|
149
|
+
}
|
|
150
|
+
if (options.respond?.length)
|
|
151
|
+
query.set('respond', buildRespond(options.respond));
|
|
152
|
+
if (options.isAutoClose !== undefined) {
|
|
153
|
+
query.set('isAutoClose', options.isAutoClose ? 'true' : 'false');
|
|
154
|
+
}
|
|
155
|
+
return `${this.sendLink}?${query.toString()}`;
|
|
156
|
+
}
|
|
157
|
+
/** One URL per spec. Each takes the same options as `build`. */
|
|
158
|
+
buildMany(alerts) {
|
|
159
|
+
return alerts.map(({ title, ...options }) => {
|
|
160
|
+
if (title === undefined) {
|
|
161
|
+
throw new M91ValidationError("every alert in buildMany needs a 'title'");
|
|
162
|
+
}
|
|
163
|
+
return this.build(title, options);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=url-builder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-builder.js","sourceRoot":"","sources":["../../src/url-builder.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval gate: ask a human, on their phone, and wait.
|
|
3
|
+
*
|
|
4
|
+
* This is the shape of every human-in-the-loop decision — raise the question,
|
|
5
|
+
* block, act on the answer. One call does all of it, including closing the
|
|
6
|
+
* alert afterwards so nobody is left with a live question that has been
|
|
7
|
+
* answered.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { M91Client } from '@msg91comm/m91';
|
|
11
|
+
|
|
12
|
+
const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
13
|
+
|
|
14
|
+
const approved = await client.requestApproval(
|
|
15
|
+
'Agent wants to process a refund of ₹48,000 for order ORD-40122',
|
|
16
|
+
{
|
|
17
|
+
description:
|
|
18
|
+
'Customer says the item never arrived. The courier\'s tracking shows delivered, ' +
|
|
19
|
+
"signed for by 'R. Kumar'. The customer's name is Rahul Kumar. " +
|
|
20
|
+
'Approving pays out immediately and cannot be reversed.',
|
|
21
|
+
customId: 'refund-ORD-40122', // stable: the order id, never a timestamp
|
|
22
|
+
severity: 'HIGH',
|
|
23
|
+
approveLabel: 'Refund',
|
|
24
|
+
rejectLabel: 'Hold',
|
|
25
|
+
timeoutMs: 300_000,
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (approved) {
|
|
30
|
+
console.log('A human approved it. Processing the refund.');
|
|
31
|
+
} else {
|
|
32
|
+
// Rejected, or nobody answered in five minutes. Both mean: do not pay out.
|
|
33
|
+
// There is no configuration that makes silence mean yes.
|
|
34
|
+
console.log('Not approved. Leaving the refund for the morning queue.');
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raise an alert, then close it when the condition heals.
|
|
3
|
+
*
|
|
4
|
+
* export M91_SEND_LINK="https://m91-api.msg91.com/s/your_token"
|
|
5
|
+
* npx tsx examples/basic-alert.ts
|
|
6
|
+
*
|
|
7
|
+
* The send link is the whole credential. It goes in the environment, never in
|
|
8
|
+
* the repository — the token is in the URL path, so the URL *is* the password.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { M91Client, M91DeliveryError } from '@msg91comm/m91';
|
|
12
|
+
|
|
13
|
+
const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
14
|
+
|
|
15
|
+
// Raises nothing. Worth doing on startup: a mistyped link otherwise looks fine
|
|
16
|
+
// until the night it has to work.
|
|
17
|
+
const channel = await client.verify();
|
|
18
|
+
console.log(`Connected to '${channel.name}' (${channel.visibility})`);
|
|
19
|
+
|
|
20
|
+
const alert = await client.send('Database disk 90% full on node-7', {
|
|
21
|
+
description: '/var/lib/postgresql at 90%. Growing ~2GB/hour. ~5 hours left.',
|
|
22
|
+
severity: 'HIGH', // HIGH also places a voice call after 45s of silence
|
|
23
|
+
// Stable: the SAME string every time this condition is true. A check running
|
|
24
|
+
// every minute then raises one alert, not sixty — and this is what closes it.
|
|
25
|
+
customId: 'disk-full-node7',
|
|
26
|
+
responseOptions: [
|
|
27
|
+
{ label: 'On it', effect: 'SHARED' }, // takes it on, clears for everyone
|
|
28
|
+
{ label: 'Seen', effect: 'PERSONAL' }, // acknowledges, stays with the team
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Not "did the request succeed" — `delivered` is phones actually reached.
|
|
33
|
+
console.log(`Delivered to ${alert.delivered} phones (alert ${alert.id})`);
|
|
34
|
+
if (alert.deduplicated) {
|
|
35
|
+
console.log('An alert for this was already open — nobody was woken a second time.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ...later, when the disk has been cleared:
|
|
39
|
+
await client.close({ customId: 'disk-full-node7' });
|
|
40
|
+
|
|
41
|
+
// The failure worth catching by name: a 2xx where nobody's phone rang.
|
|
42
|
+
try {
|
|
43
|
+
await client.send('Heartbeat check from the example', { customId: 'example-heartbeat' });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof M91DeliveryError) {
|
|
46
|
+
console.error(
|
|
47
|
+
`Nobody was reachable: ${error.unregistered.length} unregistered, ` +
|
|
48
|
+
`${error.noDevice} without a device`,
|
|
49
|
+
);
|
|
50
|
+
} else {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Claude agent that can read anything, but rings a phone before it runs a
|
|
3
|
+
* shell command or writes a file.
|
|
4
|
+
*
|
|
5
|
+
* M91_SEND_LINK=... ANTHROPIC_API_KEY=... npx tsx examples/claude-agent-sdk.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
|
|
10
|
+
import { M91Client } from '@msg91comm/m91';
|
|
11
|
+
import { createM91CanUseTool, createM91McpServer } from '@msg91comm/m91/integrations/claude-agent-sdk';
|
|
12
|
+
|
|
13
|
+
const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
14
|
+
|
|
15
|
+
for await (const message of query({
|
|
16
|
+
prompt: 'Find the feature branches merged more than 30 days ago and delete them.',
|
|
17
|
+
options: {
|
|
18
|
+
canUseTool: createM91CanUseTool(client, { tools: ['Bash', 'Write', 'Edit'] }),
|
|
19
|
+
mcpServers: { m91: await createM91McpServer(client) },
|
|
20
|
+
},
|
|
21
|
+
})) {
|
|
22
|
+
if (message.type === 'result') console.log(message);
|
|
23
|
+
}
|