@cosmoops/connect-sdk 0.40.0 → 0.42.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 (55) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/cjs/client/components/access-gate.js +18 -40
  3. package/dist/cjs/client/components/apps-grid.js +11 -3
  4. package/dist/cjs/client/components/ask-ai-errors.js +109 -0
  5. package/dist/cjs/client/components/ask-ai-turns.js +12 -0
  6. package/dist/cjs/client/components/ask-ai.js +75 -8
  7. package/dist/cjs/client/components/call-button.js +398 -0
  8. package/dist/cjs/client/components/plans.js +8 -2
  9. package/dist/cjs/client/components/readiness.js +2 -1
  10. package/dist/cjs/client/components/subscription.js +4 -4
  11. package/dist/cjs/client/components/user-menu.js +16 -11
  12. package/dist/cjs/client/handoff.js +78 -0
  13. package/dist/cjs/client/index.js +15 -7
  14. package/dist/cjs/client/portal-links.js +1 -1
  15. package/dist/cjs/client/provider.js +75 -4
  16. package/dist/cjs/client/use-checkout.js +23 -16
  17. package/dist/cjs/server/client.js +5 -1
  18. package/dist/cjs/server/handler.js +77 -1
  19. package/dist/cjs/server/resources/calls.js +58 -0
  20. package/dist/cjs/server/resources/identity.js +12 -0
  21. package/dist/cjs/server/resources/whatsapp.js +58 -0
  22. package/dist/esm/client/components/access-gate.js +18 -40
  23. package/dist/esm/client/components/apps-grid.js +11 -3
  24. package/dist/esm/client/components/ask-ai-errors.js +102 -0
  25. package/dist/esm/client/components/ask-ai-turns.js +12 -0
  26. package/dist/esm/client/components/ask-ai.js +76 -9
  27. package/dist/esm/client/components/call-button.js +358 -0
  28. package/dist/esm/client/components/plans.js +8 -2
  29. package/dist/esm/client/components/readiness.js +2 -1
  30. package/dist/esm/client/components/subscription.js +4 -4
  31. package/dist/esm/client/components/user-menu.js +16 -11
  32. package/dist/esm/client/handoff.js +72 -0
  33. package/dist/esm/client/index.js +6 -4
  34. package/dist/esm/client/portal-links.js +1 -1
  35. package/dist/esm/client/provider.js +75 -4
  36. package/dist/esm/client/use-checkout.js +23 -16
  37. package/dist/esm/server/client.js +5 -1
  38. package/dist/esm/server/handler.js +77 -1
  39. package/dist/esm/server/resources/calls.js +58 -0
  40. package/dist/esm/server/resources/identity.js +12 -0
  41. package/dist/esm/server/resources/whatsapp.js +54 -0
  42. package/dist/types/client/components/ask-ai-errors.d.ts +41 -0
  43. package/dist/types/client/components/ask-ai-turns.d.ts +5 -0
  44. package/dist/types/client/components/call-button.d.ts +132 -0
  45. package/dist/types/client/handoff.d.ts +62 -0
  46. package/dist/types/client/index.d.ts +5 -1
  47. package/dist/types/client/portal-links.d.ts +1 -1
  48. package/dist/types/client/provider.d.ts +72 -5
  49. package/dist/types/client/use-checkout.d.ts +4 -1
  50. package/dist/types/server/client.d.ts +4 -1
  51. package/dist/types/server/resources/calls.d.ts +32 -1
  52. package/dist/types/server/resources/identity.d.ts +10 -0
  53. package/dist/types/server/resources/whatsapp.d.ts +44 -0
  54. package/dist/types/types.d.ts +152 -1
  55. package/package.json +4 -1
