@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.
Files changed (179) hide show
  1. package/README.md +153 -41
  2. package/dist/client.d.ts +14 -348
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +15 -1300
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +32 -3
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +47 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +7 -1
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -5
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/guards.d.ts +7 -0
  18. package/dist/internal/guards.d.ts.map +1 -1
  19. package/dist/internal/guards.js +1 -1
  20. package/dist/internal/guards.js.map +1 -1
  21. package/dist/internal/request.d.ts +41 -0
  22. package/dist/internal/request.d.ts.map +1 -0
  23. package/dist/internal/request.js +78 -0
  24. package/dist/internal/request.js.map +1 -0
  25. package/dist/internal/retry.d.ts +9 -2
  26. package/dist/internal/retry.d.ts.map +1 -1
  27. package/dist/internal/retry.js +24 -6
  28. package/dist/internal/retry.js.map +1 -1
  29. package/dist/internal/validate.d.ts +13 -0
  30. package/dist/internal/validate.d.ts.map +1 -0
  31. package/dist/internal/validate.js +36 -0
  32. package/dist/internal/validate.js.map +1 -0
  33. package/dist/liveStatus.d.ts +106 -11
  34. package/dist/liveStatus.d.ts.map +1 -1
  35. package/dist/liveStatus.js +20 -3
  36. package/dist/liveStatus.js.map +1 -1
  37. package/dist/mcp.d.ts.map +1 -1
  38. package/dist/mcp.js +30 -8
  39. package/dist/mcp.js.map +1 -1
  40. package/dist/namespaces/actions.d.ts +50 -0
  41. package/dist/namespaces/actions.d.ts.map +1 -0
  42. package/dist/namespaces/actions.js +219 -0
  43. package/dist/namespaces/actions.js.map +1 -0
  44. package/dist/namespaces/approvals.d.ts +23 -0
  45. package/dist/namespaces/approvals.d.ts.map +1 -0
  46. package/dist/namespaces/approvals.js +78 -0
  47. package/dist/namespaces/approvals.js.map +1 -0
  48. package/dist/namespaces/attachments.d.ts +37 -0
  49. package/dist/namespaces/attachments.d.ts.map +1 -0
  50. package/dist/namespaces/attachments.js +91 -0
  51. package/dist/namespaces/attachments.js.map +1 -0
  52. package/dist/namespaces/auth.d.ts +58 -0
  53. package/dist/namespaces/auth.d.ts.map +1 -0
  54. package/dist/namespaces/auth.js +242 -0
  55. package/dist/namespaces/auth.js.map +1 -0
  56. package/dist/namespaces/directory.d.ts +10 -0
  57. package/dist/namespaces/directory.d.ts.map +1 -0
  58. package/dist/namespaces/directory.js +19 -0
  59. package/dist/namespaces/directory.js.map +1 -0
  60. package/dist/namespaces/handoffs.d.ts +47 -0
  61. package/dist/namespaces/handoffs.d.ts.map +1 -0
  62. package/dist/namespaces/handoffs.js +126 -0
  63. package/dist/namespaces/handoffs.js.map +1 -0
  64. package/dist/namespaces/inbox.d.ts +17 -0
  65. package/dist/namespaces/inbox.d.ts.map +1 -0
  66. package/dist/namespaces/inbox.js +192 -0
  67. package/dist/namespaces/inbox.js.map +1 -0
  68. package/dist/namespaces/live.d.ts +57 -0
  69. package/dist/namespaces/live.d.ts.map +1 -0
  70. package/dist/namespaces/live.js +116 -0
  71. package/dist/namespaces/live.js.map +1 -0
  72. package/dist/namespaces/notifications.d.ts +25 -0
  73. package/dist/namespaces/notifications.d.ts.map +1 -0
  74. package/dist/namespaces/notifications.js +128 -0
  75. package/dist/namespaces/notifications.js.map +1 -0
  76. package/dist/namespaces/profile.d.ts +19 -0
  77. package/dist/namespaces/profile.d.ts.map +1 -0
  78. package/dist/namespaces/profile.js +22 -0
  79. package/dist/namespaces/profile.js.map +1 -0
  80. package/dist/namespaces/questions.d.ts +41 -0
  81. package/dist/namespaces/questions.d.ts.map +1 -0
  82. package/dist/namespaces/questions.js +107 -0
  83. package/dist/namespaces/questions.js.map +1 -0
  84. package/dist/namespaces/rooms.d.ts +13 -0
  85. package/dist/namespaces/rooms.d.ts.map +1 -0
  86. package/dist/namespaces/rooms.js +41 -0
  87. package/dist/namespaces/rooms.js.map +1 -0
  88. package/dist/namespaces/webhooks.d.ts +11 -0
  89. package/dist/namespaces/webhooks.d.ts.map +1 -0
  90. package/dist/namespaces/webhooks.js +32 -0
  91. package/dist/namespaces/webhooks.js.map +1 -0
  92. package/dist/types/approvals.d.ts +62 -0
  93. package/dist/types/approvals.d.ts.map +1 -0
  94. package/dist/types/approvals.js +2 -0
  95. package/dist/types/approvals.js.map +1 -0
  96. package/dist/types/auth.d.ts +185 -0
  97. package/dist/types/auth.d.ts.map +1 -0
  98. package/dist/types/auth.js +2 -0
  99. package/dist/types/auth.js.map +1 -0
  100. package/dist/types/common.d.ts +43 -0
  101. package/dist/types/common.d.ts.map +1 -0
  102. package/dist/types/common.js +2 -0
  103. package/dist/types/common.js.map +1 -0
  104. package/dist/types/handoffs.d.ts +121 -0
  105. package/dist/types/handoffs.d.ts.map +1 -0
  106. package/dist/types/handoffs.js +2 -0
  107. package/dist/types/handoffs.js.map +1 -0
  108. package/dist/types/inbox.d.ts +51 -0
  109. package/dist/types/inbox.d.ts.map +1 -0
  110. package/dist/types/inbox.js +2 -0
  111. package/dist/types/inbox.js.map +1 -0
  112. package/dist/types/notifications.d.ts +376 -0
  113. package/dist/types/notifications.d.ts.map +1 -0
  114. package/dist/types/notifications.js +2 -0
  115. package/dist/types/notifications.js.map +1 -0
  116. package/dist/types/profile.d.ts +30 -0
  117. package/dist/types/profile.d.ts.map +1 -0
  118. package/dist/types/profile.js +3 -0
  119. package/dist/types/profile.js.map +1 -0
  120. package/dist/types/questions.d.ts +210 -0
  121. package/dist/types/questions.d.ts.map +1 -0
  122. package/dist/types/questions.js +2 -0
  123. package/dist/types/questions.js.map +1 -0
  124. package/dist/types/rooms.d.ts +163 -0
  125. package/dist/types/rooms.d.ts.map +1 -0
  126. package/dist/types/rooms.js +3 -0
  127. package/dist/types/rooms.js.map +1 -0
  128. package/dist/types.d.ts +9 -1199
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/webhooks.d.ts +14 -4
  133. package/dist/webhooks.d.ts.map +1 -1
  134. package/dist/webhooks.js +30 -5
  135. package/dist/webhooks.js.map +1 -1
  136. package/package.json +4 -2
  137. package/src/client.ts +115 -0
  138. package/src/errors.ts +291 -0
  139. package/src/http.ts +225 -0
  140. package/src/index.ts +97 -0
  141. package/src/internal/async.ts +54 -0
  142. package/src/internal/guards.ts +129 -0
  143. package/src/internal/request.ts +97 -0
  144. package/src/internal/retry.ts +157 -0
  145. package/src/internal/url.ts +78 -0
  146. package/src/internal/validate.ts +42 -0
  147. package/src/linkPing.ts +56 -0
  148. package/src/liveStatus.ts +337 -0
  149. package/src/locationPing.ts +101 -0
  150. package/src/mcp.ts +161 -0
  151. package/src/namespaces/actions.ts +234 -0
  152. package/src/namespaces/approvals.ts +104 -0
  153. package/src/namespaces/attachments.ts +106 -0
  154. package/src/namespaces/auth.ts +284 -0
  155. package/src/namespaces/directory.ts +21 -0
  156. package/src/namespaces/handoffs.ts +160 -0
  157. package/src/namespaces/inbox.ts +253 -0
  158. package/src/namespaces/live.ts +134 -0
  159. package/src/namespaces/notifications.ts +152 -0
  160. package/src/namespaces/profile.ts +24 -0
  161. package/src/namespaces/questions.ts +133 -0
  162. package/src/namespaces/rooms.ts +53 -0
  163. package/src/namespaces/webhooks.ts +38 -0
  164. package/src/types/approvals.ts +67 -0
  165. package/src/types/auth.ts +202 -0
  166. package/src/types/common.ts +74 -0
  167. package/src/types/handoffs.ts +148 -0
  168. package/src/types/inbox.ts +64 -0
  169. package/src/types/notifications.ts +393 -0
  170. package/src/types/profile.ts +37 -0
  171. package/src/types/questions.ts +226 -0
  172. package/src/types/rooms.ts +169 -0
  173. package/src/types.ts +17 -0
  174. package/src/version.ts +2 -0
  175. package/src/webhooks.ts +373 -0
  176. package/dist/liveActivity.d.ts +0 -101
  177. package/dist/liveActivity.d.ts.map +0 -1
  178. package/dist/liveActivity.js +0 -83
  179. package/dist/liveActivity.js.map +0 -1
