@pingroom/sdk 0.8.0 → 0.9.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/README.md +153 -41
- package/dist/client.d.ts +14 -348
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +15 -1300
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +32 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +47 -1
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +7 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/guards.d.ts +7 -0
- package/dist/internal/guards.d.ts.map +1 -1
- package/dist/internal/guards.js +1 -1
- package/dist/internal/guards.js.map +1 -1
- package/dist/internal/request.d.ts +41 -0
- package/dist/internal/request.d.ts.map +1 -0
- package/dist/internal/request.js +78 -0
- package/dist/internal/request.js.map +1 -0
- package/dist/internal/retry.d.ts +9 -2
- package/dist/internal/retry.d.ts.map +1 -1
- package/dist/internal/retry.js +24 -6
- package/dist/internal/retry.js.map +1 -1
- package/dist/internal/validate.d.ts +13 -0
- package/dist/internal/validate.d.ts.map +1 -0
- package/dist/internal/validate.js +36 -0
- package/dist/internal/validate.js.map +1 -0
- package/dist/liveStatus.d.ts +106 -11
- package/dist/liveStatus.d.ts.map +1 -1
- package/dist/liveStatus.js +20 -3
- package/dist/liveStatus.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +30 -8
- package/dist/mcp.js.map +1 -1
- package/dist/namespaces/actions.d.ts +50 -0
- package/dist/namespaces/actions.d.ts.map +1 -0
- package/dist/namespaces/actions.js +219 -0
- package/dist/namespaces/actions.js.map +1 -0
- package/dist/namespaces/approvals.d.ts +23 -0
- package/dist/namespaces/approvals.d.ts.map +1 -0
- package/dist/namespaces/approvals.js +78 -0
- package/dist/namespaces/approvals.js.map +1 -0
- package/dist/namespaces/attachments.d.ts +37 -0
- package/dist/namespaces/attachments.d.ts.map +1 -0
- package/dist/namespaces/attachments.js +91 -0
- package/dist/namespaces/attachments.js.map +1 -0
- package/dist/namespaces/auth.d.ts +58 -0
- package/dist/namespaces/auth.d.ts.map +1 -0
- package/dist/namespaces/auth.js +242 -0
- package/dist/namespaces/auth.js.map +1 -0
- package/dist/namespaces/directory.d.ts +10 -0
- package/dist/namespaces/directory.d.ts.map +1 -0
- package/dist/namespaces/directory.js +19 -0
- package/dist/namespaces/directory.js.map +1 -0
- package/dist/namespaces/handoffs.d.ts +47 -0
- package/dist/namespaces/handoffs.d.ts.map +1 -0
- package/dist/namespaces/handoffs.js +126 -0
- package/dist/namespaces/handoffs.js.map +1 -0
- package/dist/namespaces/inbox.d.ts +17 -0
- package/dist/namespaces/inbox.d.ts.map +1 -0
- package/dist/namespaces/inbox.js +192 -0
- package/dist/namespaces/inbox.js.map +1 -0
- package/dist/namespaces/live.d.ts +57 -0
- package/dist/namespaces/live.d.ts.map +1 -0
- package/dist/namespaces/live.js +116 -0
- package/dist/namespaces/live.js.map +1 -0
- package/dist/namespaces/notifications.d.ts +25 -0
- package/dist/namespaces/notifications.d.ts.map +1 -0
- package/dist/namespaces/notifications.js +128 -0
- package/dist/namespaces/notifications.js.map +1 -0
- package/dist/namespaces/profile.d.ts +19 -0
- package/dist/namespaces/profile.d.ts.map +1 -0
- package/dist/namespaces/profile.js +22 -0
- package/dist/namespaces/profile.js.map +1 -0
- package/dist/namespaces/questions.d.ts +41 -0
- package/dist/namespaces/questions.d.ts.map +1 -0
- package/dist/namespaces/questions.js +107 -0
- package/dist/namespaces/questions.js.map +1 -0
- package/dist/namespaces/rooms.d.ts +13 -0
- package/dist/namespaces/rooms.d.ts.map +1 -0
- package/dist/namespaces/rooms.js +41 -0
- package/dist/namespaces/rooms.js.map +1 -0
- package/dist/namespaces/webhooks.d.ts +11 -0
- package/dist/namespaces/webhooks.d.ts.map +1 -0
- package/dist/namespaces/webhooks.js +32 -0
- package/dist/namespaces/webhooks.js.map +1 -0
- package/dist/types/approvals.d.ts +62 -0
- package/dist/types/approvals.d.ts.map +1 -0
- package/dist/types/approvals.js +2 -0
- package/dist/types/approvals.js.map +1 -0
- package/dist/types/auth.d.ts +185 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/common.d.ts +43 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +2 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/handoffs.d.ts +121 -0
- package/dist/types/handoffs.d.ts.map +1 -0
- package/dist/types/handoffs.js +2 -0
- package/dist/types/handoffs.js.map +1 -0
- package/dist/types/inbox.d.ts +51 -0
- package/dist/types/inbox.d.ts.map +1 -0
- package/dist/types/inbox.js +2 -0
- package/dist/types/inbox.js.map +1 -0
- package/dist/types/notifications.d.ts +376 -0
- package/dist/types/notifications.d.ts.map +1 -0
- package/dist/types/notifications.js +2 -0
- package/dist/types/notifications.js.map +1 -0
- package/dist/types/profile.d.ts +30 -0
- package/dist/types/profile.d.ts.map +1 -0
- package/dist/types/profile.js +3 -0
- package/dist/types/profile.js.map +1 -0
- package/dist/types/questions.d.ts +210 -0
- package/dist/types/questions.d.ts.map +1 -0
- package/dist/types/questions.js +2 -0
- package/dist/types/questions.js.map +1 -0
- package/dist/types/rooms.d.ts +163 -0
- package/dist/types/rooms.d.ts.map +1 -0
- package/dist/types/rooms.js +3 -0
- package/dist/types/rooms.js.map +1 -0
- package/dist/types.d.ts +9 -1199
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +14 -4
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +30 -5
- package/dist/webhooks.js.map +1 -1
- package/package.json +4 -2
- package/src/client.ts +115 -0
- package/src/errors.ts +291 -0
- package/src/http.ts +225 -0
- package/src/index.ts +97 -0
- package/src/internal/async.ts +54 -0
- package/src/internal/guards.ts +129 -0
- package/src/internal/request.ts +97 -0
- package/src/internal/retry.ts +157 -0
- package/src/internal/url.ts +78 -0
- package/src/internal/validate.ts +42 -0
- package/src/linkPing.ts +56 -0
- package/src/liveStatus.ts +337 -0
- package/src/locationPing.ts +101 -0
- package/src/mcp.ts +161 -0
- package/src/namespaces/actions.ts +234 -0
- package/src/namespaces/approvals.ts +104 -0
- package/src/namespaces/attachments.ts +106 -0
- package/src/namespaces/auth.ts +284 -0
- package/src/namespaces/directory.ts +21 -0
- package/src/namespaces/handoffs.ts +160 -0
- package/src/namespaces/inbox.ts +253 -0
- package/src/namespaces/live.ts +134 -0
- package/src/namespaces/notifications.ts +152 -0
- package/src/namespaces/profile.ts +24 -0
- package/src/namespaces/questions.ts +133 -0
- package/src/namespaces/rooms.ts +53 -0
- package/src/namespaces/webhooks.ts +38 -0
- package/src/types/approvals.ts +67 -0
- package/src/types/auth.ts +202 -0
- package/src/types/common.ts +74 -0
- package/src/types/handoffs.ts +148 -0
- package/src/types/inbox.ts +64 -0
- package/src/types/notifications.ts +393 -0
- package/src/types/profile.ts +37 -0
- package/src/types/questions.ts +226 -0
- package/src/types/rooms.ts +169 -0
- package/src/types.ts +17 -0
- package/src/version.ts +2 -0
- package/src/webhooks.ts +373 -0
- package/dist/liveActivity.d.ts +0 -101
- package/dist/liveActivity.d.ts.map +0 -1
- package/dist/liveActivity.js +0 -83
- package/dist/liveActivity.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -1,1310 +1,27 @@
|
|
|
1
|
-
import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
|
|
2
1
|
import { HttpClient } from './http.js';
|
|
3
|
-
import {
|
|
4
|
-
import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeIdempotencyKey, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
|
|
5
|
-
import { isTransientError, pollUntilSettled, transientDelayMs } from './internal/retry.js';
|
|
6
|
-
import { linkPing } from './linkPing.js';
|
|
7
|
-
import { locationPing } from './locationPing.js';
|
|
2
|
+
import { normalizeRedeemCode } from './internal/guards.js';
|
|
8
3
|
import { McpClient } from './mcp.js';
|
|
4
|
+
import { enc, splitIdempotencyKey, dropUndefined, assertPing } from './internal/request.js';
|
|
5
|
+
import { AuthApi } from './namespaces/auth.js';
|
|
6
|
+
import { RoomsApi } from './namespaces/rooms.js';
|
|
7
|
+
import { ActionsApi } from './namespaces/actions.js';
|
|
8
|
+
import { WebhooksApi } from './namespaces/webhooks.js';
|
|
9
|
+
import { NotificationsApi } from './namespaces/notifications.js';
|
|
10
|
+
import { AttachmentsApi } from './namespaces/attachments.js';
|
|
11
|
+
import { ApprovalsApi } from './namespaces/approvals.js';
|
|
12
|
+
import { QuestionsApi } from './namespaces/questions.js';
|
|
13
|
+
import { HandoffsApi } from './namespaces/handoffs.js';
|
|
14
|
+
import { InboxApi } from './namespaces/inbox.js';
|
|
15
|
+
import { ProfileApi } from './namespaces/profile.js';
|
|
16
|
+
import { LiveApi } from './namespaces/live.js';
|
|
17
|
+
import { DirectoryApi } from './namespaces/directory.js';
|
|
9
18
|
const DEFAULT_BASE_URL = 'https://api.pingroom.io';
|
|
10
19
|
/** Node's util.inspect hook, named through the global registry so no import is needed. */
|
|
11
20
|
const INSPECT = Symbol.for('nodejs.util.inspect.custom');
|
|
12
|
-
/** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
|
|
13
|
-
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
|
|
14
|
-
const MAX_TRIGGER_ATTACHMENTS = 4;
|
|
15
|
-
const QUICK_ACTION_PAGE_SIZE = 4;
|
|
16
|
-
const MAX_QUICK_ACTION_PAGES = 4;
|
|
17
|
-
/** Extra wall-clock allowance over the server's hold window for long-poll calls. */
|
|
18
|
-
const LONG_POLL_BUFFER_MS = 10_000;
|
|
19
|
-
const DEFAULT_WAIT_SECONDS = 20;
|
|
20
|
-
const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
|
|
21
|
-
/**
|
|
22
|
-
* Minimum spacing between the starts of two handoff-wait polls. The route is
|
|
23
|
-
* throttled at 30 requests a minute per human; a short or zero `timeout`
|
|
24
|
-
* would otherwise let one loop exhaust it.
|
|
25
|
-
*/
|
|
26
|
-
const MIN_HANDOFF_POLL_INTERVAL_MS = 2100;
|
|
27
|
-
/**
|
|
28
|
-
* Minimum spacing between the starts of two approval or question wait polls.
|
|
29
|
-
* The server normally holds each one, but a proxy or a zero hold that answers
|
|
30
|
-
* `pending` at once would otherwise spin the loop and keep the human's shared
|
|
31
|
-
* 30-per-minute wait bucket exhausted. Same floor as the CLI.
|
|
32
|
-
*/
|
|
33
|
-
const MIN_WAIT_POLL_INTERVAL_MS = 1000;
|
|
34
21
|
function envBaseUrl() {
|
|
35
22
|
const proc = globalThis.process;
|
|
36
23
|
return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
|
|
37
24
|
}
|
|
38
|
-
function enc(segment) {
|
|
39
|
-
// encodeURIComponent leaves `.` and `..` alone, and URL parsing then
|
|
40
|
-
// resolves them as dot segments: `live.get('AB12', '..')` would request the
|
|
41
|
-
// room itself. No id is empty or a dot segment, so refuse them.
|
|
42
|
-
if (segment === '' || segment === '.' || segment === '..') {
|
|
43
|
-
throw new PingRoomError('A path parameter may not be empty, `.` or `..`.', { code: 'invalid_request' });
|
|
44
|
-
}
|
|
45
|
-
return encodeURIComponent(segment);
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Lift the SDK-only `idempotencyKey` out of an input and into the
|
|
49
|
-
* `Idempotency-Key` header the server reads. The server trims the key and
|
|
50
|
-
* accepts 1–255 characters; as a header it must also be printable ASCII.
|
|
51
|
-
*/
|
|
52
|
-
function splitIdempotencyKey(input) {
|
|
53
|
-
const { idempotencyKey, ...body } = input;
|
|
54
|
-
if (idempotencyKey === undefined) {
|
|
55
|
-
return { body };
|
|
56
|
-
}
|
|
57
|
-
return { body, headers: { 'Idempotency-Key': normalizeIdempotencyKey(idempotencyKey) } };
|
|
58
|
-
}
|
|
59
|
-
/** `onRetry`, when given, must be callable: a TypeError mid-outage would hide the real failure. */
|
|
60
|
-
function assertOnRetry(onRetry) {
|
|
61
|
-
if (onRetry !== undefined && typeof onRetry !== 'function') {
|
|
62
|
-
throw new PingRoomError('`onRetry` must be a function.', { code: 'invalid_request' });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* The per-slot checks shared by `updateMany()` and `updateLayout()`: a valid,
|
|
67
|
-
* present, unique action_number and an icon whenever the slot has a label.
|
|
68
|
-
* Two entries for one slot would let the last silently win, and the caller
|
|
69
|
-
* would never learn which of its definitions was stored.
|
|
70
|
-
*/
|
|
71
|
-
function assertActionSlots(actions) {
|
|
72
|
-
const seen = new Set();
|
|
73
|
-
for (const action of actions) {
|
|
74
|
-
assertActionNumber(action.action_number);
|
|
75
|
-
if (action.action_number === undefined || action.action_number === null) {
|
|
76
|
-
throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
|
|
77
|
-
}
|
|
78
|
-
if (seen.has(action.action_number)) {
|
|
79
|
-
throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
|
|
80
|
-
}
|
|
81
|
-
seen.add(action.action_number);
|
|
82
|
-
assertQuickActionIcon(action);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function dropUndefined(obj) {
|
|
86
|
-
const out = {};
|
|
87
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
88
|
-
if (v !== undefined) {
|
|
89
|
-
out[k] = v;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return out;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Mirrors the server's `icon` rule (`present`, `required_with:label`): the emoji
|
|
96
|
-
* is the half that must be there unless BOTH are empty, which reserves the
|
|
97
|
-
* slot as disabled.
|
|
98
|
-
*/
|
|
99
|
-
function assertQuickActionIcon(action) {
|
|
100
|
-
requirePresentString(action.label, 'label');
|
|
101
|
-
requirePresentString(action.icon, 'icon');
|
|
102
|
-
if (action.icon.trim() === '' && action.label.trim() !== '') {
|
|
103
|
-
throw new PingRoomError('`icon` is required when `label` is set; send both empty to disable the slot.', {
|
|
104
|
-
code: 'invalid_request',
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
function assertTriggerInput(input) {
|
|
109
|
-
const data = input.data;
|
|
110
|
-
if (data !== undefined) {
|
|
111
|
-
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
112
|
-
throw new PingRoomError('`data` must be an object with `location` and/or `url`.', { code: 'invalid_request' });
|
|
113
|
-
}
|
|
114
|
-
for (const key of Object.keys(data)) {
|
|
115
|
-
if (key !== 'location' && key !== 'url') {
|
|
116
|
-
throw new PingRoomError(`\`data.${key}\` is not accepted on a quick-action press; only location and url are.`, {
|
|
117
|
-
code: 'invalid_request',
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
if (data.location !== undefined)
|
|
123
|
-
locationPing(data.location);
|
|
124
|
-
if (data.url !== undefined)
|
|
125
|
-
linkPing({ url: data.url });
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
throw new PingRoomError(error.message, { code: 'invalid_request' });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (input.attachment_ids !== undefined) {
|
|
132
|
-
if (!Array.isArray(input.attachment_ids) || input.attachment_ids.length > MAX_TRIGGER_ATTACHMENTS) {
|
|
133
|
-
throw new PingRoomError(`\`attachment_ids\` accepts at most ${MAX_TRIGGER_ATTACHMENTS} ids.`, { code: 'invalid_request' });
|
|
134
|
-
}
|
|
135
|
-
for (const id of input.attachment_ids)
|
|
136
|
-
requireNonEmpty(id, 'attachment_ids[]');
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function attachmentByteLength(content) {
|
|
140
|
-
if (content instanceof Blob)
|
|
141
|
-
return content.size;
|
|
142
|
-
if (typeof content === 'string')
|
|
143
|
-
return new TextEncoder().encode(content).byteLength;
|
|
144
|
-
return content.byteLength;
|
|
145
|
-
}
|
|
146
|
-
function assertPing(ping) {
|
|
147
|
-
requireNonEmpty(ping.message, 'message');
|
|
148
|
-
// The invite code does not expose room visibility. Permit the public-room
|
|
149
|
-
// ceiling locally; Laravel applies 120 for private and 160 for public rooms.
|
|
150
|
-
assertMaxLength(ping.message, MAX_PUBLIC_PING_MESSAGE_LENGTH, 'message');
|
|
151
|
-
assertMaxLength(ping.title, MAX_PING_TITLE_LENGTH, 'title');
|
|
152
|
-
assertActionNumber(ping.action_number);
|
|
153
|
-
assertStructuredData(ping.data);
|
|
154
|
-
}
|
|
155
|
-
function waitTimeoutMs(seconds) {
|
|
156
|
-
return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
|
|
157
|
-
}
|
|
158
|
-
function isRecord(value) {
|
|
159
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
160
|
-
}
|
|
161
|
-
function isNonEmptyString(value) {
|
|
162
|
-
return typeof value === 'string' && value.trim() !== '';
|
|
163
|
-
}
|
|
164
|
-
function isNullableString(value) {
|
|
165
|
-
return value === null || typeof value === 'string';
|
|
166
|
-
}
|
|
167
|
-
/** Drop a malformed or unsafe server-provided URL without rejecting its response. */
|
|
168
|
-
function normalizePublicHttpUrl(candidate) {
|
|
169
|
-
if (typeof candidate !== 'string' || candidate.trim() === '')
|
|
170
|
-
return undefined;
|
|
171
|
-
const trimmed = candidate.trim();
|
|
172
|
-
// eslint-disable-next-line no-control-regex
|
|
173
|
-
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed))
|
|
174
|
-
return undefined;
|
|
175
|
-
try {
|
|
176
|
-
const url = new URL(trimmed);
|
|
177
|
-
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
178
|
-
return undefined;
|
|
179
|
-
if (url.username !== '' || url.password !== '')
|
|
180
|
-
return undefined;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
return trimmed;
|
|
186
|
-
}
|
|
187
|
-
/** Drop malformed or unsafe server-provided links without rejecting a valid credential. */
|
|
188
|
-
function normalizePairingLinks(value) {
|
|
189
|
-
if (!isRecord(value))
|
|
190
|
-
return undefined;
|
|
191
|
-
const latestPings = normalizePublicHttpUrl(value['latest_pings']);
|
|
192
|
-
if (!latestPings)
|
|
193
|
-
return undefined;
|
|
194
|
-
const installApp = normalizePublicHttpUrl(value['install_app']);
|
|
195
|
-
return {
|
|
196
|
-
latest_pings: latestPings,
|
|
197
|
-
...(installApp ? { install_app: installApp } : {}),
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
function invalidInboxResponse(message, body = {}) {
|
|
201
|
-
throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
|
|
202
|
-
}
|
|
203
|
-
function validateInboxEnsure(value) {
|
|
204
|
-
if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
|
|
205
|
-
return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
|
|
206
|
-
}
|
|
207
|
-
const room = value['room'];
|
|
208
|
-
const question = value['question'];
|
|
209
|
-
if (!isRecord(room)
|
|
210
|
-
|| !isNonEmptyString(room['id'])
|
|
211
|
-
|| typeof room['name'] !== 'string'
|
|
212
|
-
|| !isNonEmptyString(room['invite_code'])
|
|
213
|
-
|| typeof room['is_agent_inbox'] !== 'boolean'
|
|
214
|
-
|| !isRecord(question)
|
|
215
|
-
|| !isNonEmptyString(question['id'])
|
|
216
|
-
|| question['kind'] !== 'question'
|
|
217
|
-
|| !isNonEmptyString(question['prompt'])
|
|
218
|
-
|| !Array.isArray(question['options'])
|
|
219
|
-
|| question['options'].some((option) => (!isRecord(option)
|
|
220
|
-
|| !isNonEmptyString(option['value'])
|
|
221
|
-
|| !isNonEmptyString(option['label'])))
|
|
222
|
-
|| (question['state'] !== 'pending'
|
|
223
|
-
&& question['state'] !== 'answered'
|
|
224
|
-
&& question['state'] !== 'expired'
|
|
225
|
-
&& question['state'] !== 'cancelled')
|
|
226
|
-
|| !isNullableString(question['expires_at'])
|
|
227
|
-
|| !isNullableString(question['created_at'])) {
|
|
228
|
-
return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
|
|
229
|
-
}
|
|
230
|
-
return value;
|
|
231
|
-
}
|
|
232
|
-
function validateInboxWait(value, expectedId) {
|
|
233
|
-
if (!isRecord(value)) {
|
|
234
|
-
return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
|
|
235
|
-
}
|
|
236
|
-
const state = value['state'];
|
|
237
|
-
if (!isNonEmptyString(value['id'])
|
|
238
|
-
|| value['id'] !== expectedId
|
|
239
|
-
|| value['kind'] !== 'question'
|
|
240
|
-
|| (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
|
|
241
|
-
|| (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
|
|
242
|
-
|| (state !== 'answered' && value['activation_completed'] === true)) {
|
|
243
|
-
return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
|
|
244
|
-
expected_id: expectedId,
|
|
245
|
-
received_id: typeof value['id'] === 'string' ? value['id'] : null,
|
|
246
|
-
kind: typeof value['kind'] === 'string' ? value['kind'] : null,
|
|
247
|
-
state: typeof state === 'string' ? state : null,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
if (state === 'answered') {
|
|
251
|
-
const answer = value['answer'];
|
|
252
|
-
if (!isRecord(answer)
|
|
253
|
-
|| !isNullableString(answer['value'])
|
|
254
|
-
|| !isNullableString(answer['label'])
|
|
255
|
-
|| !isNullableString(answer['text'])
|
|
256
|
-
|| (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
|
|
257
|
-
|| !isNullableString(answer['answered_at'])
|
|
258
|
-
|| (answer['responder'] !== null && !isRecord(answer['responder']))) {
|
|
259
|
-
return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
|
|
260
|
-
id: expectedId,
|
|
261
|
-
state,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
const responder = answer['responder'];
|
|
265
|
-
if (isRecord(responder)
|
|
266
|
-
&& (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))) {
|
|
267
|
-
return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
|
|
268
|
-
id: expectedId,
|
|
269
|
-
state,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
else if (value['answer'] !== undefined && value['answer'] !== null) {
|
|
274
|
-
return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
|
|
275
|
-
id: expectedId,
|
|
276
|
-
state,
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
return value;
|
|
280
|
-
}
|
|
281
|
-
/** UUIDs are case-insensitive; a caller may hold an id in either case. */
|
|
282
|
-
function sameId(received, expected) {
|
|
283
|
-
return typeof received === 'string' && received.toLowerCase() === expected.toLowerCase();
|
|
284
|
-
}
|
|
285
|
-
function invalidWaitResponse(subject, expectedId, value, stateField) {
|
|
286
|
-
throw new PingRoomError(`PingRoom returned an invalid ${subject} wait response.`, {
|
|
287
|
-
code: 'invalid_response',
|
|
288
|
-
body: {
|
|
289
|
-
expected_id: expectedId,
|
|
290
|
-
received_id: isRecord(value) && typeof value['id'] === 'string' ? value['id'] : null,
|
|
291
|
-
[stateField]: isRecord(value) && typeof value[stateField] === 'string' ? value[stateField] : null,
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
const APPROVAL_STATUSES = new Set(['pending', 'decided', 'expired', 'cancelled']);
|
|
296
|
-
const QUESTION_STATES = new Set(['pending', 'answered', 'expired', 'cancelled']);
|
|
297
|
-
const HANDOFF_STATES = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
|
|
298
|
-
/**
|
|
299
|
-
* A wait response is trusted as an outcome only when it is the approval that
|
|
300
|
-
* was asked about, in a known status. A captive portal's HTML, `{}`, or another
|
|
301
|
-
* approval must not read as "decided".
|
|
302
|
-
*/
|
|
303
|
-
function validateApprovalWait(value, approvalId) {
|
|
304
|
-
const status = isRecord(value) ? value['status'] : undefined;
|
|
305
|
-
if (!isRecord(value)
|
|
306
|
-
|| !sameId(value['id'], approvalId)
|
|
307
|
-
|| typeof status !== 'string'
|
|
308
|
-
|| !APPROVAL_STATUSES.has(status)
|
|
309
|
-
|| !isNullableString(value['decided_at'])
|
|
310
|
-
// A gate acts on `decision`; a decided approval without one, or a pending
|
|
311
|
-
// one already stamped decided, is not an outcome it can act on.
|
|
312
|
-
|| (status === 'decided' && !isNonEmptyString(value['decision']))
|
|
313
|
-
|| (status === 'pending' && value['decided_at'] !== null)) {
|
|
314
|
-
return invalidWaitResponse('approval', approvalId, value, 'status');
|
|
315
|
-
}
|
|
316
|
-
return value;
|
|
317
|
-
}
|
|
318
|
-
function validateQuestionWait(value, questionId) {
|
|
319
|
-
const state = isRecord(value) ? value['state'] : undefined;
|
|
320
|
-
if (!isRecord(value) || !sameId(value['id'], questionId) || typeof state !== 'string' || !QUESTION_STATES.has(state)) {
|
|
321
|
-
return invalidWaitResponse('question', questionId, value, 'state');
|
|
322
|
-
}
|
|
323
|
-
return value;
|
|
324
|
-
}
|
|
325
|
-
function validateHandoffWait(value, handoffId) {
|
|
326
|
-
const state = isRecord(value) ? value['state'] : undefined;
|
|
327
|
-
if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
|
|
328
|
-
return invalidWaitResponse('handoff', handoffId, value, 'state');
|
|
329
|
-
}
|
|
330
|
-
return value;
|
|
331
|
-
}
|
|
332
|
-
// --- namespaces -----------------------------------------------------------
|
|
333
|
-
/** Agent credential lifecycle (auth.md flow). */
|
|
334
|
-
class AuthApi {
|
|
335
|
-
http;
|
|
336
|
-
constructor(http) {
|
|
337
|
-
this.http = http;
|
|
338
|
-
}
|
|
339
|
-
/** Register an agent. Anonymous or via an identity assertion (ID-JAG / verified email). Public. */
|
|
340
|
-
register(params) {
|
|
341
|
-
requireNonEmpty(params.type, 'type');
|
|
342
|
-
return this.http.request('POST', '/api/agent/auth', { auth: false, body: dropUndefined({ ...params }) });
|
|
343
|
-
}
|
|
344
|
-
/** Begin claiming a pre-claim credential onto a human account; emails an OTP. */
|
|
345
|
-
claimStart(params) {
|
|
346
|
-
requireNonEmpty(params.email, 'email');
|
|
347
|
-
return this.http.request('POST', '/api/agent/auth/claim/start', { body: { email: params.email } });
|
|
348
|
-
}
|
|
349
|
-
/** Complete the claim with the emailed OTP; returns an active credential + handle. */
|
|
350
|
-
claimComplete(params) {
|
|
351
|
-
requireNonEmpty(params.email, 'email');
|
|
352
|
-
requireNonEmpty(params.otp, 'otp');
|
|
353
|
-
return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Register a pending agent and create a short-lived link that opens the
|
|
357
|
-
* PingRoom app. The server owns the full grant shown to the approver; this
|
|
358
|
-
* client sends no scope list. The pending credential stays private inside the
|
|
359
|
-
* client and is replaced automatically once waitForPairing() succeeds.
|
|
360
|
-
*/
|
|
361
|
-
async startPairing(params = {}) {
|
|
362
|
-
const previousToken = this.http.getToken();
|
|
363
|
-
const pending = await this.register({
|
|
364
|
-
type: 'anonymous',
|
|
365
|
-
...(params.agent_label ? { agent_label: params.agent_label } : {}),
|
|
366
|
-
});
|
|
367
|
-
this.http.setToken(pending.credential);
|
|
368
|
-
try {
|
|
369
|
-
return await this.http.request('POST', '/api/agent/auth/pair/start', {
|
|
370
|
-
body: {},
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
catch (error) {
|
|
374
|
-
if (this.http.getToken() === pending.credential) {
|
|
375
|
-
this.http.setToken(previousToken);
|
|
376
|
-
}
|
|
377
|
-
throw error;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
/** Read the current state of an app pairing using the pending credential. */
|
|
381
|
-
pairingStatus(options = {}) {
|
|
382
|
-
return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Wait until the person approves in PingRoom, or the short-lived link
|
|
386
|
-
* expires. Transient network, 429, and 5xx failures are retried inside the
|
|
387
|
-
* server-provided lifetime. On success the client immediately starts using
|
|
388
|
-
* the active credential returned by the server.
|
|
389
|
-
*/
|
|
390
|
-
async waitForPairing(pairing, options = {}) {
|
|
391
|
-
const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
|
|
392
|
-
const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
|
|
393
|
-
const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
|
|
394
|
-
const startedAt = Date.now();
|
|
395
|
-
const deadline = startedAt + lifetimeMs;
|
|
396
|
-
let transientFailures = 0;
|
|
397
|
-
while (Date.now() < deadline) {
|
|
398
|
-
throwIfAborted(options.signal);
|
|
399
|
-
try {
|
|
400
|
-
const status = await this.pairingStatus({ signal: options.signal });
|
|
401
|
-
throwIfAborted(options.signal);
|
|
402
|
-
transientFailures = 0;
|
|
403
|
-
if (status.status === 'active') {
|
|
404
|
-
if (typeof status.credential !== 'string'
|
|
405
|
-
|| status.credential.trim() === ''
|
|
406
|
-
|| status.credential_type !== 'active'
|
|
407
|
-
|| (status.expires_in !== null
|
|
408
|
-
&& (typeof status.expires_in !== 'number'
|
|
409
|
-
|| !Number.isFinite(status.expires_in)
|
|
410
|
-
|| status.expires_in < 0))
|
|
411
|
-
// A null/absent `room` is valid when the account has no eligible
|
|
412
|
-
// private delivery room (and on older servers). Only a room that is
|
|
413
|
-
// present but malformed is a broken response.
|
|
414
|
-
|| (status.room != null
|
|
415
|
-
&& (typeof status.room.invite_code !== 'string'
|
|
416
|
-
|| status.room.invite_code.trim() === ''))) {
|
|
417
|
-
throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
|
|
418
|
-
code: 'pairing_invalid_response',
|
|
419
|
-
body: { status: status.status },
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
this.http.setToken(status.credential);
|
|
423
|
-
// Pairing is server-authoritative. Some servers still include their
|
|
424
|
-
// internal grant marker for protocol compatibility; it is neither a
|
|
425
|
-
// client input nor part of the paired credential surface.
|
|
426
|
-
const { status: _status, scopes: _serverScopes, links: rawLinks, ...credential } = status;
|
|
427
|
-
const links = normalizePairingLinks(rawLinks);
|
|
428
|
-
return links ? { ...credential, links } : credential;
|
|
429
|
-
}
|
|
430
|
-
if (status.status === 'expired') {
|
|
431
|
-
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
432
|
-
code: 'pairing_expired',
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
catch (error) {
|
|
437
|
-
if (error instanceof PingRoomError
|
|
438
|
-
&& [401, 403, 404].includes(error.status)
|
|
439
|
-
&& Date.now() - startedAt >= lifetimeMs / 2) {
|
|
440
|
-
// The pending bearer can expire before the server returns an
|
|
441
|
-
// explicit expired pairing state. Keep early auth failures intact.
|
|
442
|
-
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
443
|
-
code: 'pairing_expired',
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
if (error instanceof PingRoomError
|
|
447
|
-
&& (error.code === 'network_error'
|
|
448
|
-
|| error.code === 'timeout'
|
|
449
|
-
|| error.status === 429
|
|
450
|
-
|| error.status >= 500)) {
|
|
451
|
-
transientFailures += 1;
|
|
452
|
-
const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
|
|
453
|
-
await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
throw error;
|
|
457
|
-
}
|
|
458
|
-
await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
|
|
459
|
-
}
|
|
460
|
-
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
461
|
-
code: 'pairing_expired',
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
/** Rotate to a fresh credential (same scopes/handle). */
|
|
465
|
-
refresh() {
|
|
466
|
-
return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
|
|
467
|
-
}
|
|
468
|
-
/** Revoke the current credential. */
|
|
469
|
-
revoke() {
|
|
470
|
-
return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* What this credential is connected to: the robot, the owner's public id and
|
|
474
|
-
* name, the home room and room memberships, and the room grant. Check
|
|
475
|
-
* `owner.id` and `handle` against the account and robot you approved before
|
|
476
|
-
* sending anything.
|
|
477
|
-
*/
|
|
478
|
-
connection() {
|
|
479
|
-
return this.http.request('GET', '/api/agent/connection');
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* Disconnect this agent: revokes the calling credential and its refresh
|
|
483
|
-
* credentials. The client keeps the now-revoked token; clear it with
|
|
484
|
-
* `setToken(null)` or pair again.
|
|
485
|
-
*/
|
|
486
|
-
disconnect() {
|
|
487
|
-
return this.http.request('POST', '/api/agent/disconnect', { body: {} });
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
class RoomsApi {
|
|
491
|
-
http;
|
|
492
|
-
constructor(http) {
|
|
493
|
-
this.http = http;
|
|
494
|
-
}
|
|
495
|
-
icons() {
|
|
496
|
-
return this.http.request('GET', '/api/agent/room-icons');
|
|
497
|
-
}
|
|
498
|
-
list() {
|
|
499
|
-
return this.http.request('GET', '/api/agent/rooms');
|
|
500
|
-
}
|
|
501
|
-
get(inviteCode) {
|
|
502
|
-
return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}`);
|
|
503
|
-
}
|
|
504
|
-
create(input) {
|
|
505
|
-
requireNonEmpty(input.name, 'name');
|
|
506
|
-
return this.http.request('POST', '/api/agent/rooms', { body: dropUndefined({ ...input }) });
|
|
507
|
-
}
|
|
508
|
-
createPublic(input) {
|
|
509
|
-
requireNonEmpty(input.name, 'name');
|
|
510
|
-
requireNonEmpty(input.handle, 'handle');
|
|
511
|
-
// The server takes the location trio all-or-nothing; a partial set is a
|
|
512
|
-
// 422 on a field the caller may not have meant to send at all.
|
|
513
|
-
const present = [input.location_name, input.location_latitude, input.location_longitude].filter((v) => v !== undefined).length;
|
|
514
|
-
if (present !== 0 && present !== 3) {
|
|
515
|
-
throw new PingRoomError('Send `location_name`, `location_latitude` and `location_longitude` together, or none of them.', {
|
|
516
|
-
code: 'invalid_request',
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
return this.http.request('POST', '/api/agent/rooms/public', { body: dropUndefined({ ...input }) });
|
|
520
|
-
}
|
|
521
|
-
join(input) {
|
|
522
|
-
requireNonEmpty(input.invite_code, 'invite_code');
|
|
523
|
-
return this.http.request('POST', '/api/agent/rooms/join', { body: dropUndefined({ ...input }) });
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
class ActionsApi {
|
|
527
|
-
http;
|
|
528
|
-
constructor(http) {
|
|
529
|
-
this.http = http;
|
|
530
|
-
}
|
|
531
|
-
list(inviteCode) {
|
|
532
|
-
return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/actions`);
|
|
533
|
-
}
|
|
534
|
-
update(inviteCode, actionNumber, input) {
|
|
535
|
-
assertActionNumber(actionNumber);
|
|
536
|
-
// A Ping's title is optional — its emoji can be the whole name — so an
|
|
537
|
-
// empty `label` is a valid update. `icon` is the half that must be there,
|
|
538
|
-
// unless both are empty: that reserves the slot as disabled.
|
|
539
|
-
assertQuickActionIcon(input);
|
|
540
|
-
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}`, {
|
|
541
|
-
body: dropUndefined({ ...input }),
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* Write up to 16 action slots in one request. The server enforces the room owner's plan.
|
|
546
|
-
* Add new pages in complete groups of four; existing slots may be edited individually.
|
|
547
|
-
*
|
|
548
|
-
* Prefer this over looping `update()`. Each single-slot write enqueues its
|
|
549
|
-
* own silent rooms-refresh push, so configuring a room's four Pings one at a
|
|
550
|
-
* time wakes room members' devices four times inside a few seconds — four of a
|
|
551
|
-
* finite daily background-push budget spent on one logical operation.
|
|
552
|
-
*
|
|
553
|
-
* Partial and additive: slots absent from `actions` keep their current
|
|
554
|
-
* configuration, so this is also the right call for editing a single Ping.
|
|
555
|
-
* Nothing here deletes an action, and an empty array is rejected locally
|
|
556
|
-
* rather than being sent as a clear. Resolves to the room's full ordered
|
|
557
|
-
* action set, not just the slots written.
|
|
558
|
-
*/
|
|
559
|
-
updateMany(inviteCode, actions) {
|
|
560
|
-
if (!Array.isArray(actions) || actions.length === 0) {
|
|
561
|
-
throw new PingRoomError('`actions` must contain at least one action.', { code: 'invalid_request' });
|
|
562
|
-
}
|
|
563
|
-
if (actions.length > 16) {
|
|
564
|
-
throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
|
|
565
|
-
}
|
|
566
|
-
assertActionSlots(actions);
|
|
567
|
-
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
|
|
568
|
-
body: { actions: actions.map((action) => dropUndefined({ ...action })) },
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Press a slot. When the action has an `input_type`, pass the matching
|
|
573
|
-
* detail (`data.location`, `data.url`, or `attachment_ids`) or the server
|
|
574
|
-
* refuses the press with `422 quick_action_input_required` and sends nothing.
|
|
575
|
-
* Read `input_type` from `list()` first; pass the action's `id` as
|
|
576
|
-
* `quick_action_id` to be refused rather than fire the wrong Ping if the
|
|
577
|
-
* owner moved pages meanwhile.
|
|
578
|
-
*/
|
|
579
|
-
trigger(inviteCode, actionNumber, input = {}) {
|
|
580
|
-
assertActionNumber(actionNumber);
|
|
581
|
-
assertTriggerInput(input);
|
|
582
|
-
const { body, headers } = splitIdempotencyKey(input);
|
|
583
|
-
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
|
|
584
|
-
body: dropUndefined({ ...body }),
|
|
585
|
-
...(headers ? { headers } : {}),
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Replace the room's page layout atomically (room owner on Pro only). See
|
|
590
|
-
* `UpdateQuickActionLayoutInput` for the snapshot contract; a stale
|
|
591
|
-
* `base_action_ids` is `409 quick_action_layout_changed`, a page still used
|
|
592
|
-
* by a time trigger, webhook, agent binding or running live update is
|
|
593
|
-
* `409 quick_action_page_in_use`. Prefer `deletePage()`, which builds the
|
|
594
|
-
* body from a fresh read.
|
|
595
|
-
*/
|
|
596
|
-
updateLayout(inviteCode, input) {
|
|
597
|
-
if (!Array.isArray(input.base_action_ids)) {
|
|
598
|
-
throw new PingRoomError('`base_action_ids` must be an array of action ids (may be empty).', { code: 'invalid_request' });
|
|
599
|
-
}
|
|
600
|
-
if (!Array.isArray(input.page_order) || input.page_order.length < 1 || input.page_order.length > MAX_QUICK_ACTION_PAGES) {
|
|
601
|
-
throw new PingRoomError(`\`page_order\` must list 1–${MAX_QUICK_ACTION_PAGES} pages.`, { code: 'invalid_request' });
|
|
602
|
-
}
|
|
603
|
-
for (const page of input.page_order) {
|
|
604
|
-
if (page !== null && (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES)) {
|
|
605
|
-
throw new PingRoomError('`page_order` entries must be an original page number 1–4 or null for a blank page.', {
|
|
606
|
-
code: 'invalid_request',
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
const expected = input.page_order.length * QUICK_ACTION_PAGE_SIZE;
|
|
611
|
-
if (!Array.isArray(input.actions) || input.actions.length !== expected) {
|
|
612
|
-
throw new PingRoomError(`\`actions\` must hold exactly ${expected} slots for ${input.page_order.length} page(s).`, {
|
|
613
|
-
code: 'invalid_request',
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
assertActionSlots(input.actions);
|
|
617
|
-
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
|
|
618
|
-
body: {
|
|
619
|
-
base_action_ids: input.base_action_ids,
|
|
620
|
-
page_order: input.page_order,
|
|
621
|
-
actions: input.actions.map((action) => dropUndefined({ ...action })),
|
|
622
|
-
},
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Delete one page (1–4) and shift the later pages left, keeping every other
|
|
627
|
-
* slot's configuration. Reads the current action set first so the layout
|
|
628
|
-
* snapshot is real, then calls `updateLayout()`. Page 1 can be deleted only
|
|
629
|
-
* when another page remains.
|
|
630
|
-
*/
|
|
631
|
-
async deletePage(inviteCode, page) {
|
|
632
|
-
if (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES) {
|
|
633
|
-
throw new PingRoomError('`page` must be an integer 1–4.', { code: 'invalid_request' });
|
|
634
|
-
}
|
|
635
|
-
const current = await this.list(inviteCode);
|
|
636
|
-
const stored = [...current].sort((a, b) => a.action_number - b.action_number);
|
|
637
|
-
const pageCount = Math.max(1, Math.ceil((stored.at(-1)?.action_number ?? 0) / QUICK_ACTION_PAGE_SIZE));
|
|
638
|
-
if (page > pageCount) {
|
|
639
|
-
throw new PingRoomError(`The room has ${pageCount} page(s); page ${page} does not exist.`, { code: 'invalid_request' });
|
|
640
|
-
}
|
|
641
|
-
if (pageCount === 1) {
|
|
642
|
-
throw new PingRoomError('A room keeps at least one page; configure its slots instead of deleting it.', {
|
|
643
|
-
code: 'invalid_request',
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
const keep = Array.from({ length: pageCount }, (_, i) => i + 1).filter((p) => p !== page);
|
|
647
|
-
const byNumber = new Map(stored.map((a) => [a.action_number, a]));
|
|
648
|
-
const actions = [];
|
|
649
|
-
keep.forEach((originalPage, index) => {
|
|
650
|
-
for (let offset = 1; offset <= QUICK_ACTION_PAGE_SIZE; offset++) {
|
|
651
|
-
const source = byNumber.get((originalPage - 1) * QUICK_ACTION_PAGE_SIZE + offset);
|
|
652
|
-
actions.push({
|
|
653
|
-
action_number: index * QUICK_ACTION_PAGE_SIZE + offset,
|
|
654
|
-
label: source?.label ?? '',
|
|
655
|
-
icon: source?.icon ?? '',
|
|
656
|
-
...(source?.sound !== undefined ? { sound: source.sound } : {}),
|
|
657
|
-
...(source?.haptic_style !== undefined ? { haptic_style: source.haptic_style } : {}),
|
|
658
|
-
...(source?.requires_ack !== undefined ? { requires_ack: source.requires_ack } : {}),
|
|
659
|
-
...(source?.input_type !== undefined ? { input_type: source.input_type } : {}),
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
return this.updateLayout(inviteCode, {
|
|
664
|
-
base_action_ids: stored.map((a) => a.id).filter((id) => typeof id === 'string'),
|
|
665
|
-
page_order: keep,
|
|
666
|
-
actions,
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
class WebhooksApi {
|
|
671
|
-
http;
|
|
672
|
-
constructor(http) {
|
|
673
|
-
this.http = http;
|
|
674
|
-
}
|
|
675
|
-
async list(inviteCode) {
|
|
676
|
-
const result = await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/webhooks`);
|
|
677
|
-
return result.webhooks;
|
|
678
|
-
}
|
|
679
|
-
create(inviteCode, input) {
|
|
680
|
-
requireNonEmpty(input.name, 'name');
|
|
681
|
-
assertActionNumber(input.action_number);
|
|
682
|
-
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/webhooks`, {
|
|
683
|
-
body: dropUndefined({ ...input }),
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
update(inviteCode, webhookId, input) {
|
|
687
|
-
requireNonEmpty(webhookId, 'webhookId');
|
|
688
|
-
assertActionNumber(input.action_number);
|
|
689
|
-
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`, {
|
|
690
|
-
body: dropUndefined({ ...input }),
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
async delete(inviteCode, webhookId) {
|
|
694
|
-
requireNonEmpty(webhookId, 'webhookId');
|
|
695
|
-
await this.http.request('DELETE', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
class NotificationsApi {
|
|
699
|
-
http;
|
|
700
|
-
constructor(http) {
|
|
701
|
-
this.http = http;
|
|
702
|
-
}
|
|
703
|
-
/** Paginated list (polling fallback). Prefer wait()/listen() for real-time. */
|
|
704
|
-
async list(input = {}) {
|
|
705
|
-
const { notifications_per_page, ...query } = input;
|
|
706
|
-
const page = await this.http.request('GET', '/api/agent/notifications', {
|
|
707
|
-
query: dropUndefined({ ...query, limit: input.limit ?? notifications_per_page }),
|
|
708
|
-
});
|
|
709
|
-
return {
|
|
710
|
-
...page,
|
|
711
|
-
data: page.data.map(({ room, ...notification }) => ({
|
|
712
|
-
...notification,
|
|
713
|
-
...(room ? { room: { ...room, code: room.invite_code } } : {}),
|
|
714
|
-
})),
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
/** Fetch one visible notification, including its current acknowledgement state. */
|
|
718
|
-
getNotification(notificationId) {
|
|
719
|
-
requireNonEmpty(notificationId, 'notificationId');
|
|
720
|
-
return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* One bounded long-poll for an ack-required ping. The returned state may
|
|
724
|
-
* still be `open` when the hold timeout elapses; call again to keep waiting.
|
|
725
|
-
*/
|
|
726
|
-
waitForAcknowledgement(notificationId, input = {}) {
|
|
727
|
-
requireNonEmpty(notificationId, 'notificationId');
|
|
728
|
-
return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
|
|
729
|
-
query: dropUndefined({ timeout: input.timeout }),
|
|
730
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
731
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
/** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
|
|
735
|
-
wait(input = {}) {
|
|
736
|
-
return this.http.request('GET', '/api/agent/notifications/wait', {
|
|
737
|
-
query: dropUndefined({ after: input.after, timeout: input.timeout, limit: input.limit }),
|
|
738
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
739
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
/**
|
|
743
|
-
* Continuous real-time inbound: an async iterator that long-polls in a loop,
|
|
744
|
-
* advancing the cursor, yielding each new ping. Stops when `signal` aborts.
|
|
745
|
-
*
|
|
746
|
-
* for await (const ping of pr.notifications.listen({ signal })) { ... }
|
|
747
|
-
*/
|
|
748
|
-
async *listen(input = {}) {
|
|
749
|
-
let cursor = input.after;
|
|
750
|
-
let headRead = cursor !== undefined;
|
|
751
|
-
const backoffMs = input.backoffMs ?? 1000;
|
|
752
|
-
let failures = 0;
|
|
753
|
-
while (!input.signal?.aborted) {
|
|
754
|
-
let result;
|
|
755
|
-
try {
|
|
756
|
-
result = headRead
|
|
757
|
-
? await this.wait({
|
|
758
|
-
after: cursor,
|
|
759
|
-
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
|
|
760
|
-
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
761
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
762
|
-
})
|
|
763
|
-
// No cursor yet: read the current head (an immediate, empty answer)
|
|
764
|
-
// so only pings that land after this call are delivered.
|
|
765
|
-
: await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) });
|
|
766
|
-
}
|
|
767
|
-
catch (err) {
|
|
768
|
-
if (input.signal?.aborted) {
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
// A revoked credential or a bad request never heals; only network
|
|
772
|
-
// failures, deadlines, 429 and 5xx are retried.
|
|
773
|
-
if (!isTransientError(err)) {
|
|
774
|
-
throw err;
|
|
775
|
-
}
|
|
776
|
-
failures += 1;
|
|
777
|
-
input.onError?.(err);
|
|
778
|
-
await sleep(transientDelayMs(err, failures, backoffMs), input.signal);
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
failures = 0;
|
|
782
|
-
headRead = true;
|
|
783
|
-
cursor = result.cursor ?? cursor;
|
|
784
|
-
for (const n of result.notifications) {
|
|
785
|
-
yield n;
|
|
786
|
-
}
|
|
787
|
-
// Yield between polls so timer-driven cancellation can run (see ApprovalsApi).
|
|
788
|
-
await sleep(0, input.signal);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
class AgentsApi {
|
|
793
|
-
http;
|
|
794
|
-
constructor(http) {
|
|
795
|
-
this.http = http;
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* @deprecated Retired server-side — always fails with 410
|
|
799
|
-
* `cross_account_ping_retired`. An agent can only reach the account that
|
|
800
|
-
* connected it; to reach anyone else, post into a room you both belong to.
|
|
801
|
-
*/
|
|
802
|
-
ping(handle, ping) {
|
|
803
|
-
requireNonEmpty(handle, 'handle');
|
|
804
|
-
assertPing(ping);
|
|
805
|
-
const { body, headers } = splitIdempotencyKey(ping);
|
|
806
|
-
return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, {
|
|
807
|
-
body: dropUndefined({ ...body }),
|
|
808
|
-
...(headers ? { headers } : {}),
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
class ApprovalsApi {
|
|
813
|
-
http;
|
|
814
|
-
constructor(http) {
|
|
815
|
-
this.http = http;
|
|
816
|
-
}
|
|
817
|
-
/** Ask the human you act for to decide; returns the pending approval. */
|
|
818
|
-
request(inviteCode, input) {
|
|
819
|
-
requireNonEmpty(input.question, 'question');
|
|
820
|
-
assertStructuredData(input.data);
|
|
821
|
-
const { body, headers } = splitIdempotencyKey(input);
|
|
822
|
-
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
|
|
823
|
-
body: dropUndefined({ ...body }),
|
|
824
|
-
...(headers ? { headers } : {}),
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
get(approvalId) {
|
|
828
|
-
return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}`);
|
|
829
|
-
}
|
|
830
|
-
/** One long-poll for the decision; returns the (possibly still-pending) approval. */
|
|
831
|
-
wait(approvalId, input = {}) {
|
|
832
|
-
return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}/wait`, {
|
|
833
|
-
query: dropUndefined({ timeout: input.timeout }),
|
|
834
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
835
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* Block until the human decides (or the request expires or is cancelled),
|
|
840
|
-
* looping wait() under the hood, with polls started at least 1 s apart. A
|
|
841
|
-
* response that is not this approval in a known status rejects with
|
|
842
|
-
* `invalid_response`. Network failures, 429 and 5xx are retried with backoff
|
|
843
|
-
* (honouring `retryAfter`), and reported to `onRetry`, until `signal` aborts
|
|
844
|
-
* or the outage outlasts the approval's `expires_at` by 5 minutes (15 minutes
|
|
845
|
-
* of failures while no `expires_at` has been read); that rejects with
|
|
846
|
-
* `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
847
|
-
*/
|
|
848
|
-
async waitForDecision(approvalId, input = {}) {
|
|
849
|
-
requireNonEmpty(approvalId, 'approvalId');
|
|
850
|
-
assertOnRetry(input.onRetry);
|
|
851
|
-
return pollUntilSettled({
|
|
852
|
-
poll: () => this.wait(approvalId, input.signal ? { signal: input.signal } : {}),
|
|
853
|
-
validate: (value) => validateApprovalWait(value, approvalId),
|
|
854
|
-
settled: (approval) => approval.status !== 'pending',
|
|
855
|
-
expiresAt: (approval) => approval.expires_at,
|
|
856
|
-
what: `approval ${approvalId}`,
|
|
857
|
-
minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
|
|
858
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
859
|
-
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
/**
|
|
864
|
-
* The general human-in-the-loop primitive: ask a human a question with 2–4
|
|
865
|
-
* options (or a short typed answer) and block until they tap an answer. An
|
|
866
|
-
* {@link ApprovalsApi | approval} is the two-option case; reach for questions
|
|
867
|
-
* when you need more choices, a typed reply, or room-scope answering.
|
|
868
|
-
*/
|
|
869
|
-
class QuestionsApi {
|
|
870
|
-
http;
|
|
871
|
-
constructor(http) {
|
|
872
|
-
this.http = http;
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Ask a Question in a room; returns the pending Question (id + computed
|
|
876
|
-
* expiry). Pass `resolution_policy: 'all'` to poll the room: everyone asked
|
|
877
|
-
* votes once, and `results` carries the tally on every read.
|
|
878
|
-
*/
|
|
879
|
-
ask(inviteCode, input) {
|
|
880
|
-
requireNonEmpty(input.prompt, 'prompt');
|
|
881
|
-
assertStructuredData(input.data);
|
|
882
|
-
assertPollShape(input);
|
|
883
|
-
const { body, headers } = splitIdempotencyKey(input);
|
|
884
|
-
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/questions`, {
|
|
885
|
-
body: dropUndefined(body),
|
|
886
|
-
...(headers ? { headers } : {}),
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
/** Fetch a Question's current state. */
|
|
890
|
-
get(questionId) {
|
|
891
|
-
return this.http.request('GET', `/api/agent/questions/${enc(questionId)}`);
|
|
892
|
-
}
|
|
893
|
-
/** One long-poll for the answer; returns the (possibly still-pending) Question. */
|
|
894
|
-
wait(questionId, input = {}) {
|
|
895
|
-
return this.http.request('GET', `/api/agent/questions/${enc(questionId)}/wait`, {
|
|
896
|
-
query: dropUndefined({ timeout: input.timeout }),
|
|
897
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
898
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* Block until the human answers (or the question expires/cancels), looping
|
|
903
|
-
* wait() under the hood. Returns the resolved Question — inspect `state` and
|
|
904
|
-
* `answer.value` to branch. A poll returns once it closes; its `answer` is the
|
|
905
|
-
* leading option, or `null` on a tie, and `results` holds the tally.
|
|
906
|
-
*
|
|
907
|
-
* Polls start at least 1 s apart. A response that is not this Question in a
|
|
908
|
-
* known state rejects with `invalid_response`. Network failures, 429 and 5xx
|
|
909
|
-
* are retried with backoff (honouring `retryAfter`), and reported to
|
|
910
|
-
* `onRetry`, until `signal` aborts or the outage outlasts the Question's
|
|
911
|
-
* `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
|
|
912
|
-
* been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
913
|
-
*/
|
|
914
|
-
async waitForAnswer(questionId, input = {}) {
|
|
915
|
-
requireNonEmpty(questionId, 'questionId');
|
|
916
|
-
assertOnRetry(input.onRetry);
|
|
917
|
-
return pollUntilSettled({
|
|
918
|
-
poll: () => this.wait(questionId, input.signal ? { signal: input.signal } : {}),
|
|
919
|
-
validate: (value) => validateQuestionWait(value, questionId),
|
|
920
|
-
settled: (question) => question.state !== 'pending',
|
|
921
|
-
expiresAt: (question) => question.expires_at,
|
|
922
|
-
what: `question ${questionId}`,
|
|
923
|
-
minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
|
|
924
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
925
|
-
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
/** List the asker's Questions, optionally filtered by state. */
|
|
929
|
-
async list(input = {}) {
|
|
930
|
-
const res = await this.http.request('GET', '/api/agent/questions', {
|
|
931
|
-
query: dropUndefined({ state: input.state }),
|
|
932
|
-
});
|
|
933
|
-
return res.questions ?? [];
|
|
934
|
-
}
|
|
935
|
-
/** Withdraw a still-pending Question (409 if it already resolved). */
|
|
936
|
-
cancel(questionId) {
|
|
937
|
-
return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
/**
|
|
941
|
-
* A poll asks the whole room and counts option votes, so the server refuses it
|
|
942
|
-
* (422 on `resolution_policy`) alongside a direct scope, a named target, or a
|
|
943
|
-
* typed answer. Fail before the request instead.
|
|
944
|
-
*/
|
|
945
|
-
function assertPollShape(input) {
|
|
946
|
-
if (input.resolution_policy !== 'all')
|
|
947
|
-
return;
|
|
948
|
-
const target = input.target_user_id;
|
|
949
|
-
if (input.responder_scope === 'direct'
|
|
950
|
-
|| (target !== undefined && target !== null && target !== '')
|
|
951
|
-
|| (input.text_input !== undefined && input.text_input !== null)) {
|
|
952
|
-
throw new PingRoomError("A poll (`resolution_policy: 'all'`) asks the whole room with options only; it cannot use `responder_scope: 'direct'`, `target_user_id`, or `text_input`.", { code: 'invalid_request' });
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
/** A bare string option is shorthand for `{ value, label: value }`. */
|
|
956
|
-
function normalizeHandoffOptions(options) {
|
|
957
|
-
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
958
|
-
}
|
|
959
|
-
/** Terminal states never change again — the wait loop can stop as soon as one lands. */
|
|
960
|
-
function isHandoffTerminal(handoff) {
|
|
961
|
-
const s = handoff.state;
|
|
962
|
-
return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
|
|
966
|
-
* human) and PRIVATE (no other room member can read it). Backed by the ack and
|
|
967
|
-
* question primitives, so its {@link Handoff.state | state} space is their union
|
|
968
|
-
* — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
|
|
969
|
-
* question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
|
|
970
|
-
*
|
|
971
|
-
* const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
|
|
972
|
-
* const result = await pr.handoffs.waitForResult(task.id);
|
|
973
|
-
*
|
|
974
|
-
* const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
|
|
975
|
-
*/
|
|
976
|
-
class HandoffsApi {
|
|
977
|
-
http;
|
|
978
|
-
constructor(http) {
|
|
979
|
-
this.http = http;
|
|
980
|
-
}
|
|
981
|
-
/** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
|
|
982
|
-
requestAck(input) {
|
|
983
|
-
requireNonEmpty(input.prompt, 'prompt');
|
|
984
|
-
assertStructuredData(input.data);
|
|
985
|
-
return this.create('ack', input);
|
|
986
|
-
}
|
|
987
|
-
/**
|
|
988
|
-
* Ask a human a multi-option question (a `question` handoff). Resolves on the
|
|
989
|
-
* first tapped answer. Bare-string options normalize to `{ value, label }`.
|
|
990
|
-
*/
|
|
991
|
-
ask(input) {
|
|
992
|
-
requireNonEmpty(input.prompt, 'prompt');
|
|
993
|
-
assertStructuredData(input.data);
|
|
994
|
-
if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
|
|
995
|
-
throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
|
|
996
|
-
code: 'invalid_request',
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
return this.create('question', input, normalizeHandoffOptions(input.options));
|
|
1000
|
-
}
|
|
1001
|
-
/** Fetch a handoff's current state. */
|
|
1002
|
-
get(id) {
|
|
1003
|
-
requireNonEmpty(id, 'id');
|
|
1004
|
-
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* Block until the handoff resolves (or expires/cancels), looping the bounded
|
|
1008
|
-
* long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
|
|
1009
|
-
* `hold`) all surface as normal terminal results — nothing is thrown for them.
|
|
1010
|
-
*
|
|
1011
|
-
* Polls start at least 2.1 s apart, so a short `timeout` stays under the
|
|
1012
|
-
* route's 30-per-minute limit. A response that is not this handoff in a
|
|
1013
|
-
* known state rejects with `invalid_response`. Network failures, 429 and 5xx
|
|
1014
|
-
* are retried with backoff (honouring `retryAfter`), and reported to
|
|
1015
|
-
* `onRetry`, until `signal` aborts or the outage outlasts the handoff's
|
|
1016
|
-
* `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
|
|
1017
|
-
* been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
1018
|
-
*/
|
|
1019
|
-
async waitForResult(id, input = {}) {
|
|
1020
|
-
requireNonEmpty(id, 'id');
|
|
1021
|
-
assertOnRetry(input.onRetry);
|
|
1022
|
-
return pollUntilSettled({
|
|
1023
|
-
poll: () => this.wait(id, input),
|
|
1024
|
-
validate: (value) => validateHandoffWait(value, id),
|
|
1025
|
-
settled: isHandoffTerminal,
|
|
1026
|
-
expiresAt: (handoff) => handoff.expires_at,
|
|
1027
|
-
what: `handoff ${id}`,
|
|
1028
|
-
minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
|
|
1029
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
1030
|
-
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
/** List the agent's open handoffs or bounded recent history. */
|
|
1034
|
-
async list(input = {}) {
|
|
1035
|
-
const res = await this.http.request('GET', '/api/agent/handoffs', {
|
|
1036
|
-
query: dropUndefined({ state: input.state }),
|
|
1037
|
-
});
|
|
1038
|
-
return res.handoffs ?? [];
|
|
1039
|
-
}
|
|
1040
|
-
/** One bounded long-poll; returns the (possibly still-open) handoff. */
|
|
1041
|
-
wait(id, input) {
|
|
1042
|
-
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
|
|
1043
|
-
query: dropUndefined({ timeout: input.timeout }),
|
|
1044
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
1045
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
create(kind, input, options) {
|
|
1049
|
-
const body = dropUndefined({
|
|
1050
|
-
kind,
|
|
1051
|
-
prompt: input.prompt,
|
|
1052
|
-
audience: { type: 'direct', user_id: input.target ?? 'me' },
|
|
1053
|
-
expires_in: input.expiresIn,
|
|
1054
|
-
urgency: input.urgency,
|
|
1055
|
-
options,
|
|
1056
|
-
correlation_id: input.correlationId,
|
|
1057
|
-
reply_to: input.replyTo,
|
|
1058
|
-
data: input.data,
|
|
1059
|
-
});
|
|
1060
|
-
return this.http.request('POST', '/api/agent/handoffs', {
|
|
1061
|
-
body,
|
|
1062
|
-
...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
/** Setup and activation of the bound human's Agent Inbox delivery room. */
|
|
1067
|
-
class InboxApi {
|
|
1068
|
-
http;
|
|
1069
|
-
constructor(http) {
|
|
1070
|
-
this.http = http;
|
|
1071
|
-
}
|
|
1072
|
-
/** Replay the viable activation attempt or create its next numbered retry. */
|
|
1073
|
-
ensure(input = {}) {
|
|
1074
|
-
return this.http.request('POST', '/api/agent/inbox/ensure', {
|
|
1075
|
-
body: {},
|
|
1076
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
/**
|
|
1080
|
-
* Ensure the bound human's Agent Inbox, then block on its onboarding
|
|
1081
|
-
* Question until the server confirms activation, it expires/cancels, or the
|
|
1082
|
-
* overall deadline elapses. Pairing remains a separate operation so adopting
|
|
1083
|
-
* a credential never silently waits for a phone response.
|
|
1084
|
-
*/
|
|
1085
|
-
async activate(input = {}) {
|
|
1086
|
-
const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
|
|
1087
|
-
if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
|
|
1088
|
-
throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
|
|
1089
|
-
code: 'invalid_request',
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
const deadlineError = () => (new PingRoomActivationIncompleteError('deadline_exceeded', 'Agent Inbox activation did not complete before the overall deadline.', { overall_timeout_ms: overallTimeoutMs }));
|
|
1093
|
-
const deadlineController = new AbortController();
|
|
1094
|
-
const deadlineTimer = overallTimeoutMs === 0
|
|
1095
|
-
? null
|
|
1096
|
-
: setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
|
|
1097
|
-
if (overallTimeoutMs === 0)
|
|
1098
|
-
deadlineController.abort(deadlineError());
|
|
1099
|
-
const signal = input.signal
|
|
1100
|
-
? combineSignals(input.signal, deadlineController.signal)
|
|
1101
|
-
: deadlineController.signal;
|
|
1102
|
-
try {
|
|
1103
|
-
throwIfAborted(signal);
|
|
1104
|
-
const ensured = validateInboxEnsure(await this.ensure({ signal }));
|
|
1105
|
-
const questionId = ensured.question.id;
|
|
1106
|
-
for (;;) {
|
|
1107
|
-
throwIfAborted(signal);
|
|
1108
|
-
const pollStartedAt = Date.now();
|
|
1109
|
-
const question = validateInboxWait(await this.http.request('GET', `/api/agent/handoffs/${enc(questionId)}/wait`, {
|
|
1110
|
-
query: dropUndefined({ timeout: input.timeout }),
|
|
1111
|
-
timeoutMs: waitTimeoutMs(input.timeout),
|
|
1112
|
-
signal,
|
|
1113
|
-
}), questionId);
|
|
1114
|
-
throwIfAborted(signal);
|
|
1115
|
-
if (question.state === 'pending') {
|
|
1116
|
-
await sleep(Math.max(0, MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
if (question.state === 'answered' && question.activation_completed === true) {
|
|
1120
|
-
return {
|
|
1121
|
-
...ensured,
|
|
1122
|
-
activation_completed: true,
|
|
1123
|
-
question: question,
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
if (question.state === 'answered') {
|
|
1127
|
-
throw new PingRoomActivationIncompleteError('answered_without_completion', 'The onboarding Question was answered without verified native phone receipt before the answer.', { question_id: questionId, activation_completed: question.activation_completed ?? null });
|
|
1128
|
-
}
|
|
1129
|
-
if (question.state === 'expired' || question.state === 'cancelled') {
|
|
1130
|
-
throw new PingRoomActivationIncompleteError(question.state, `The onboarding Question ${question.state} before Agent Inbox activation completed.`, { question_id: questionId, activation_completed: question.activation_completed ?? null });
|
|
1131
|
-
}
|
|
1132
|
-
return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
|
|
1133
|
-
id: questionId,
|
|
1134
|
-
state: question.state,
|
|
1135
|
-
});
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
catch (error) {
|
|
1139
|
-
// A custom fetch implementation may reject with a generic AbortError
|
|
1140
|
-
// instead of the signal's richer reason. Preserve caller cancellation
|
|
1141
|
-
// and the activation-specific deadline error at this public boundary.
|
|
1142
|
-
throwIfAborted(signal);
|
|
1143
|
-
throw error;
|
|
1144
|
-
}
|
|
1145
|
-
finally {
|
|
1146
|
-
if (deadlineTimer !== null)
|
|
1147
|
-
clearTimeout(deadlineTimer);
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
class ProfileApi {
|
|
1152
|
-
http;
|
|
1153
|
-
constructor(http) {
|
|
1154
|
-
this.http = http;
|
|
1155
|
-
}
|
|
1156
|
-
/**
|
|
1157
|
-
* Set this agent's own avatar (from the bot avatar set) — the robot face
|
|
1158
|
-
* its pings and Questions wear. Never touches the bound human's profile
|
|
1159
|
-
* picture. Returns `{ ok, avatar_id, avatar_url }`.
|
|
1160
|
-
*/
|
|
1161
|
-
setAvatar(avatarId) {
|
|
1162
|
-
requireNonEmpty(avatarId, 'avatarId');
|
|
1163
|
-
return this.http.request('POST', '/api/agent/profile/avatar', { body: { avatar_id: avatarId } });
|
|
1164
|
-
}
|
|
1165
|
-
/** Rotate the agent's handle — the kill-switch for a leaked handle. */
|
|
1166
|
-
rotateHandle() {
|
|
1167
|
-
return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Live-status streams — a self-updating card on the room members' lock screen.
|
|
1172
|
-
*
|
|
1173
|
-
* Requires the `pingroom:live:write` scope. Free accounts get a small daily
|
|
1174
|
-
* budget of NEW streams; updates and the terminal ping of an open stream are
|
|
1175
|
-
* never charged, so a card can never be quota-blocked into hanging open. A
|
|
1176
|
-
* first ping that is already `done`/`failed` is a new stream and is charged.
|
|
1177
|
-
*/
|
|
1178
|
-
class LiveApi {
|
|
1179
|
-
http;
|
|
1180
|
-
constructor(http) {
|
|
1181
|
-
this.http = http;
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Send one stream ping — start, update, or end, chosen by `live_status.state`
|
|
1185
|
-
* and whether the correlation already exists.
|
|
1186
|
-
*/
|
|
1187
|
-
push(inviteCode, ping) {
|
|
1188
|
-
requireNonEmpty(inviteCode, 'inviteCode');
|
|
1189
|
-
requireNonEmpty(ping.correlation_id, 'correlation_id');
|
|
1190
|
-
assertStructuredData(ping.data);
|
|
1191
|
-
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* Read back a stream this credential started, so a restarted producer can
|
|
1195
|
-
* reconcile instead of opening a duplicate.
|
|
1196
|
-
*
|
|
1197
|
-
* Resolves null ONLY for a 404 `stream_not_found` — no stream with that
|
|
1198
|
-
* correlation id in the last 24 hours. Every other failure throws: a 404 for
|
|
1199
|
-
* an unknown or deleted room code, and a 403 `room_not_granted`
|
|
1200
|
-
* ({@link RoomScopedErrorCode}) when the room is outside the grant the human
|
|
1201
|
-
* gave this agent, both propagate rather than being reported as "no stream".
|
|
1202
|
-
*/
|
|
1203
|
-
async get(inviteCode, correlationId) {
|
|
1204
|
-
requireNonEmpty(inviteCode, 'inviteCode');
|
|
1205
|
-
requireNonEmpty(correlationId, 'correlationId');
|
|
1206
|
-
try {
|
|
1207
|
-
return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
|
|
1208
|
-
}
|
|
1209
|
-
catch (error) {
|
|
1210
|
-
if (error instanceof PingRoomError && error.status === 404 && error.code === 'stream_not_found')
|
|
1211
|
-
return null;
|
|
1212
|
-
throw error;
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
/**
|
|
1217
|
-
* Private files that a Ping or Question can carry.
|
|
1218
|
-
*
|
|
1219
|
-
* Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
|
|
1220
|
-
* as `attachment_ids`. Bytes never travel over MCP or a JSON ping body. An
|
|
1221
|
-
* upload that is never attached expires on its own after 24 hours.
|
|
1222
|
-
* Each file must be 1 byte–5 MiB with an md/pdf/html/txt/jpg/jpeg/png/zip filename;
|
|
1223
|
-
* a Ping or Question accepts at most four ids.
|
|
1224
|
-
*
|
|
1225
|
-
* Uploading requires the bound account to hold Pro (the API answers 402
|
|
1226
|
-
* `pro_required` otherwise). Reading and deleting are not gated.
|
|
1227
|
-
*/
|
|
1228
|
-
class AttachmentsApi {
|
|
1229
|
-
http;
|
|
1230
|
-
constructor(http) {
|
|
1231
|
-
this.http = http;
|
|
1232
|
-
}
|
|
1233
|
-
/** Upload one file and get back the id a send can claim. */
|
|
1234
|
-
upload(input) {
|
|
1235
|
-
requireNonEmpty(input.filename, 'filename');
|
|
1236
|
-
// The server answers an oversized body with 413 before reading it; refuse
|
|
1237
|
-
// locally so no bytes travel for a file that can never be accepted.
|
|
1238
|
-
if (attachmentByteLength(input.content) > ATTACHMENT_MAX_BYTES) {
|
|
1239
|
-
return Promise.reject(new PingRoomError('Attachments are at most 5 MiB each.', { code: 'attachment_too_large', status: 413 }));
|
|
1240
|
-
}
|
|
1241
|
-
const body = new FormData();
|
|
1242
|
-
body.append('file', toAttachmentBlob(input), input.filename);
|
|
1243
|
-
return this.http
|
|
1244
|
-
.request('POST', '/api/agent/attachments', {
|
|
1245
|
-
body,
|
|
1246
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
1247
|
-
})
|
|
1248
|
-
.then((res) => res.attachment);
|
|
1249
|
-
}
|
|
1250
|
-
/**
|
|
1251
|
-
* Raw bytes for an attachment this credential is allowed to see. Returns the
|
|
1252
|
-
* undecoded Response so a caller can stream it, or read text/arrayBuffer.
|
|
1253
|
-
*/
|
|
1254
|
-
content(attachmentId, signal) {
|
|
1255
|
-
requireNonEmpty(attachmentId, 'attachmentId');
|
|
1256
|
-
return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
|
|
1257
|
-
...(signal ? { signal } : {}),
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
/**
|
|
1261
|
-
* The listing of a `.zip`'s contents, recorded when it was uploaded.
|
|
1262
|
-
*
|
|
1263
|
-
* Resolves null for a non-archive, and for an archive whose central
|
|
1264
|
-
* directory did not parse. Nothing decompresses the file to produce this, so
|
|
1265
|
-
* the declared sizes are what the archive claims, not verified output.
|
|
1266
|
-
*/
|
|
1267
|
-
manifest(attachmentId, signal) {
|
|
1268
|
-
requireNonEmpty(attachmentId, 'attachmentId');
|
|
1269
|
-
return this.http
|
|
1270
|
-
.request('GET', `/api/agent/attachments/${enc(attachmentId)}/manifest`, { ...(signal ? { signal } : {}) })
|
|
1271
|
-
.then((res) => res.manifest ?? null);
|
|
1272
|
-
}
|
|
1273
|
-
/** Discard an upload that has not been attached to a ping yet. */
|
|
1274
|
-
delete(attachmentId) {
|
|
1275
|
-
requireNonEmpty(attachmentId, 'attachmentId');
|
|
1276
|
-
return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
/** Normalise the three accepted content shapes into one multipart part. */
|
|
1280
|
-
function toAttachmentBlob(input) {
|
|
1281
|
-
const type = input.contentType ?? '';
|
|
1282
|
-
if (input.content instanceof Blob) {
|
|
1283
|
-
return type ? new Blob([input.content], { type }) : input.content;
|
|
1284
|
-
}
|
|
1285
|
-
// Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
|
|
1286
|
-
// larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
|
|
1287
|
-
const parts = typeof input.content === 'string'
|
|
1288
|
-
? [input.content]
|
|
1289
|
-
: [new Uint8Array(input.content).slice().buffer];
|
|
1290
|
-
return new Blob(parts, type ? { type } : undefined);
|
|
1291
|
-
}
|
|
1292
|
-
/** Public, unauthenticated agent directory. */
|
|
1293
|
-
class DirectoryApi {
|
|
1294
|
-
http;
|
|
1295
|
-
constructor(http) {
|
|
1296
|
-
this.http = http;
|
|
1297
|
-
}
|
|
1298
|
-
list(input = {}) {
|
|
1299
|
-
return this.http.request('GET', '/api/agents/directory', {
|
|
1300
|
-
auth: false,
|
|
1301
|
-
query: dropUndefined({ ...input }),
|
|
1302
|
-
});
|
|
1303
|
-
}
|
|
1304
|
-
get(handle) {
|
|
1305
|
-
return this.http.request('GET', `/api/agents/directory/${enc(handle)}`, { auth: false });
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
25
|
// --- main client ----------------------------------------------------------
|
|
1309
26
|
export class PingRoom {
|
|
1310
27
|
auth;
|
|
@@ -1313,7 +30,6 @@ export class PingRoom {
|
|
|
1313
30
|
actions;
|
|
1314
31
|
notifications;
|
|
1315
32
|
attachments;
|
|
1316
|
-
agents;
|
|
1317
33
|
approvals;
|
|
1318
34
|
questions;
|
|
1319
35
|
handoffs;
|
|
@@ -1339,7 +55,6 @@ export class PingRoom {
|
|
|
1339
55
|
this.actions = new ActionsApi(this.http);
|
|
1340
56
|
this.notifications = new NotificationsApi(this.http);
|
|
1341
57
|
this.attachments = new AttachmentsApi(this.http);
|
|
1342
|
-
this.agents = new AgentsApi(this.http);
|
|
1343
58
|
this.approvals = new ApprovalsApi(this.http);
|
|
1344
59
|
this.questions = new QuestionsApi(this.http);
|
|
1345
60
|
this.handoffs = new HandoffsApi(this.http);
|