@@ -15,6 +15,7 @@ export { ConnectProvider, useConnect,
15
15
  // For a component that must render on a page with no provider - see its own
16
16
  // note. `<ConnectVisitorChat>` is the case it exists for.
17
17
  useConnectOptional, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectPlatformAccess, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectCompanyProfile, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
18
+ export { CONNECT_HANDOFF_MESSAGE_TYPE, CONNECT_HANDOFF_RESULT_PARAM } from "./handoff.js";
18
19
  // Re-exported from the root entry rather than kept there alone: it is a pure
19
20
  // helper, but every caller is UI code, and making a component file reach two
20
21
  // entry points for one function is friction with nothing behind it.
@@ -31,10 +32,10 @@ ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, Connect
31
32
  // Built, compiled into `dist/`, and reachable from no entry point until now -
32
33
  // `ConnectUserMenu` was the only thing that could render it.
33
34
  export { ConnectAccountSessions } from "./components/account-sessions.js";
34
- // A link to *any* Connect portal page that offers a "Back to <your app>" way
35
- // home - what `ConnectProfileButton` builds for `/profile` internally, made
36
- // reusable for a host linking out to a different Connect page (e.g.
37
- // `/calendar/settings`) on its own.
35
+ // A link to *any* Connect portal page that offers a `"Back to <your app>"` way
36
+ // home, for a host that needs a real `<a href>` (a server-rendered link, a
37
+ // middle-click). For a button, prefer `useConnect().openConnect`, which also
38
+ // brings the visitor back when the task is done.
38
39
  export { connectPortalUrl, useConnectPortalUrl } from "./portal-links.js";
39
40
  // The frame the rest of these components live inside. Exported next to the
40
41
  // pieces it composes rather than in a section of its own - an integration
@@ -152,6 +153,7 @@ export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js"
152
153
  // than a link-out to Connect's own "Create workspace" button.
153
154
  export { ConnectCreateWorkspaceModal } from "./components/create-workspace-form.js";
154
155
  export { useCreateWorkspace } from "./use-workspaces.js";
156
+ export { ConnectCallProvider, ConnectCallButton, ConnectCallingStatus, useConnectCall, } from "./components/call-button.js";
155
157
  export { ConnectBookingCalendar } from "./components/booking-calendar.js";
156
158
  export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
157
159
  // The write half of scheduling - a workspace's own first-time setup, not a
@@ -6,7 +6,7 @@ import { useConnect } from "./provider.js";
6
6
  * `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
7
7
  *
8
8
  * Connect answers any of these paths for a signed-in user on the bare URL -
9
- * these two params only add the "Back to <your app>" banner
9
+ * these two params only add the `"Back to <your app>"` banner
10
10
  * (`ApplicationReturnBar` on Connect's side). It validates `return_to`
11
11
  * against this `client_id`'s registered redirect origins and silently drops
12
12
  * the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
@@ -2,6 +2,8 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { clearOneTapSuppression, suppressOneTapAfterSignOut } from "./components/one-tap-state.js";
5
+ import { ConnectError } from "../errors.js";
6
+ import { buildHandoffUrl, readHandoffMessage, takeHandoffResult, } from "./handoff.js";
5
7
  export const CONNECT_APP_URL = "https://connect.cosmoops.com";
6
8
  const ConnectContext = createContext(null);
7
9
  /** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
@@ -20,7 +22,7 @@ const SILENT_ATTEMPT_KEY = "connect_silent_attempted";
20
22
  * </ConnectProvider>
21
23
  * ```
22
24
  */
23
- export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, children, }) {
25
+ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, handoff = "popup", onHandoffResult, children, }) {
24
26
  // One state cell, not three. `isLoading` is *derived* from "no result yet"
25
27
  // rather than set - a `setIsLoading(true)` at the top of an effect is a
26
28
  // synchronous setState inside that effect, which cascades an extra render
@@ -122,7 +124,17 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
122
124
  });
123
125
  if (!response.ok) {
124
126
  const envelope = await response.json().catch(() => null);
125
- throw new Error(envelope?.error?.message ?? `Connect bridge returned ${response.status}`);
127
+ // A `ConnectError` rather than a bare `Error`, so a caller can branch
128
+ // on the status and code - a signed-out 401 and a throttled 429 need
129
+ // different words in front of a person. Same message as before, so
130
+ // anything already reading it sees no change.
131
+ throw new ConnectError({
132
+ message: envelope?.error?.message ?? `Connect bridge returned ${response.status}`,
133
+ status: response.status,
134
+ code: typeof envelope?.error?.code === "string" ? envelope.error.code : "unknown",
135
+ endpoint: `POST ${path}`,
136
+ body: envelope,
137
+ });
126
138
  }
127
139
  return response;
128
140
  }, [basePath, fetchImpl]);
@@ -172,6 +184,61 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
172
184
  // eslint-disable-next-line react-hooks/set-state-in-effect
173
185
  void refresh();
174
186
  }, [refresh]);