@@ -0,0 +1,234 @@
1
+ // `pr.actions`: a room's quick-action slots, pages and presses.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { assertActionNumber, requireNonEmpty, requirePresentString } from '../internal/guards.js';
6
+ import { linkPing } from '../linkPing.js';
7
+ import { locationPing } from '../locationPing.js';
8
+ import type {
9
+ PingResult,
10
+ QuickAction,
11
+ TriggerInput,
12
+ QuickActionInput,
13
+ UpdateQuickActionInput,
14
+ UpdateQuickActionLayoutInput,
15
+ } from '../types.js';
16
+ import { enc, splitIdempotencyKey, dropUndefined } from '../internal/request.js';
17
+
18
+ const MAX_TRIGGER_ATTACHMENTS = 4;
19
+ const QUICK_ACTION_PAGE_SIZE = 4;
20
+ const MAX_QUICK_ACTION_PAGES = 4;
21
+
22
+ /**
23
+ * The per-slot checks shared by `updateMany()` and `updateLayout()`: a valid,
24
+ * present, unique action_number and an icon whenever the slot has a label.
25
+ * Two entries for one slot would let the last silently win, and the caller
26
+ * would never learn which of its definitions was stored.
27
+ */
28
+ function assertActionSlots(actions: QuickActionInput[]): void {
29
+ const seen = new Set<number>();
30
+ for (const action of actions) {
31
+ assertActionNumber(action.action_number);
32
+ if (action.action_number === undefined || action.action_number === null) {
33
+ throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
34
+ }
35
+ if (seen.has(action.action_number)) {
36
+ throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
37
+ }
38
+ seen.add(action.action_number);
39
+ assertQuickActionIcon(action);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Mirrors the server's `icon` rule (`present`, `required_with:label`): the emoji
45
+ * is the half that must be there unless BOTH are empty, which reserves the
46
+ * slot as disabled.
47
+ */
48
+ function assertQuickActionIcon(action: { label: unknown; icon: unknown }): void {
49
+ requirePresentString(action.label, 'label');
50
+ requirePresentString(action.icon, 'icon');
51
+ if ((action.icon as string).trim() === '' && (action.label as string).trim() !== '') {
52
+ throw new PingRoomError('`icon` is required when `label` is set; send both empty to disable the slot.', {
53
+ code: 'invalid_request',
54
+ });
55
+ }
56
+ }
57
+
58
+ function assertTriggerInput(input: TriggerInput): void {
59
+ const data = input.data;
60
+ if (data !== undefined) {
61
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
62
+ throw new PingRoomError('`data` must be an object with `location` and/or `url`.', { code: 'invalid_request' });
63
+ }
64
+ for (const key of Object.keys(data)) {
65
+ if (key !== 'location' && key !== 'url') {
66
+ throw new PingRoomError(`\`data.${key}\` is not accepted on a quick-action press; only location and url are.`, {
67
+ code: 'invalid_request',
68
+ });
69
+ }
70
+ }
71
+ try {
72
+ if (data.location !== undefined) locationPing(data.location);
73
+ if (data.url !== undefined) linkPing({ url: data.url });
74
+ } catch (error) {
75
+ throw new PingRoomError((error as Error).message, { code: 'invalid_request' });
76
+ }
77
+ }
78
+ if (input.attachment_ids !== undefined) {
79
+ if (!Array.isArray(input.attachment_ids) || input.attachment_ids.length > MAX_TRIGGER_ATTACHMENTS) {
80
+ throw new PingRoomError(`\`attachment_ids\` accepts at most ${MAX_TRIGGER_ATTACHMENTS} ids.`, { code: 'invalid_request' });
81
+ }
82
+ for (const id of input.attachment_ids) requireNonEmpty(id, 'attachment_ids[]');
83
+ }
84
+ }
85
+
86
+ export class ActionsApi {
87
+ constructor(private readonly http: HttpClient) {}
88
+
89
+ list(inviteCode: string): Promise<QuickAction[]> {
90
+ return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/actions`);
91
+ }
92
+
93
+ update(inviteCode: string, actionNumber: number, input: UpdateQuickActionInput): Promise<QuickAction> {
94
+ assertActionNumber(actionNumber);
95
+ // A Ping's title is optional — its emoji can be the whole name — so an
96
+ // empty `label` is a valid update. `icon` is the half that must be there,
97
+ // unless both are empty: that reserves the slot as disabled.
98
+ assertQuickActionIcon(input);
99
+ return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}`, {
100
+ body: dropUndefined({ ...input }),
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Write up to 16 action slots in one request. The server enforces the room owner's plan.
106
+ * Add new pages in complete groups of four; existing slots may be edited individually.
107
+ *
108
+ * Prefer this over looping `update()`. Each single-slot write enqueues its
109
+ * own silent rooms-refresh push, so configuring a room's four Pings one at a
110
+ * time wakes room members' devices four times inside a few seconds — four of a
111
+ * finite daily background-push budget spent on one logical operation.
112
+ *
113
+ * Partial and additive: slots absent from `actions` keep their current
114
+ * configuration, so this is also the right call for editing a single Ping.
115
+ * Nothing here deletes an action, and an empty array is rejected locally
116
+ * rather than being sent as a clear. Resolves to the room's full ordered
117
+ * action set, not just the slots written.
118
+ */
119
+ updateMany(inviteCode: string, actions: QuickActionInput[]): Promise<QuickAction[]> {
120
+ if (!Array.isArray(actions) || actions.length === 0) {
121
+ throw new PingRoomError('`actions` must contain at least one action.', { code: 'invalid_request' });
122
+ }
123
+ if (actions.length > 16) {
124
+ throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
125
+ }
126
+
127
+ assertActionSlots(actions);
128
+
129
+ return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
130
+ body: { actions: actions.map((action) => dropUndefined({ ...action })) },
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Press a slot. When the action has an `input_type`, pass the matching
136
+ * detail (`data.location`, `data.url`, or `attachment_ids`) or the server
137
+ * refuses the press with `422 quick_action_input_required` and sends nothing.
138
+ * Read `input_type` from `list()` first; pass the action's `id` as
139
+ * `quick_action_id` to be refused rather than fire the wrong Ping if the
140
+ * owner moved pages meanwhile.
141
+ */
142
+ trigger(inviteCode: string, actionNumber: number, input: TriggerInput = {}): Promise<PingResult> {
143
+ assertActionNumber(actionNumber);
144
+ assertTriggerInput(input);
145
+ const { body, headers } = splitIdempotencyKey(input);
146
+ return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
147
+ body: dropUndefined({ ...body }),
148
+ ...(headers ? { headers } : {}),
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Replace the room's page layout atomically (room owner on Pro only). See
154
+ * `UpdateQuickActionLayoutInput` for the snapshot contract; a stale
155
+ * `base_action_ids` is `409 quick_action_layout_changed`, a page still used
156
+ * by a time trigger, webhook, agent binding or running live update is
157
+ * `409 quick_action_page_in_use`. Prefer `deletePage()`, which builds the
158
+ * body from a fresh read.
159
+ */
160
+ updateLayout(inviteCode: string, input: UpdateQuickActionLayoutInput): Promise<QuickAction[]> {
161
+ if (!Array.isArray(input.base_action_ids)) {
162
+ throw new PingRoomError('`base_action_ids` must be an array of action ids (may be empty).', { code: 'invalid_request' });
163
+ }
164
+ if (!Array.isArray(input.page_order) || input.page_order.length < 1 || input.page_order.length > MAX_QUICK_ACTION_PAGES) {
165
+ throw new PingRoomError(`\`page_order\` must list 1–${MAX_QUICK_ACTION_PAGES} pages.`, { code: 'invalid_request' });
166
+ }
167
+ for (const page of input.page_order) {
168
+ if (page !== null && (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES)) {
169
+ throw new PingRoomError('`page_order` entries must be an original page number 1–4 or null for a blank page.', {
170
+ code: 'invalid_request',
171
+ });
172
+ }
173
+ }
174
+ const expected = input.page_order.length * QUICK_ACTION_PAGE_SIZE;
175
+ if (!Array.isArray(input.actions) || input.actions.length !== expected) {
176
+ throw new PingRoomError(`\`actions\` must hold exactly ${expected} slots for ${input.page_order.length} page(s).`, {
177
+ code: 'invalid_request',
178
+ });
179
+ }
180
+ assertActionSlots(input.actions);
181
+ return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
182
+ body: {
183
+ base_action_ids: input.base_action_ids,
184
+ page_order: input.page_order,
185
+ actions: input.actions.map((action) => dropUndefined({ ...action })),
186
+ },
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Delete one page (1–4) and shift the later pages left, keeping every other
192
+ * slot's configuration. Reads the current action set first so the layout
193
+ * snapshot is real, then calls `updateLayout()`. Page 1 can be deleted only
194
+ * when another page remains.
195
+ */
196
+ async deletePage(inviteCode: string, page: number): Promise<QuickAction[]> {
197
+ if (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES) {
198
+ throw new PingRoomError('`page` must be an integer 1–4.', { code: 'invalid_request' });
199
+ }
200
+ const current = await this.list(inviteCode);
201
+ const stored = [...current].sort((a, b) => a.action_number - b.action_number);
202
+ const pageCount = Math.max(1, Math.ceil((stored.at(-1)?.action_number ?? 0) / QUICK_ACTION_PAGE_SIZE));
203
+ if (page > pageCount) {
204
+ throw new PingRoomError(`The room has ${pageCount} page(s); page ${page} does not exist.`, { code: 'invalid_request' });
205
+ }
206
+ if (pageCount === 1) {
207
+ throw new PingRoomError('A room keeps at least one page; configure its slots instead of deleting it.', {
208
+ code: 'invalid_request',
209
+ });
210
+ }
211
+ const keep = Array.from({ length: pageCount }, (_, i) => i + 1).filter((p) => p !== page);
212
+ const byNumber = new Map(stored.map((a) => [a.action_number, a]));
213
+ const actions: QuickActionInput[] = [];
214
+ keep.forEach((originalPage, index) => {
215
+ for (let offset = 1; offset <= QUICK_ACTION_PAGE_SIZE; offset++) {
216
+ const source = byNumber.get((originalPage - 1) * QUICK_ACTION_PAGE_SIZE + offset);
217
+ actions.push({
218
+ action_number: index * QUICK_ACTION_PAGE_SIZE + offset,
219
+ label: source?.label ?? '',
220
+ icon: source?.icon ?? '',
221
+ ...(source?.sound !== undefined ? { sound: source.sound } : {}),
222
+ ...(source?.haptic_style !== undefined ? { haptic_style: source.haptic_style } : {}),
223
+ ...(source?.requires_ack !== undefined ? { requires_ack: source.requires_ack } : {}),
224
+ ...(source?.input_type !== undefined ? { input_type: source.input_type } : {}),
225
+ });
226
+ }
227
+ });
228
+ return this.updateLayout(inviteCode, {
229
+ base_action_ids: stored.map((a) => a.id).filter((id): id is string => typeof id === 'string'),
230
+ page_order: keep,
231
+ actions,
232
+ });
233
+ }
234
+ }
@@ -0,0 +1,104 @@
1
+ // `pr.approvals`: two-option human-in-the-loop decisions.
2
+
3
+ import type { HttpClient } from '../http.js';
4
+ import { assertStructuredData, requireNonEmpty } from '../internal/guards.js';
5
+ import { pollUntilSettled } from '../internal/retry.js';
6
+ import type {
7
+ Approval,
8
+ ApprovalInput,
9
+ WaitApprovalInput,
10
+ WaitForOutcomeOptions,
11
+ } from '../types.js';
12
+ import {
13
+ MIN_WAIT_POLL_INTERVAL_MS,
14
+ enc,
15
+ splitIdempotencyKey,
16
+ assertOnRetry,
17
+ dropUndefined,
18
+ waitTimeoutMs,
19
+ } from '../internal/request.js';
20
+ import {
21
+ isRecord,
22
+ isNonEmptyString,
23
+ isNullableString,
24
+ sameId,
25
+ invalidWaitResponse,
26
+ } from '../internal/validate.js';
27
+
28
+ const APPROVAL_STATUSES: ReadonlySet<string> = new Set(['pending', 'decided', 'expired', 'cancelled']);
29
+
30
+ /**
31
+ * A wait response is trusted as an outcome only when it is the approval that
32
+ * was asked about, in a known status. A captive portal's HTML, `{}`, or another
33
+ * approval must not read as "decided".
34
+ */
35
+ function validateApprovalWait(value: unknown, approvalId: string): Approval {
36
+ const status = isRecord(value) ? value['status'] : undefined;
37
+ if (
38
+ !isRecord(value)
39
+ || !sameId(value['id'], approvalId)
40
+ || typeof status !== 'string'
41
+ || !APPROVAL_STATUSES.has(status)
42
+ || !isNullableString(value['decided_at'])
43
+ // A gate acts on `decision`; a decided approval without one, or a pending
44
+ // one already stamped decided, is not an outcome it can act on.
45
+ || (status === 'decided' && !isNonEmptyString(value['decision']))
46
+ || (status === 'pending' && value['decided_at'] !== null)
47
+ ) {
48
+ return invalidWaitResponse('approval', approvalId, value, 'status');
49
+ }
50
+ return value as unknown as Approval;
51
+ }
52
+
53
+ export class ApprovalsApi {
54
+ constructor(private readonly http: HttpClient) {}
55
+
56
+ /** Ask the human you act for to decide; returns the pending approval. */
57
+ request(inviteCode: string, input: ApprovalInput): Promise<Approval> {
58
+ requireNonEmpty(input.question, 'question');
59
+ assertStructuredData(input.data);
60
+ const { body, headers } = splitIdempotencyKey(input);
61
+ return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
62
+ body: dropUndefined({ ...body }),
63
+ ...(headers ? { headers } : {}),
64
+ });
65
+ }
66
+
67
+ get(approvalId: string): Promise<Approval> {
68
+ return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}`);
69
+ }
70
+
71
+ /** One long-poll for the decision; returns the (possibly still-pending) approval. */
72
+ wait(approvalId: string, input: WaitApprovalInput = {}): Promise<Approval> {
73
+ return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}/wait`, {
74
+ query: dropUndefined({ timeout: input.timeout }),
75
+ timeoutMs: waitTimeoutMs(input.timeout),
76
+ ...(input.signal ? { signal: input.signal } : {}),
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Block until the human decides (or the request expires or is cancelled),
82
+ * looping wait() under the hood, with polls started at least 2.1 s apart. A
83
+ * response that is not this approval in a known status rejects with
84
+ * `invalid_response`. Network failures, 429 and 5xx are retried with backoff
85
+ * (honouring `retryAfter`), and reported to `onRetry`, until `signal` aborts
86
+ * or the outage outlasts the approval's `expires_at` by 5 minutes (15 minutes
87
+ * of failures while no `expires_at` has been read); that rejects with
88
+ * `unavailable`. See {@link WaitForOutcomeOptions}.
89
+ */
90
+ async waitForDecision(approvalId: string, input: WaitForOutcomeOptions = {}): Promise<Approval> {
91
+ requireNonEmpty(approvalId, 'approvalId');
92
+ assertOnRetry(input.onRetry);
93
+ return pollUntilSettled({
94
+ poll: () => this.wait(approvalId, input.signal ? { signal: input.signal } : {}),
95
+ validate: (value) => validateApprovalWait(value, approvalId),
96
+ settled: (approval) => approval.status !== 'pending',
97
+ expiresAt: (approval) => approval.expires_at,
98
+ what: `approval ${approvalId}`,
99
+ minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
100
+ ...(input.signal ? { signal: input.signal } : {}),
101
+ ...(input.onRetry ? { onRetry: input.onRetry } : {}),
102
+ });
103
+ }
104
+ }
@@ -0,0 +1,106 @@
1
+ // `pr.attachments`: upload, read and discard private files.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { requireNonEmpty } from '../internal/guards.js';
6
+ import type { Attachment, ZipManifest, UploadAttachmentInput } from '../types.js';
7
+ import { enc } from '../internal/request.js';
8
+
9
+ /** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
10
+ const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
11
+
12
+ function attachmentByteLength(content: UploadAttachmentInput['content']): number {
13
+ if (content instanceof Blob) return content.size;
14
+ if (typeof content === 'string') return new TextEncoder().encode(content).byteLength;
15
+ return content.byteLength;
16
+ }
17
+
18
+ /**
19
+ * Private files that a Ping or Question can carry.
20
+ *
21
+ * Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
22
+ * as `attachment_ids`. Bytes never travel inside a JSON ping body (the MCP
23
+ * `upload_attachment` tool takes only small files, about 90 KiB, as base64). An
24
+ * upload that is never attached expires on its own after 24 hours.
25
+ * Each file must be 1 byte–5 MiB with an md/pdf/html/txt/jpg/jpeg/png/zip filename;
26
+ * a Ping or Question accepts at most four ids.
27
+ *
28
+ * Uploading requires the bound account to hold Pro (the API answers 402
29
+ * `pro_required` otherwise). Reading and deleting are not gated.
30
+ */
31
+ export class AttachmentsApi {
32
+ constructor(private readonly http: HttpClient) {}
33
+
34
+ /** Upload one file and get back the id a send can claim. */
35
+ upload(input: UploadAttachmentInput): Promise<Attachment> {
36
+ requireNonEmpty(input.filename, 'filename');
37
+ // The server answers an oversized body with 413 before reading it; refuse
38
+ // locally so no bytes travel for a file that can never be accepted.
39
+ if (attachmentByteLength(input.content) > ATTACHMENT_MAX_BYTES) {
40
+ return Promise.reject(
41
+ new PingRoomError('Attachments are at most 5 MiB each.', { code: 'attachment_too_large', status: 413 }),
42
+ );
43
+ }
44
+
45
+ const body = new FormData();
46
+ body.append('file', toAttachmentBlob(input), input.filename);
47
+
48
+ return this.http
49
+ .request<{ attachment: Attachment }>('POST', '/api/agent/attachments', {
50
+ body,
51
+ ...(input.signal ? { signal: input.signal } : {}),
52
+ })
53
+ .then((res) => res.attachment);
54
+ }
55
+
56
+ /**
57
+ * Raw bytes for an attachment this credential is allowed to see. Returns the
58
+ * undecoded Response so a caller can stream it, or read text/arrayBuffer.
59
+ */
60
+ content(attachmentId: string, signal?: AbortSignal): Promise<Response> {
61
+ requireNonEmpty(attachmentId, 'attachmentId');
62
+ return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
63
+ ...(signal ? { signal } : {}),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * The listing of a `.zip`'s contents, recorded when it was uploaded.
69
+ *
70
+ * Resolves null for a non-archive, and for an archive whose central
71
+ * directory did not parse. Nothing decompresses the file to produce this, so
72
+ * the declared sizes are what the archive claims, not verified output.
73
+ */
74
+ manifest(attachmentId: string, signal?: AbortSignal): Promise<ZipManifest | null> {
75
+ requireNonEmpty(attachmentId, 'attachmentId');
76
+ return this.http
77
+ .request<{ manifest: ZipManifest | null }>(
78
+ 'GET',
79
+ `/api/agent/attachments/${enc(attachmentId)}/manifest`,
80
+ { ...(signal ? { signal } : {}) },
81
+ )
82
+ .then((res) => res.manifest ?? null);
83
+ }
84
+
85
+ /** Discard an upload that has not been attached to a ping yet. */
86
+ delete(attachmentId: string): Promise<void> {
87
+ requireNonEmpty(attachmentId, 'attachmentId');
88
+ return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
89
+ }
90
+ }
91
+
92
+ /** Normalise the three accepted content shapes into one multipart part. */
93
+ function toAttachmentBlob(input: UploadAttachmentInput): Blob {
94
+ const type = input.contentType ?? '';
95
+ if (input.content instanceof Blob) {
96
+ return type ? new Blob([input.content], { type }) : input.content;
97
+ }
98
+ // Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
99
+ // larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
100
+ const parts: BlobPart[] =
101
+ typeof input.content === 'string'
102
+ ? [input.content]
103
+ : [new Uint8Array(input.content).slice().buffer as ArrayBuffer];
104
+
105
+ return new Blob(parts, type ? { type } : undefined);
106
+ }