187
+ /*
188
+ * Hand-offs to Connect - see `ConnectContextValue.openConnect`.
189
+ *
190
+ * The provider's own `clientId` prop wins; otherwise the bridge's, which is
191
+ * what makes this work for the common integration that never passes one.
192
+ */
193
+ const handoffClientId = clientId || session?.clientId || "";
194
+ const openConnect = useCallback((target, options = {}) => {
195
+ const returnTo = window.location.href;
196
+ if (handoff === "popup") {
197
+ // Deliberately no `noopener`: with it, `window.open` returns `null`
198
+ // by specification and Connect gets no `opener` - so it could
199
+ // neither tell this page it finished nor close itself (a browser
200
+ // only lets script close a window script opened). The opener it is
201
+ // handed is this page, and cross-origin that permits navigating it
202
+ // and posting to it - neither of which Connect does uninvited.
203
+ const opened = window.open(buildHandoffUrl(target, { appUrl, clientId: handoffClientId, mode: "popup", returnTo }), options.name ?? "_blank", options.features);
204
+ if (opened)
205
+ return opened;
206
+ // Blocked. A same-tab trip still gets them there and back, where a
207
+ // "your browser blocked the window" note leaves them to sort it out.
208
+ }
209
+ window.location.assign(buildHandoffUrl(target, { appUrl, clientId: handoffClientId, mode: "redirect", returnTo }));
210
+ return null;
211
+ }, [appUrl, handoff, handoffClientId]);
212
+ // Latest callback without re-subscribing the listener on every render of a
213
+ // host that passes an inline function.
214
+ const onHandoffResultRef = useRef(onHandoffResult);
215
+ useEffect(() => {
216
+ onHandoffResultRef.current = onHandoffResult;
217
+ });
218
+ useEffect(() => {
219
+ // Back from a redirect-mode trip. The page is a fresh load, so every read
220
+ // is already fresh - this only reports the outcome and tidies the URL.
221
+ // `replaceState` rather than a router call: this package imports no
222
+ // router, and Next's App Router honours a native `replaceState`.
223
+ const returned = takeHandoffResult(window.location.href);
224
+ if (returned) {
225
+ window.history.replaceState(window.history.state, "", returned.cleaned);
226
+ onHandoffResultRef.current?.(returned.result);
227
+ }
228
+ // A popup reporting in before it closes. `readHandoffMessage` checks the
229
+ // origin, which is what stops any other window driving this.
230
+ const onMessage = (event) => {
231
+ const result = readHandoffMessage(event, appUrl);
232
+ if (!result)
233
+ return;
234
+ // Session-wide: the plan, the readiness, the seat count - whatever the
235
+ // trip changed, every Connect component on the page re-reads it.
236
+ void refresh();
237
+ onHandoffResultRef.current?.(result);
238
+ };
239
+ window.addEventListener("message", onMessage);
240
+ return () => window.removeEventListener("message", onMessage);
241
+ }, [appUrl, refresh]);
175
242
  useEffect(() => {
176
243
  // `silentPending` already encodes settled-and-signed-out, no prior
177
244
  // attempt, and no bridge error - a failed bridge read says nothing about
@@ -293,8 +360,10 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
293
360
  }
294
361
  }, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
295
362
  const value = useMemo(() => ({
296
- clientId,
363
+ clientId: handoffClientId,
297
364
  appUrl,
365
+ handoffMode: handoff,
366
+ openConnect,
298
367
  session,
299
368
  isLoading,
300
369
  error,
@@ -308,8 +377,10 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
308
377
  readOneTapHint,
309
378
  oneTapSignIn,
310
379
  }), [
311
- clientId,
380
+ handoffClientId,
312
381
  appUrl,
382
+ handoff,
383
+ openConnect,
313
384
  session,
314
385
  isLoading,
315
386
  error,
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { readHandoffMessage } from "./handoff.js";
3
4
  import { useConnect } from "./provider.js";
4
5
  /** How often `/entitlements` is re-read while a checkout is open. */
5
6
  const POLL_INTERVAL_MS = 3_000;
@@ -30,7 +31,7 @@ const CLOSE_GRACE_MS = 12_000;
30
31
  * period rather than ending the run.
31
32
  */
32
33
  export function useConnectCheckout() {
33
- const { fetchResource, invalidateResource, refresh } = useConnect();
34
+ const { appUrl, fetchResource, invalidateResource, refresh, openConnect } = useConnect();
34
35
  const [state, setState] = useState({ status: "idle", url: null });
35
36
  // Everything the poll loop needs lives in a ref: it runs from a timer, and
36
37
  // re-creating the loop whenever a piece of it changed identity would
@@ -51,20 +52,18 @@ export function useConnectCheckout() {
51
52
  // an await is what pop-up blockers exist to stop, and it would be
52
53
  // blocked in every browser regardless of the user's settings.
53
54
  //
54
- // No `noopener`: with it set, `window.open` returns `null` by
55
- // specification, and this hook would then read every successfully
56
- // opened checkout as blocked. The handle is the whole point - it is
57
- // how the grace period below knows the customer closed the window.
58
- // The window it hands an `opener` to is Connect's own origin, and
59
- // cross-origin access is limited to navigating this page, which
60
- // Connect does not do.
61
- const opened = window.open(url, "connect_checkout", "popup,width=520,height=780");
62
- if (!opened) {
63
- // Not a failure to report as an error: the customer can still get
64
- // there, they just have to click the link themselves.
65
- setState({ status: "blocked", url });
55
+ // Through `openConnect`, which stamps the way back on the URL: Connect
56
+ // then closes this window itself once the change lands, instead of
57
+ // leaving the customer on a billing page they have to close by hand.
58
+ // The handle is still what the grace period below watches.
59
+ //
60
+ // `null` means it did not open a window - the provider is in `redirect`
61
+ // mode, or the popup was blocked and it fell back to one - and this
62
+ // page is navigating away. Nothing to watch; the trip back is a fresh
63
+ // load that reads the new entitlement on its own.
64
+ const opened = openConnect(url, { name: "connect_checkout", features: "popup,width=520,height=780" });
65
+ if (!opened)
66
66
  return;
67
- }
68
67
  run.current = {
69
68
  url,
70
69
  isSettled,
@@ -74,7 +73,7 @@ export function useConnectCheckout() {
74
73
  generation: generation.current,
75
74
  };
76
75
  setState({ status: "waiting", url });
77
- }, [stop]);
76
+ }, [openConnect, stop]);
78
77
  useEffect(() => {
79
78
  // One interval for the hook's whole life rather than one per run: it does
80
79
  // nothing while `run.current` is null, and a run that starts from a press
@@ -130,11 +129,19 @@ export function useConnectCheckout() {
130
129
  void poll();
131
130
  };
132
131
  document.addEventListener("visibilitychange", onVisible);
132
+ // Connect saying it is done is a stronger hint still - read now, before
133
+ // the window has even finished closing.
134
+ const onMessage = (event) => {
135
+ if (readHandoffMessage(event, appUrl))
136
+ void poll();
137
+ };
138
+ window.addEventListener("message", onMessage);
133
139
  return () => {
134
140
  clearInterval(interval);
135
141
  document.removeEventListener("visibilitychange", onVisible);
142
+ window.removeEventListener("message", onMessage);
136
143
  };
137
- }, [fetchResource, invalidateResource, refresh, stop]);
144
+ }, [appUrl, fetchResource, invalidateResource, refresh, stop]);
138
145
  return { ...state, start, reset };
139
146
  }
140
147
  /**
@@ -22,6 +22,7 @@ import { SessionsResource } from "./resources/sessions.js";
22
22
  import { PlatformResource } from "./resources/platform.js";
23
23
  import { VisitorResource } from "./resources/visitor.js";
24
24
  import { PushResource } from "./resources/push.js";
25
+ import { WhatsAppResource } from "./resources/whatsapp.js";
25
26
  export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
26
27
  const APPLICATION_API_PREFIX = "/api/v1/application";
27
28
  /**
@@ -125,6 +126,8 @@ export class ConnectClient {
125
126
  * preferences; you supply a title, a body and a URL. See `PushResource`.
126
127
  */
127
128
  push;
129
+ /** Your application's own approved WhatsApp templates, to a workspace's customers. */
130
+ whatsapp;
128
131
  /**
129
132
  * Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
130
133
  * store route and nothing else. See `storePassthrough`.
@@ -171,8 +174,9 @@ export class ConnectClient {
171
174
  this.visitor = new VisitorResource(transport);
172
175
  this.platform = new PlatformResource(transport);
173
176
  this.push = new PushResource(transport);
177
+ this.whatsapp = new WhatsAppResource(transport);
174
178
  this.storeRelay = (spec) => storePassthrough(transport, spec);
175
179
  }
176
180
  }
177
181
  /** Kept in step with `package.json` by `sdk.test.ts`. */
178
- export const SDK_VERSION = "0.40.0";
182
+ export const SDK_VERSION = "0.42.0";
@@ -408,6 +408,8 @@ export function createConnectHandler(options) {
408
408
  * anyone who used it).
409
409
  */
410
410
  const oneTapEndpoints = session.write ? client.identity.oneTapEndpoints() : null;
411
+ /** See `IdentityResource.publicClientId` - what lets the browser name this application on a hand-off. */
412
+ const clientId = client.identity.publicClientId();
411
413
  const routes = {
412
414
  "GET /session": async (request) => {
413
415
  const token = await session.read(request);
@@ -429,9 +431,10 @@ export function createConnectHandler(options) {
429
431
  // whose shape depends on the answer is a response consumers start
430
432
  // testing for key presence instead of value.
431
433
  oneTap: oneTapEndpoints,
434
+ clientId,
432
435
  });
433
436
  }
434
- return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints });
437
+ return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
435
438
  },
436
439
  "GET /entitlements": async (request) => {
437
440
  const { workspaceId } = await requireWorkspace(request);
@@ -1210,6 +1213,67 @@ export function createConnectHandler(options) {
1210
1213
  const items = await client.sessions.list(await requireToken(request));
1211
1214
  return json({ items });
1212
1215
  },
1216
+ /*
1217
+ * Browser calling - `<ConnectCallButton>`. Three fixed writes and no read:
1218
+ * how a call went is your *server's* question (`client.calls.get`), never
1219
+ * the browser's, so a call log can't be written from what a page claims.
1220
+ *
1221
+ * The workspace and the member both come from the session, as everywhere
1222
+ * here; the browser names only the number.
1223
+ */
1224
+ /*
1225
+ * The one calling *read*, and it is about the member, not about a call:
1226
+ * can they ring anybody from here. A refusal means they lack "Make calls"
1227
+ * - which is an answer, not a failure, so it is returned as one and the
1228
+ * button simply is not offered.
1229
+ */
1230
+ "GET /calls/availability": async (request) => {
1231
+ await requireWorkspace(request);
1232
+ const asUser = await requireToken(request);
1233
+ try {
1234
+ return json(await client.calls.getCallingAvailability({ asUser }));
1235
+ }
1236
+ catch (error) {
1237
+ if (error instanceof ConnectError && error.status === 403) {
1238
+ return json(NO_CALLING_PERMISSION);
1239
+ }
1240
+ throw error;
1241
+ }
1242
+ },
1243
+ "POST /calls/browser-token": async (request) => {
1244
+ await requireWorkspace(request);
1245
+ const asUser = await requireToken(request);
1246
+ return json(await client.calls.getBrowserCallingToken({ asUser }));
1247
+ },
1248
+ "POST /calls/start": async (request) => {
1249
+ await requireWorkspace(request);
1250
+ const asUser = await requireToken(request);
1251
+ const body = (await request.json().catch(() => null));
1252
+ if (typeof body?.phone !== "string") {
1253
+ throw new ConnectError({
1254
+ status: 400,
1255
+ code: "validation_error",
1256
+ message: "phone is required",
1257
+ endpoint: "/calls/start",
1258
+ });
1259
+ }
1260
+ return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
1261
+ },
1262
+ "POST /calls/cancel": async (request) => {
1263
+ await requireWorkspace(request);
1264
+ const asUser = await requireToken(request);
1265
+ const body = (await request.json().catch(() => null));
1266
+ if (typeof body?.callId !== "string") {
1267
+ throw new ConnectError({
1268
+ status: 400,
1269
+ code: "validation_error",
1270
+ message: "callId is required",
1271
+ endpoint: "/calls/cancel",
1272
+ });
1273
+ }
1274
+ await client.calls.cancelBrowserCall({ callId: body.callId, asUser });
1275
+ return json({ cancelled: true });
1276
+ },
1213
1277
  "POST /sessions/revoke": async (request) => {
1214
1278
  const body = (await request.json().catch(() => null));
1215
1279
  if (!body?.sessionId) {
@@ -1495,6 +1559,18 @@ export function createConnectHandler(options) {
1495
1559
  }
1496
1560
  };
1497
1561
  }
1562
+ /** What a member without "Make calls" is told - see `GET /calls/availability`. */
1563
+ const NO_CALLING_PERMISSION = {
1564
+ status: "no_permission",
1565
+ number: null,
1566
+ available: 0,
1567
+ availableMinutes: 0,
1568
+ ratePaisePerMinute: 0,
1569
+ lowBalance: false,
1570
+ canTopUp: false,
1571
+ canManageNumber: false,
1572
+ offer: null,
1573
+ };
1498
1574
  function json(body, status = 200) {
1499
1575
  return new Response(JSON.stringify(body), {
1500
1576
  status,
@@ -1,4 +1,5 @@
1
1
  import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
2
3
  /**
3
4
  * Every call that arrived on a channel assigned to your application.
4
5
  *
@@ -50,4 +51,61 @@ export class CallsResource {
50
51
  path: `/calls/${encodeURIComponent(callId)}`,
51
52
  });
52
53
  }
54
+ /*
55
+ * Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
56
+ *
57
+ * All three need the `calls:place` scope **and** a signed-in member
58
+ * (`asUser`), whose own "Make calls" permission Connect checks. None works
59
+ * server-to-server: a call is placed by a person, from the workspace's own
60
+ * number, and is not recorded. Read how one went with `get(callId)`, which
61
+ * needs `calls:read` - a call your application placed is readable by it.
62
+ */
63
+ /**
64
+ * Whether the member can call, and if not why - what `<ConnectCallButton>`
65
+ * renders from. Throws `ConnectError` 403 for a member without "Make
66
+ * calls"; the bridge turns that into `status: "no_permission"`.
67
+ */
68
+ getCallingAvailability(input) {
69
+ return request(this.transport, {
70
+ method: "GET",
71
+ path: "/calls/availability",
72
+ headers: actingAs(input.asUser),
73
+ });
74
+ }
75
+ /** A carrier token the member's browser signs in to the phone network with. */
76
+ getBrowserCallingToken(input) {
77
+ return request(this.transport, {
78
+ method: "POST",
79
+ path: "/calls/browser-token",
80
+ json: {},
81
+ headers: actingAs(input.asUser),
82
+ });
83
+ }
84
+ /**
85
+ * Approves one call from the member to `phone` (E.164) and records it.
86
+ *
87
+ * Throws `ConnectError` with status `412` when the workspace has no active
88
+ * number, is not allowed to place calls, or the member is already on one -
89
+ * the message is written to be shown to them.
90
+ */
91
+ startBrowserCall(input) {
92
+ return request(this.transport, {
93
+ method: "POST",
94
+ path: "/calls/start",
95
+ json: { phone: input.phone },
96
+ headers: actingAs(input.asUser),
97
+ });
98
+ }
99
+ /**
100
+ * Closes a call the browser never managed to place - recorded as
101
+ * `unanswered`/`cancelled`. A no-op once the carrier has it.
102
+ */
103
+ async cancelBrowserCall(input) {
104
+ await request(this.transport, {
105
+ method: "POST",
106
+ path: "/calls/cancel",
107
+ json: { callId: input.callId },
108
+ headers: actingAs(input.asUser),
109
+ });
110
+ }
53
111
  }
@@ -89,6 +89,18 @@ export class IdentityResource {
89
89
  authorize.searchParams.set("client_id", this.clientId);
90
90
  return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
91
91
  }
92
+ /**
93
+ * This client's public `client_id`, or `null` when none is configured.
94
+ *
95
+ * The bridge hands it to the browser on `/session` so `openConnect` can name
96
+ * the application on every hand-off without the host passing `clientId` to
97
+ * `<ConnectProvider>` too - Connect only honours a `return_to` it can match
98
+ * against *a client's* registered redirect origins. Public by definition:
99
+ * it already rides on every authorize URL.
100
+ */
101
+ publicClientId() {
102
+ return this.clientId;
103
+ }
92
104
  /** Step 2 - trade the one-time `code` for a session. Server-side only. */
93
105
  async exchangeCode(input) {
94
106
  const wire = await this.post("/sso/token", {
@@ -0,0 +1,54 @@
1
+ import { request } from "../http.js";
2
+ /**
3
+ * Your application's own WhatsApp templates, sent to a workspace's customers
4
+ * through Connect's WhatsApp sender - no WhatsApp Business account of your
5
+ * own.
6
+ *
7
+ * Templates are written and submitted to Meta in Console under your
8
+ * application; you name them here by slug and Connect resolves
9
+ * `app_<yourApplicationKey>_<slug>` from your key, so a key can never send
10
+ * another application's message or one of Connect's own. There is no free
11
+ * text: outside a customer's 24-hour window WhatsApp permits nothing else.
12
+ *
13
+ * Server-to-server only - there is no browser path, by design.
14
+ */
15
+ export class WhatsAppResource {
16
+ transport;
17
+ constructor(transport) {
18
+ this.transport = transport;
19
+ }
20
+ /**
21
+ * Send one template to one customer.
22
+ *
23
+ * Refusals arrive as a `ConnectError` whose `code` says what to tell the
24
+ * person who pressed Send - `template_not_approved`, `consent_required`,
25
+ * `opted_out`, `not_found`. None of them is a fault worth retrying.
26
+ *
27
+ * An `Idempotency-Key` is always sent, as on `otp.send`: a retried send
28
+ * without one is a second message on somebody's phone. Pass your own -
29
+ * derived from what is being sent, e.g. the quote number - to make a
30
+ * retry from a *later* request safe too.
31
+ */
32
+ send(input, options = {}) {
33
+ return request(this.transport, {
34
+ method: "POST",
35
+ path: "/whatsapp/messages",
36
+ json: input,
37
+ headers: { "idempotency-key": options.idempotencyKey ?? crypto.randomUUID() },
38
+ });
39
+ }
40
+ /**
41
+ * Whether a template would send right now, and the values it needs.
42
+ *
43
+ * Ask before offering WhatsApp on a screen: Meta's approval takes minutes
44
+ * to days, and an approved template can be paused later. A `ConnectError`
45
+ * with `status: 404` means your application has no such template.
46
+ */
47
+ template(slug, options = {}) {
48
+ const language = options.language ? `?language=${encodeURIComponent(options.language)}` : "";
49
+ return request(this.transport, {
50
+ method: "GET",
51
+ path: `/whatsapp/templates/${encodeURIComponent(slug)}${language}`,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What Ask AI shows when something fails - a sentence for a person, never the
3
+ * error itself.
4
+ *
5
+ * The raw text is somebody else's diagnostic: `TypeError: Failed to fetch`
6
+ * from the browser, `Connect bridge returned 502` from the provider, a
7
+ * validation message naming a request field. None of it tells the person what
8
+ * happened or what to do, and the last one tells them something about the
9
+ * API they should not need to know. The real error is still on the console of
10
+ * whoever is debugging; this module decides only what is on screen.
11
+ */
12
+ export declare const ASK_AI_GENERIC_FAILURE = "Something went wrong. Please try again.";
13
+ export declare const TOOL_GENERIC_FAILURE = "That step couldn't be completed. Try again in a moment.";
14
+ /**
15
+ * A failure Connect reported inside the stream, after it had opened.
16
+ *
17
+ * Kept apart from a transport failure because its text was written for the
18
+ * person by Connect's own stream error handler, so it may be shown as it is.
19
+ */
20
+ export declare class AskAiStreamError extends Error {
21
+ readonly name = "AskAiStreamError";
22
+ }
23
+ export declare function describeAskAiFailure(cause: unknown): string;
24
+ /**
25
+ * Whether trying the same question again could help.
26
+ *
27
+ * Off for the failures a retry cannot fix - signed out, no access, Ask AI
28
+ * switched off - so the panel does not offer a button that will fail the same
29
+ * way every time.
30
+ */
31
+ export declare function isRetryableAskAiFailure(cause: unknown): boolean;
32
+ /**
33
+ * The failure a tool's *result* reports, or `null` when it succeeded.
34
+ *
35
+ * An application's tool answers `{ isError: true, ... }` rather than throwing,
36
+ * which is MCP's distinction between "the tool refused" and "the call broke".
37
+ * Connect puts its own sentence in `error.message` when it could not reach
38
+ * the application at all; otherwise the application's first text block is its
39
+ * own refusal ("You don't have permission to ..."), written for its own user.
40
+ */
41
+ export declare function toolFailureFromOutput(output: unknown): string | null;
@@ -14,6 +14,11 @@ export type AskAiBlock = {
14
14
  id: string;
15
15
  name: string;
16
16
  input: unknown | null;
17
+ /**
18
+ * Set when the step failed - a sentence for the person, never the raw
19
+ * error. See `toolFailureFromOutput`.
20
+ */
21
+ failure?: string;
17
22
  };
18
23
  export interface AskAiTurn {
19
24
  id: string;