@robono/linked-apps 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/INTEGRATION.md +141 -0
- package/LICENSE +130 -0
- package/README.md +48 -0
- package/dist/delivery.d.ts +39 -0
- package/dist/delivery.js +39 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +325 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.js +1 -0
- package/dist/webhook.d.ts +19 -0
- package/dist/webhook.js +22 -0
- package/package.json +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0-preview.1
|
|
4
|
+
|
|
5
|
+
First distributable developer preview: user approval with PKCE, scoped messaging and media, receipts, secure native adapters, rotating credentials, resumable synchronization, verified background hints, and revocation. Requires an approved Robono registration and an activated environment.
|
package/INTEGRATION.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Integrating an independent app
|
|
2
|
+
|
|
3
|
+
Use a separate registration for development and production. Robono supplies its
|
|
4
|
+
functions URL, client ID and reviewed redirect URI/scopes. Client IDs are public.
|
|
5
|
+
A backend webhook signing secret is confidential and must never enter a mobile bundle.
|
|
6
|
+
|
|
7
|
+
## Link an account
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
|
|
11
|
+
import * as SecureStore from 'expo-secure-store';
|
|
12
|
+
import * as Crypto from 'expo-crypto';
|
|
13
|
+
import { Linking } from 'react-native';
|
|
14
|
+
|
|
15
|
+
const ot = new RobonoLinkedApps({
|
|
16
|
+
functionsUrl: config.robonoFunctionsUrl,
|
|
17
|
+
clientId: config.robonoClientId,
|
|
18
|
+
tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
|
|
19
|
+
crypto: nativeCryptoProvider({
|
|
20
|
+
getRandomBytes: Crypto.getRandomBytes,
|
|
21
|
+
digest: (_, bytes) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes)),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
const link = await ot.beginLink('com.example.app://robono/callback',
|
|
25
|
+
['account:read', 'messages:read', 'messages:send', 'receipts:write']);
|
|
26
|
+
// Keep the verifier/state in Keychain/Keystore across the app switch.
|
|
27
|
+
await SecureStore.setItemAsync('robono.pending-link', JSON.stringify(link.pending));
|
|
28
|
+
await Linking.openURL(link.authorizationUrl);
|
|
29
|
+
// In your callback handler, after matching the registered route:
|
|
30
|
+
const pending = JSON.parse((await SecureStore.getItemAsync('robono.pending-link'))!);
|
|
31
|
+
await ot.completeLink(callbackUrl, pending);
|
|
32
|
+
await SecureStore.deleteItemAsync('robono.pending-link');
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The native adapters are dependency-injected: bare React Native can supply equivalent
|
|
36
|
+
Keychain/Keystore and cryptography implementations. Never use Math.random or ordinary
|
|
37
|
+
AsyncStorage for credentials/verifiers. Choose platform key accessibility according
|
|
38
|
+
to your app's background needs; never opt into backup/sync of account secrets.
|
|
39
|
+
|
|
40
|
+
Register the callback on **both iOS and Android**. Prefer an owned HTTPS universal/app
|
|
41
|
+
link where possible. Private native schemes must be unique reverse-domain names;
|
|
42
|
+
PKCE protects code redemption from interception. Never accept arbitrary callback
|
|
43
|
+
hosts, skip state verification, or copy a Robono phone session into your app.
|
|
44
|
+
|
|
45
|
+
Only one SDK instance owns a connected account's refresh sequence. Multiple runtimes
|
|
46
|
+
or processes must coordinate access to secure storage. A lost refresh response or
|
|
47
|
+
crash before the rotated pair is persisted may require relinking; retrying a spent
|
|
48
|
+
refresh token revokes the grant. Do not automatically repeat token exchanges.
|
|
49
|
+
|
|
50
|
+
## Display and send
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const snapshot = await ot.conversations();
|
|
54
|
+
const page = await ot.messages(conversationId, { limit: 50 });
|
|
55
|
+
// Generate and persist this ID before sending; use it again on every retry.
|
|
56
|
+
const result = await ot.send({ conversationId, messageKind: 'text',
|
|
57
|
+
clientMessageId: durableOutgoingId, textBody: 'Hello' });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Show the allowed tools from each network's capabilities and connection status. The
|
|
61
|
+
server remains authoritative: never infer a successful delivery from a successful
|
|
62
|
+
send acknowledgement. Do not reveal another user's block. The API does not create
|
|
63
|
+
connections, bypass parental permissions, unblock contacts or change network rules.
|
|
64
|
+
|
|
65
|
+
For voice, retain the original recording and its measured duration, request an upload
|
|
66
|
+
ticket, upload the bytes, then send `messageKind:'voice'` with `mediaObjectId:ticket.media.id`.
|
|
67
|
+
Use `uploadBytes(ticket,bytes,mimeType)` or the equivalent native file streaming PUT to
|
|
68
|
+
`ticket.signedUrl`. Send **no account Authorization header** to that URL. If a send
|
|
69
|
+
times out, query/retry with the same message ID; do not record/upload/send a new copy
|
|
70
|
+
just because confirmation was lost. The existing server checks enforce format, size,
|
|
71
|
+
ownership, conversation membership and voice restrictions.
|
|
72
|
+
|
|
73
|
+
Download only when needed. A playback URL has an expiry, is sensitive, and may become
|
|
74
|
+
unavailable according to network retention. Only call `receipt(id,'heard')` after
|
|
75
|
+
actual listening. Use `delivered` and `read` for their corresponding user states.
|
|
76
|
+
|
|
77
|
+
## Synchronize
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const watching = ot.watch({
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
cursorStore: persistedCursors, // keyed by grant ID
|
|
84
|
+
onResync: async () => reconcile(await ot.conversations()),
|
|
85
|
+
onEvents: async events => {
|
|
86
|
+
// Deduplicate message IDs. Fetch only relevant current pages; remove unavailable
|
|
87
|
+
// or deleted content. Do not notify for receipt-only events or your own messages.
|
|
88
|
+
await applyAuthoritativeChanges(events);
|
|
89
|
+
},
|
|
90
|
+
onError: reportTransientSyncFailure,
|
|
91
|
+
});
|
|
92
|
+
// Stop on background/logout/account change. Resume on foreground or a push hint.
|
|
93
|
+
controller.abort();
|
|
94
|
+
await watching;
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`onResync` must reconcile active conversations and any visible message pages. Clear
|
|
98
|
+
stale local histories when `history_cleared_at` advances or a deleted conversation is
|
|
99
|
+
reported. Walkie-talkie replacement can invalidate an earlier message even if its ID
|
|
100
|
+
was previously downloaded. Read all pages with `has_more` using both returned page
|
|
101
|
+
markers; preserve microsecond timestamps as strings. Do not synthesize timestamps.
|
|
102
|
+
|
|
103
|
+
Polling stores its cursor only after successful processing. A callback can run again
|
|
104
|
+
after a crash, so make local writes idempotent. Failed callbacks keep the previous
|
|
105
|
+
cursor. Abort does not advance past unfinished work. Grant changes terminate the old
|
|
106
|
+
watcher; cursors and cached data must never move to a different account.
|
|
107
|
+
|
|
108
|
+
## Background notices
|
|
109
|
+
|
|
110
|
+
1. Configure a reviewed HTTPS backend webhook with Robono.
|
|
111
|
+
2. Your backend associates a grant with an authenticated account in your own service.
|
|
112
|
+
Verify it by calling `account.get` using that account's linked token; never trust
|
|
113
|
+
a client-supplied grant ID to route another user's notifications.
|
|
114
|
+
3. Call `configureBackgroundDelivery(true)` after the mapping is ready.
|
|
115
|
+
4. Verify the **raw** webhook body before parsing/queuing it:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { verifyWebhook } from '@robono/linked-apps';
|
|
119
|
+
const hint = await verifyWebhook({ body: rawBody,
|
|
120
|
+
timestamp: request.headers.get('x-robono-timestamp')!,
|
|
121
|
+
signature: request.headers.get('x-robono-signature')!,
|
|
122
|
+
secret: backendOnlySigningKey });
|
|
123
|
+
// Check expected client_id; atomically deduplicate hint.id and enqueue the hint.
|
|
124
|
+
// Return 2xx only after the queue write commits.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The signature covers `timestamp + '.' + rawBody` with HMAC-SHA256, accepted within
|
|
128
|
+
five minutes. Delivery IDs remain stable across retries; attempt timestamps change.
|
|
129
|
+
Reject replayed IDs after verification, check client identity, limit request size and
|
|
130
|
+
retain dedup IDs for at least seven days. Invalid signatures receive no processing.
|
|
131
|
+
|
|
132
|
+
5. Send a content-free sync hint through **your app's** APNs/FCM/Expo credentials.
|
|
133
|
+
6. On wake/resume, call `events(cursor)` or restart `watch`; do not trust a webhook's
|
|
134
|
+
cursor as proof you already applied earlier events. Never overwrite the local
|
|
135
|
+
cursor with the pushed cursor. Keep Robono notifications as the default alert and
|
|
136
|
+
companion hints silent to avoid duplicate sounds.
|
|
137
|
+
|
|
138
|
+
Disconnect: call `ot.disconnect()`; clear your local cached account content and stop
|
|
139
|
+
watchers after revocation succeeds. A lost response may require retry/relink UI; a
|
|
140
|
+
user can always revoke from Robono's Linked apps screen. Account deletion, revoked
|
|
141
|
+
permissions or `invalid_token` must not trigger repeated unauthorized background work.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
ROBONO SDK LICENSE AGREEMENT
|
|
2
|
+
Version 1.0, July 20, 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Add to Loop LLC. All rights reserved.
|
|
5
|
+
|
|
6
|
+
This Robono SDK License Agreement (the "Agreement") is between Add to Loop LLC,
|
|
7
|
+
operating the Robono service ("Robono"), and the person or entity that downloads,
|
|
8
|
+
installs, copies, or uses the Software ("You"). By downloading, installing,
|
|
9
|
+
copying, or using the Software, You accept this Agreement. If You do not accept
|
|
10
|
+
it, do not use the Software.
|
|
11
|
+
|
|
12
|
+
1. Definitions
|
|
13
|
+
|
|
14
|
+
"Software" means this Robono software development kit, its documentation, and
|
|
15
|
+
any updates Robono provides under this Agreement.
|
|
16
|
+
|
|
17
|
+
"Robono Service" means the messaging interoperability, routing, policy,
|
|
18
|
+
conversion, delivery, and related services operated or expressly authorized by
|
|
19
|
+
Robono.
|
|
20
|
+
|
|
21
|
+
"Application" means a product or service owned or controlled by You that is
|
|
22
|
+
authorized to connect to the Robono Service.
|
|
23
|
+
|
|
24
|
+
2. Limited License
|
|
25
|
+
|
|
26
|
+
Subject to this Agreement, Robono grants You a limited, non-exclusive,
|
|
27
|
+
non-transferable, non-sublicensable, revocable license to:
|
|
28
|
+
|
|
29
|
+
(a) install, copy, and use the Software internally to develop, test, and
|
|
30
|
+
operate an Application that communicates with the Robono Service; and
|
|
31
|
+
|
|
32
|
+
(b) reproduce and distribute the portions of the Software that are necessarily
|
|
33
|
+
included in Your Application, solely in compiled, minified, bundled, or object
|
|
34
|
+
code form and solely as needed for that Application to communicate with the
|
|
35
|
+
Robono Service.
|
|
36
|
+
|
|
37
|
+
No right is granted to distribute the Software as a standalone product or SDK.
|
|
38
|
+
|
|
39
|
+
3. Restrictions
|
|
40
|
+
|
|
41
|
+
You may not, and may not permit another person to:
|
|
42
|
+
|
|
43
|
+
(a) sell, rent, lease, sublicense, publish, or redistribute the Software as a
|
|
44
|
+
standalone product, library, SDK, or service;
|
|
45
|
+
|
|
46
|
+
(b) use the Software or a modified or derivative version of it to develop,
|
|
47
|
+
provide, or support a product or service that competes with the Robono Service;
|
|
48
|
+
|
|
49
|
+
(c) use the Software to bypass Robono, avoid applicable usage controls or fees,
|
|
50
|
+
or access the Robono Service without authorization;
|
|
51
|
+
|
|
52
|
+
(d) remove or alter copyright, trademark, attribution, or proprietary notices;
|
|
53
|
+
|
|
54
|
+
(e) reverse engineer, decompile, or disassemble the Software except to the
|
|
55
|
+
limited extent applicable law expressly prohibits this restriction; or
|
|
56
|
+
|
|
57
|
+
(f) use the Software in violation of applicable law, the rights of another
|
|
58
|
+
person, or the terms governing access to the Robono Service.
|
|
59
|
+
|
|
60
|
+
4. Ownership and Reserved Rights
|
|
61
|
+
|
|
62
|
+
The Software is licensed, not sold. Robono and its licensors retain all right,
|
|
63
|
+
title, and interest in the Software and all related intellectual property.
|
|
64
|
+
Except for the limited rights expressly granted in Section 2, all rights are
|
|
65
|
+
reserved.
|
|
66
|
+
|
|
67
|
+
No patent license is granted except the minimum license, if any, necessarily
|
|
68
|
+
required to exercise the rights expressly granted in Section 2, and that
|
|
69
|
+
limited patent license applies only while the Software is used with an
|
|
70
|
+
authorized Robono Service.
|
|
71
|
+
|
|
72
|
+
5. Service Access
|
|
73
|
+
|
|
74
|
+
This Agreement does not itself grant an account, API credentials, service
|
|
75
|
+
availability, support, or a right to access the Robono Service. Service access
|
|
76
|
+
may be governed by additional terms, plans, limits, and policies. Robono may
|
|
77
|
+
suspend or terminate access to protect users, systems, legal compliance, or the
|
|
78
|
+
integrity of the Robono Service.
|
|
79
|
+
|
|
80
|
+
6. Updates and Support
|
|
81
|
+
|
|
82
|
+
Robono is not required to provide maintenance, updates, support, or continued
|
|
83
|
+
availability of any version of the Software unless separately agreed in
|
|
84
|
+
writing.
|
|
85
|
+
|
|
86
|
+
7. Term and Termination
|
|
87
|
+
|
|
88
|
+
This Agreement continues until terminated. Your rights terminate automatically
|
|
89
|
+
if You breach this Agreement. On termination, You must stop using the Software
|
|
90
|
+
and delete copies under Your control, except for copies necessarily contained
|
|
91
|
+
in previously distributed Applications. Sections 3, 4, 8, 9, 10, and 11
|
|
92
|
+
survive termination.
|
|
93
|
+
|
|
94
|
+
8. Disclaimer of Warranties
|
|
95
|
+
|
|
96
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS" AND
|
|
97
|
+
"AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, EXPRESS, IMPLIED, OR
|
|
98
|
+
STATUTORY, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
99
|
+
PURPOSE, TITLE, AND NON-INFRINGEMENT.
|
|
100
|
+
|
|
101
|
+
9. Limitation of Liability
|
|
102
|
+
|
|
103
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, ROBONO WILL NOT BE LIABLE FOR INDIRECT,
|
|
104
|
+
INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS
|
|
105
|
+
OF DATA, REVENUE, PROFITS, GOODWILL, OR BUSINESS OPPORTUNITY, ARISING FROM OR
|
|
106
|
+
RELATING TO THE SOFTWARE. ROBONO'S TOTAL LIABILITY RELATING TO THE SOFTWARE
|
|
107
|
+
WILL NOT EXCEED THE GREATER OF ONE HUNDRED U.S. DOLLARS (US $100) OR THE AMOUNT
|
|
108
|
+
YOU PAID ROBONO FOR THE SOFTWARE DURING THE TWELVE MONTHS BEFORE THE EVENT
|
|
109
|
+
GIVING RISE TO THE CLAIM. THESE LIMITATIONS APPLY TO THE MAXIMUM EXTENT
|
|
110
|
+
PERMITTED BY LAW.
|
|
111
|
+
|
|
112
|
+
10. Governing Law
|
|
113
|
+
|
|
114
|
+
This Agreement is governed by the laws of the State of Florida, excluding its
|
|
115
|
+
conflict-of-laws rules. The state and federal courts located in Palm Beach
|
|
116
|
+
County, Florida have exclusive jurisdiction, and each party consents to their
|
|
117
|
+
jurisdiction and venue.
|
|
118
|
+
|
|
119
|
+
11. General
|
|
120
|
+
|
|
121
|
+
You may not assign this Agreement without Robono's prior written consent.
|
|
122
|
+
Robono may assign this Agreement in connection with a reorganization, transfer
|
|
123
|
+
of intellectual property, financing, merger, acquisition, or sale of all or
|
|
124
|
+
substantially all of the relevant assets. If a provision is unenforceable, it
|
|
125
|
+
will be enforced to the maximum extent permitted and the remaining provisions
|
|
126
|
+
will remain effective. A waiver must be in writing. This Agreement is the
|
|
127
|
+
entire agreement concerning the Software unless You and Robono sign a separate
|
|
128
|
+
written agreement that expressly replaces it.
|
|
129
|
+
|
|
130
|
+
Questions about this license may be submitted at https://robono.com/contact.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @robono/linked-apps
|
|
2
|
+
|
|
3
|
+
Robono's user-authorized messaging API client, for independent applications.
|
|
4
|
+
Developer preview. Requires a reviewed app registration and an activated Robono environment.
|
|
5
|
+
SDK installation alone does not enable account access.
|
|
6
|
+
|
|
7
|
+
Supports PKCE account linking, rotating credentials, typed conversations/messages,
|
|
8
|
+
text/voice/file sending, signed media upload/download, receipts, resumable change
|
|
9
|
+
watching, backend delivery subscription, webhook verification and revocation.
|
|
10
|
+
No dependency on Matrix, TalkOpen, Loop, Expo or a specific UI framework.
|
|
11
|
+
|
|
12
|
+
Build/test from the repository:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm --prefix packages/linked-apps test
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Use one `RobonoLinkedApps` instance per connected account. Inject a Keychain/Keystore
|
|
19
|
+
`TokenStore` and native `CryptoProvider`; adapters are exported. Browser/server
|
|
20
|
+
runtimes may use WebCrypto. Securely persist pending PKCE verifier/state before
|
|
21
|
+
opening Robono; validate the callback using `completeLink`.
|
|
22
|
+
|
|
23
|
+
[Integration guide](./INTEGRATION.md) ·
|
|
24
|
+
[API reference](https://robono.com/api-reference-viewer.html?spec=linked-apps) ·
|
|
25
|
+
[Developer documentation](https://robono.com/linked-apps)
|
|
26
|
+
|
|
27
|
+
The watcher is explicit and cancellable. Stop on background/logout; resume on a push
|
|
28
|
+
hint or foreground. It checkpoints only after successful consumer callbacks, so
|
|
29
|
+
callbacks must tolerate redelivery. Backends verify webhook signatures and deduplicate
|
|
30
|
+
stable delivery IDs before pushing through their own app credentials. Never ship a
|
|
31
|
+
webhook signing key in mobile code. Hints contain no message text and should not
|
|
32
|
+
produce sounds by themselves.
|
|
33
|
+
|
|
34
|
+
Send retries must retain the original UUID. Refresh requests are serialized within
|
|
35
|
+
one instance; coordinate across processes yourself. A lost refresh response can
|
|
36
|
+
require relinking because reusing a spent refresh token revokes the grant.
|
|
37
|
+
|
|
38
|
+
## Preview distribution
|
|
39
|
+
|
|
40
|
+
The official distribution channel is npm. Install this exact preview version:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install --save-exact @robono/linked-apps@0.1.0-preview.1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Preview releases use the `preview` tag. Pin the version and commit your lockfile; review release notes and test before updating. The Robono website does not distribute SDK archives.
|
|
47
|
+
|
|
48
|
+
The SDK is covered by the included Robono SDK License Agreement. This package contains no client secret or credentials. Robono supplies the registered client ID and environment URL separately.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CryptoProvider, TokenStore } from "./index.js";
|
|
2
|
+
export interface LinkedEvent {
|
|
3
|
+
id: string;
|
|
4
|
+
type: "messages.changed" | "receipts.changed" | "conversations.changed";
|
|
5
|
+
conversation_id: string | null;
|
|
6
|
+
message_id: string | null;
|
|
7
|
+
created_at: string;
|
|
8
|
+
}
|
|
9
|
+
export interface EventPage {
|
|
10
|
+
events: LinkedEvent[];
|
|
11
|
+
cursor: string;
|
|
12
|
+
has_more: boolean;
|
|
13
|
+
resync_required?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface CursorStore {
|
|
16
|
+
/** Key by grant ID; never share a cursor between accounts or grants. */
|
|
17
|
+
get(grantId: string): Promise<string | null>;
|
|
18
|
+
set(grantId: string, cursor: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface WatchOptions {
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
cursorStore: CursorStore;
|
|
23
|
+
onEvents(events: LinkedEvent[]): Promise<void>;
|
|
24
|
+
/** Reconcile the conversation list and currently open message pages. */
|
|
25
|
+
onResync(): Promise<void>;
|
|
26
|
+
onError?(error: unknown): void;
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function waitForPoll(ms: number, signal: AbortSignal): Promise<void>;
|
|
30
|
+
/** Dependency injection keeps the SDK usable in Expo and bare native apps. */
|
|
31
|
+
export declare function secureTokenStore(storage: {
|
|
32
|
+
getItemAsync(key: string): Promise<string | null>;
|
|
33
|
+
setItemAsync(key: string, value: string): Promise<void>;
|
|
34
|
+
deleteItemAsync(key: string): Promise<void>;
|
|
35
|
+
}, key: string): TokenStore;
|
|
36
|
+
export declare function nativeCryptoProvider(native: {
|
|
37
|
+
getRandomBytes(length: number): Uint8Array;
|
|
38
|
+
digest(algorithm: "SHA-256", bytes: Uint8Array): Promise<ArrayBuffer>;
|
|
39
|
+
}): CryptoProvider;
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export async function waitForPoll(ms, signal) {
|
|
2
|
+
if (signal.aborted)
|
|
3
|
+
return;
|
|
4
|
+
await new Promise((resolve) => {
|
|
5
|
+
const done = () => {
|
|
6
|
+
clearTimeout(timer);
|
|
7
|
+
signal.removeEventListener("abort", done);
|
|
8
|
+
resolve();
|
|
9
|
+
};
|
|
10
|
+
const timer = setTimeout(done, ms);
|
|
11
|
+
signal.addEventListener("abort", done, { once: true });
|
|
12
|
+
if (signal.aborted)
|
|
13
|
+
done();
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Dependency injection keeps the SDK usable in Expo and bare native apps. */
|
|
17
|
+
export function secureTokenStore(storage, key) {
|
|
18
|
+
if (!/^[A-Za-z0-9._-]{1,120}$/.test(key)) {
|
|
19
|
+
throw new Error("Use a unique, stable secure-storage key for each account.");
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
async get() {
|
|
23
|
+
const value = await storage.getItemAsync(key);
|
|
24
|
+
return value ? JSON.parse(value) : null;
|
|
25
|
+
},
|
|
26
|
+
async set(tokens) {
|
|
27
|
+
if (tokens)
|
|
28
|
+
await storage.setItemAsync(key, JSON.stringify(tokens));
|
|
29
|
+
else
|
|
30
|
+
await storage.deleteItemAsync(key);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function nativeCryptoProvider(native) {
|
|
35
|
+
return {
|
|
36
|
+
randomBytes: (length) => native.getRandomBytes(length),
|
|
37
|
+
sha256: async (bytes) => new Uint8Array(await native.digest("SHA-256", bytes)),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type EventPage, type WatchOptions } from "./delivery.js";
|
|
2
|
+
import type { ConversationPage, Message, MessagePage, Receipt, UploadTicket } from "./types.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export * from "./delivery.js";
|
|
5
|
+
export * from "./webhook.js";
|
|
6
|
+
export type Scope = "account:read" | "messages:read" | "messages:send" | "receipts:write";
|
|
7
|
+
export type Tokens = {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
refreshToken: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
refreshExpiresAt: string;
|
|
12
|
+
grantId: string;
|
|
13
|
+
scopes: Scope[];
|
|
14
|
+
};
|
|
15
|
+
export interface TokenStore {
|
|
16
|
+
/** Use the device Keychain/Keystore. Never AsyncStorage, URLs or logs. */
|
|
17
|
+
get(): Promise<Tokens | null>;
|
|
18
|
+
set(tokens: Tokens | null): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface CryptoProvider {
|
|
21
|
+
randomBytes(length: number): Uint8Array;
|
|
22
|
+
sha256(bytes: Uint8Array): Promise<Uint8Array>;
|
|
23
|
+
}
|
|
24
|
+
export interface PendingLink {
|
|
25
|
+
clientId: string;
|
|
26
|
+
redirectUri: string;
|
|
27
|
+
verifier: string;
|
|
28
|
+
state: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SendMessage {
|
|
32
|
+
conversationId: string;
|
|
33
|
+
messageKind: "text" | "voice" | "image" | "video" | "document";
|
|
34
|
+
/** Persist this UUID with the outgoing message. Reuse it for every retry. */
|
|
35
|
+
clientMessageId: string;
|
|
36
|
+
textBody?: string;
|
|
37
|
+
mediaObjectId?: string;
|
|
38
|
+
replyToMessageId?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface MediaUpload {
|
|
41
|
+
conversationId: string;
|
|
42
|
+
mediaKind: "voice" | "image" | "video" | "document";
|
|
43
|
+
mimeType: string;
|
|
44
|
+
sizeBytes: number;
|
|
45
|
+
fileName?: string;
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
waveform?: number[];
|
|
48
|
+
}
|
|
49
|
+
export type ApiRecord = Record<string, unknown>;
|
|
50
|
+
export declare class RobonoLinkedAppError extends Error {
|
|
51
|
+
code: string;
|
|
52
|
+
status: number;
|
|
53
|
+
constructor(code: string, status: number);
|
|
54
|
+
}
|
|
55
|
+
export declare function webCryptoProvider(): CryptoProvider;
|
|
56
|
+
/** One instance per connected account. Watching starts only when explicitly requested. */
|
|
57
|
+
export declare class RobonoLinkedApps {
|
|
58
|
+
private readonly options;
|
|
59
|
+
private readonly baseUrl;
|
|
60
|
+
private readonly fetcher;
|
|
61
|
+
private refreshPending;
|
|
62
|
+
constructor(options: {
|
|
63
|
+
functionsUrl: string;
|
|
64
|
+
clientId: string;
|
|
65
|
+
tokenStore: TokenStore;
|
|
66
|
+
crypto?: CryptoProvider;
|
|
67
|
+
fetch?: typeof fetch;
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
});
|
|
70
|
+
private post;
|
|
71
|
+
beginLink(redirectUri: string, scopes: Scope[]): Promise<{
|
|
72
|
+
authorizationUrl: string;
|
|
73
|
+
requestId: string;
|
|
74
|
+
pending: {
|
|
75
|
+
clientId: string;
|
|
76
|
+
redirectUri: string;
|
|
77
|
+
verifier: string;
|
|
78
|
+
state: string;
|
|
79
|
+
expiresAt: string;
|
|
80
|
+
};
|
|
81
|
+
}>;
|
|
82
|
+
completeLink(callback: string, pending: PendingLink): Promise<Tokens>;
|
|
83
|
+
private save;
|
|
84
|
+
refresh(): Promise<Tokens>;
|
|
85
|
+
private call;
|
|
86
|
+
account(): Promise<{
|
|
87
|
+
account: {
|
|
88
|
+
id: string;
|
|
89
|
+
display_name: string | null;
|
|
90
|
+
phone: string;
|
|
91
|
+
};
|
|
92
|
+
grant_id: string;
|
|
93
|
+
scopes: Scope[];
|
|
94
|
+
}>;
|
|
95
|
+
conversations(): Promise<ConversationPage>;
|
|
96
|
+
messages(conversationId: string, page?: {
|
|
97
|
+
before?: string;
|
|
98
|
+
beforeId?: string;
|
|
99
|
+
limit?: number;
|
|
100
|
+
}): Promise<MessagePage>;
|
|
101
|
+
message(conversationId: string, messageId: string): Promise<{
|
|
102
|
+
message: Message;
|
|
103
|
+
messages: Message[];
|
|
104
|
+
}>;
|
|
105
|
+
send(input: SendMessage): Promise<{
|
|
106
|
+
message: Message;
|
|
107
|
+
}>;
|
|
108
|
+
createUpload(input: MediaUpload): Promise<UploadTicket>;
|
|
109
|
+
/** Upload exactly the bytes described in the ticket. No Robono bearer is sent to storage. */
|
|
110
|
+
uploadBytes(ticket: UploadTicket, bytes: Uint8Array, mimeType: string, signal?: AbortSignal): Promise<void>;
|
|
111
|
+
download(messageId: string): Promise<{
|
|
112
|
+
signedUrl: string;
|
|
113
|
+
expiresIn: number;
|
|
114
|
+
}>;
|
|
115
|
+
receipt(messageId: string, state: "delivered" | "read" | "heard"): Promise<{
|
|
116
|
+
receipts: Receipt[];
|
|
117
|
+
}>;
|
|
118
|
+
events(cursor: string | null, signal?: AbortSignal): Promise<EventPage>;
|
|
119
|
+
configureBackgroundDelivery(enabled: boolean): Promise<{
|
|
120
|
+
enabled: boolean;
|
|
121
|
+
}>;
|
|
122
|
+
/** Run while active; stop on background, resume on foreground or a push hint.
|
|
123
|
+
* Callbacks must tolerate redelivery. Cursor advances only after success.
|
|
124
|
+
*/
|
|
125
|
+
watch(options: WatchOptions): Promise<void>;
|
|
126
|
+
disconnect(): Promise<void>;
|
|
127
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { waitForPoll } from "./delivery.js";
|
|
2
|
+
export * from "./types.js";
|
|
3
|
+
export * from "./delivery.js";
|
|
4
|
+
export * from "./webhook.js";
|
|
5
|
+
export class RobonoLinkedAppError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, status) {
|
|
9
|
+
super(`Robono request failed (${code})`);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const scopeNames = new Set([
|
|
15
|
+
"account:read",
|
|
16
|
+
"messages:read",
|
|
17
|
+
"messages:send",
|
|
18
|
+
"receipts:write",
|
|
19
|
+
]);
|
|
20
|
+
const hex = (bytes) => Array.from(bytes, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
21
|
+
function base64url(bytes) {
|
|
22
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
23
|
+
let result = "", bits = 0, value = 0;
|
|
24
|
+
for (const byte of bytes) {
|
|
25
|
+
value = (value << 8) | byte;
|
|
26
|
+
bits += 8;
|
|
27
|
+
while (bits >= 6) {
|
|
28
|
+
bits -= 6;
|
|
29
|
+
result += alphabet[(value >>> bits) & 63];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (bits)
|
|
33
|
+
result += alphabet[(value << (6 - bits)) & 63];
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
export function webCryptoProvider() {
|
|
37
|
+
if (!globalThis.crypto?.subtle) {
|
|
38
|
+
throw new Error("A secure CryptoProvider is required on this platform.");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
randomBytes: (length) => globalThis.crypto.getRandomValues(new Uint8Array(length)),
|
|
42
|
+
sha256: async (bytes) => new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(bytes))),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** One instance per connected account. Watching starts only when explicitly requested. */
|
|
46
|
+
export class RobonoLinkedApps {
|
|
47
|
+
options;
|
|
48
|
+
baseUrl;
|
|
49
|
+
fetcher;
|
|
50
|
+
refreshPending = null;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
const url = new URL(options.functionsUrl);
|
|
54
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
55
|
+
!(url.protocol === "https:" ||
|
|
56
|
+
(url.protocol === "http:" &&
|
|
57
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
|
|
58
|
+
throw new Error("Use an HTTPS Robono functions URL (HTTP is allowed only for local tests).");
|
|
59
|
+
}
|
|
60
|
+
this.baseUrl = url.toString().replace(/\/$/, "");
|
|
61
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
62
|
+
}
|
|
63
|
+
async post(endpoint, body, token, signal) {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const cancel = () => controller.abort();
|
|
66
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
67
|
+
if (signal?.aborted)
|
|
68
|
+
controller.abort();
|
|
69
|
+
const timeout = setTimeout(cancel, this.options.timeoutMs ?? 30_000);
|
|
70
|
+
try {
|
|
71
|
+
const response = await this.fetcher(`${this.baseUrl}/${endpoint}`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
redirect: "error",
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
headers: {
|
|
76
|
+
"content-type": "application/json",
|
|
77
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
const data = await response.json();
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const code = typeof data.error === "string"
|
|
84
|
+
? data.error
|
|
85
|
+
: data.error?.code ?? "request_failed";
|
|
86
|
+
throw new RobonoLinkedAppError(code, response.status);
|
|
87
|
+
}
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
signal?.removeEventListener("abort", cancel);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async beginLink(redirectUri, scopes) {
|
|
96
|
+
if (!scopes.length || scopes.some((scope) => !scopeNames.has(scope))) {
|
|
97
|
+
throw new Error("Select supported permissions.");
|
|
98
|
+
}
|
|
99
|
+
const crypto = this.options.crypto ?? webCryptoProvider();
|
|
100
|
+
const verifier = hex(crypto.randomBytes(32)), state = hex(crypto.randomBytes(32));
|
|
101
|
+
const challenge = base64url(await crypto.sha256(new TextEncoder().encode(verifier)));
|
|
102
|
+
const result = await this.post("linked-app-authorize", {
|
|
103
|
+
client_id: this.options.clientId,
|
|
104
|
+
redirect_uri: redirectUri,
|
|
105
|
+
scopes,
|
|
106
|
+
state,
|
|
107
|
+
code_challenge: challenge,
|
|
108
|
+
code_challenge_method: "S256",
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
authorizationUrl: result.authorization_url,
|
|
112
|
+
requestId: result.request_id,
|
|
113
|
+
pending: {
|
|
114
|
+
clientId: this.options.clientId,
|
|
115
|
+
redirectUri,
|
|
116
|
+
verifier,
|
|
117
|
+
state,
|
|
118
|
+
expiresAt: result.expires_at,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async completeLink(callback, pending) {
|
|
123
|
+
const url = new URL(callback), expected = new URL(pending.redirectUri);
|
|
124
|
+
if (pending.clientId !== this.options.clientId ||
|
|
125
|
+
Date.parse(pending.expiresAt) <= Date.now() ||
|
|
126
|
+
url.protocol !== expected.protocol || url.host !== expected.host ||
|
|
127
|
+
url.pathname !== expected.pathname ||
|
|
128
|
+
url.username || url.password || url.hash ||
|
|
129
|
+
url.searchParams.getAll("state").length !== 1 ||
|
|
130
|
+
url.searchParams.get("state") !== pending.state ||
|
|
131
|
+
[...expected.searchParams].some(([k, v]) => url.searchParams.get(k) !== v)) {
|
|
132
|
+
throw new RobonoLinkedAppError("invalid_callback", 400);
|
|
133
|
+
}
|
|
134
|
+
if (url.searchParams.has("error")) {
|
|
135
|
+
throw new RobonoLinkedAppError("access_denied", 400);
|
|
136
|
+
}
|
|
137
|
+
const code = url.searchParams.get("code");
|
|
138
|
+
if (url.searchParams.getAll("code").length !== 1 || !code ||
|
|
139
|
+
!/^rlc_[a-f0-9]{64}$/.test(code)) {
|
|
140
|
+
throw new RobonoLinkedAppError("invalid_callback", 400);
|
|
141
|
+
}
|
|
142
|
+
return this.save(await this.post("linked-app-token", {
|
|
143
|
+
client_id: this.options.clientId,
|
|
144
|
+
grant_type: "authorization_code",
|
|
145
|
+
redirect_uri: pending.redirectUri,
|
|
146
|
+
code,
|
|
147
|
+
code_verifier: pending.verifier,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
async save(wire) {
|
|
151
|
+
const tokens = {
|
|
152
|
+
accessToken: wire.access_token,
|
|
153
|
+
refreshToken: wire.refresh_token,
|
|
154
|
+
expiresAt: Date.now() + wire.expires_in * 1000,
|
|
155
|
+
refreshExpiresAt: wire.refresh_expires_at,
|
|
156
|
+
grantId: wire.grant_id,
|
|
157
|
+
scopes: wire.scope.split(" "),
|
|
158
|
+
};
|
|
159
|
+
await this.options.tokenStore.set(tokens);
|
|
160
|
+
return tokens;
|
|
161
|
+
}
|
|
162
|
+
async refresh() {
|
|
163
|
+
if (this.refreshPending)
|
|
164
|
+
return this.refreshPending;
|
|
165
|
+
this.refreshPending = (async () => {
|
|
166
|
+
const tokens = await this.options.tokenStore.get();
|
|
167
|
+
if (!tokens)
|
|
168
|
+
throw new RobonoLinkedAppError("not_connected", 401);
|
|
169
|
+
try {
|
|
170
|
+
return await this.save(await this.post("linked-app-token", {
|
|
171
|
+
client_id: this.options.clientId,
|
|
172
|
+
grant_type: "refresh_token",
|
|
173
|
+
refresh_token: tokens.refreshToken,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof RobonoLinkedAppError &&
|
|
178
|
+
error.code === "invalid_grant")
|
|
179
|
+
await this.options.tokenStore.set(null);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
try {
|
|
184
|
+
return await this.refreshPending;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
this.refreshPending = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async call(operation, input = {}, signal) {
|
|
191
|
+
let tokens = await this.options.tokenStore.get();
|
|
192
|
+
if (!tokens)
|
|
193
|
+
throw new RobonoLinkedAppError("not_connected", 401);
|
|
194
|
+
if (tokens.expiresAt <= Date.now() + 30_000)
|
|
195
|
+
tokens = await this.refresh();
|
|
196
|
+
// No blind retries: a timeout may occur after a successful send. The host
|
|
197
|
+
// retains the original clientMessageId and explicitly retries that message.
|
|
198
|
+
return this.post("linked-app-api", { version: 1, operation, input }, tokens.accessToken, signal);
|
|
199
|
+
}
|
|
200
|
+
account() {
|
|
201
|
+
return this.call("account.get");
|
|
202
|
+
}
|
|
203
|
+
conversations() {
|
|
204
|
+
return this.call("conversations.list");
|
|
205
|
+
}
|
|
206
|
+
messages(conversationId, page = {}) {
|
|
207
|
+
return this.call("messages.list", { conversationId, ...page });
|
|
208
|
+
}
|
|
209
|
+
message(conversationId, messageId) {
|
|
210
|
+
return this.call("messages.get", { conversationId, messageId });
|
|
211
|
+
}
|
|
212
|
+
send(input) {
|
|
213
|
+
if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(input.clientMessageId))
|
|
214
|
+
throw new Error("A stable clientMessageId UUID is required.");
|
|
215
|
+
return this.call("messages.send", {
|
|
216
|
+
...input,
|
|
217
|
+
inputSource: "external",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
createUpload(input) {
|
|
221
|
+
return this.call("media.upload", input);
|
|
222
|
+
}
|
|
223
|
+
/** Upload exactly the bytes described in the ticket. No Robono bearer is sent to storage. */
|
|
224
|
+
async uploadBytes(ticket, bytes, mimeType, signal) {
|
|
225
|
+
const url = new URL(ticket.signedUrl);
|
|
226
|
+
if (url.protocol !== "https:" ||
|
|
227
|
+
url.origin !== new URL(this.baseUrl).origin ||
|
|
228
|
+
!url.pathname.startsWith("/storage/v1/object/upload/sign/"))
|
|
229
|
+
throw new Error("Invalid upload destination");
|
|
230
|
+
if (ticket.media.size_bytes !== undefined &&
|
|
231
|
+
bytes.byteLength !== ticket.media.size_bytes)
|
|
232
|
+
throw new Error("Upload size does not match ticket");
|
|
233
|
+
if (ticket.media.mime_type && mimeType !== ticket.media.mime_type) {
|
|
234
|
+
throw new Error("Upload type does not match ticket");
|
|
235
|
+
}
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const cancel = () => controller.abort();
|
|
238
|
+
const timer = setTimeout(cancel, 120_000);
|
|
239
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
240
|
+
if (signal?.aborted)
|
|
241
|
+
controller.abort();
|
|
242
|
+
try {
|
|
243
|
+
const response = await this.fetcher(url.toString(), {
|
|
244
|
+
method: "PUT",
|
|
245
|
+
redirect: "error",
|
|
246
|
+
signal: controller.signal,
|
|
247
|
+
headers: { "content-type": mimeType, "x-upsert": "false" },
|
|
248
|
+
body: new Uint8Array(bytes),
|
|
249
|
+
});
|
|
250
|
+
await response.body?.cancel();
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
throw new RobonoLinkedAppError("upload_failed", response.status);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
signal?.removeEventListener("abort", cancel);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
download(messageId) {
|
|
261
|
+
return this.call("media.download", { messageId });
|
|
262
|
+
}
|
|
263
|
+
receipt(messageId, state) {
|
|
264
|
+
return this.call("receipts.update", {
|
|
265
|
+
messageId,
|
|
266
|
+
heard: state === "heard",
|
|
267
|
+
deliveredOnly: state === "delivered",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
events(cursor, signal) {
|
|
271
|
+
return this.call("events.poll", { cursor }, signal);
|
|
272
|
+
}
|
|
273
|
+
configureBackgroundDelivery(enabled) {
|
|
274
|
+
return this.call("delivery.configure", { enabled });
|
|
275
|
+
}
|
|
276
|
+
/** Run while active; stop on background, resume on foreground or a push hint.
|
|
277
|
+
* Callbacks must tolerate redelivery. Cursor advances only after success.
|
|
278
|
+
*/
|
|
279
|
+
async watch(options) {
|
|
280
|
+
const tokens = await this.options.tokenStore.get();
|
|
281
|
+
if (!tokens)
|
|
282
|
+
throw new RobonoLinkedAppError("not_connected", 401);
|
|
283
|
+
const grantId = tokens.grantId;
|
|
284
|
+
let cursor = await options.cursorStore.get(grantId);
|
|
285
|
+
let failures = 0;
|
|
286
|
+
while (!options.signal.aborted) {
|
|
287
|
+
try {
|
|
288
|
+
const current = await this.options.tokenStore.get();
|
|
289
|
+
if (!current || current.grantId !== grantId) {
|
|
290
|
+
throw new RobonoLinkedAppError("not_connected", 401);
|
|
291
|
+
}
|
|
292
|
+
const page = await this.events(cursor, options.signal);
|
|
293
|
+
if (options.signal.aborted)
|
|
294
|
+
return;
|
|
295
|
+
if (cursor === null || page.resync_required)
|
|
296
|
+
await options.onResync();
|
|
297
|
+
else if (page.events.length)
|
|
298
|
+
await options.onEvents(page.events);
|
|
299
|
+
if (options.signal.aborted)
|
|
300
|
+
return;
|
|
301
|
+
await options.cursorStore.set(grantId, page.cursor);
|
|
302
|
+
cursor = page.cursor;
|
|
303
|
+
failures = 0;
|
|
304
|
+
if (page.has_more)
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (options.signal.aborted)
|
|
309
|
+
return;
|
|
310
|
+
if (error instanceof RobonoLinkedAppError &&
|
|
311
|
+
[400, 401, 403].includes(error.status))
|
|
312
|
+
throw error;
|
|
313
|
+
options.onError?.(error);
|
|
314
|
+
failures++;
|
|
315
|
+
}
|
|
316
|
+
await waitForPoll(failures
|
|
317
|
+
? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5))
|
|
318
|
+
: Math.max(1000, options.pollIntervalMs ?? 1000), options.signal);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async disconnect() {
|
|
322
|
+
await this.call("connection.revoke");
|
|
323
|
+
await this.options.tokenStore.set(null);
|
|
324
|
+
}
|
|
325
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export interface Receipt {
|
|
2
|
+
id?: string;
|
|
3
|
+
recipient_user_id?: string | null;
|
|
4
|
+
child_app_connection_id?: string | null;
|
|
5
|
+
delivered_at?: string | null;
|
|
6
|
+
seen_at?: string | null;
|
|
7
|
+
read_at?: string | null;
|
|
8
|
+
heard_at?: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface Media {
|
|
11
|
+
id: string;
|
|
12
|
+
media_kind?: string;
|
|
13
|
+
mime_type?: string;
|
|
14
|
+
size_bytes?: number;
|
|
15
|
+
file_name?: string | null;
|
|
16
|
+
duration_ms?: number | null;
|
|
17
|
+
width?: number | null;
|
|
18
|
+
height?: number | null;
|
|
19
|
+
waveform?: number[] | null;
|
|
20
|
+
upload_state?: string;
|
|
21
|
+
server_expires_at?: string | null;
|
|
22
|
+
deleted_from_storage_at?: string | null;
|
|
23
|
+
signed_url?: string;
|
|
24
|
+
signed_url_expires_at?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Message {
|
|
27
|
+
id: string;
|
|
28
|
+
conversation_id: string;
|
|
29
|
+
sender_kind: string;
|
|
30
|
+
sender_user_id?: string | null;
|
|
31
|
+
child_app_connection_id?: string | null;
|
|
32
|
+
client_message_id?: string | null;
|
|
33
|
+
message_kind: "text" | "voice" | "image" | "video" | "document";
|
|
34
|
+
text_body?: string | null;
|
|
35
|
+
media_object_id?: string | null;
|
|
36
|
+
created_at: string;
|
|
37
|
+
active_in_walkie_talkie?: boolean;
|
|
38
|
+
reply_to_message_id?: string | null;
|
|
39
|
+
local_only?: boolean;
|
|
40
|
+
local_send_state?: string;
|
|
41
|
+
media_objects: Media | null;
|
|
42
|
+
message_receipts: Receipt[];
|
|
43
|
+
message_reactions: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
user_id: string;
|
|
46
|
+
emoji: string;
|
|
47
|
+
created_at: string;
|
|
48
|
+
updated_at: string;
|
|
49
|
+
}>;
|
|
50
|
+
message_speech_artifacts: Array<{
|
|
51
|
+
id: string;
|
|
52
|
+
artifact_kind: string;
|
|
53
|
+
language_code?: string;
|
|
54
|
+
text_body?: string | null;
|
|
55
|
+
media_objects: Media | null;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
export interface Conversation {
|
|
59
|
+
id: string;
|
|
60
|
+
mode: "history" | "walkie_talkie";
|
|
61
|
+
title?: string | null;
|
|
62
|
+
updated_at?: string;
|
|
63
|
+
unread_count?: number;
|
|
64
|
+
blocked_by_you: boolean;
|
|
65
|
+
user_preferences: {
|
|
66
|
+
muted_at?: string | null;
|
|
67
|
+
blurred_at?: string | null;
|
|
68
|
+
history_cleared_at?: string | null;
|
|
69
|
+
};
|
|
70
|
+
conversation_members: Array<{
|
|
71
|
+
id: string;
|
|
72
|
+
member_kind: string;
|
|
73
|
+
user_id?: string | null;
|
|
74
|
+
child_app_connection_id?: string | null;
|
|
75
|
+
left_at?: string | null;
|
|
76
|
+
app_users: {
|
|
77
|
+
display_name?: string | null;
|
|
78
|
+
phone_e164?: string;
|
|
79
|
+
avatar_url?: string | null;
|
|
80
|
+
deleted_at?: string | null;
|
|
81
|
+
};
|
|
82
|
+
child_app_connections: {
|
|
83
|
+
child_original_name?: string;
|
|
84
|
+
status?: string;
|
|
85
|
+
disconnected_at?: string | null;
|
|
86
|
+
external_account_deleted_at?: string | null;
|
|
87
|
+
external_avatar_url?: string | null;
|
|
88
|
+
capabilities?: Record<string, unknown>;
|
|
89
|
+
monitoring_disclosure?: unknown;
|
|
90
|
+
child_apps: {
|
|
91
|
+
name?: string;
|
|
92
|
+
};
|
|
93
|
+
} | null;
|
|
94
|
+
}>;
|
|
95
|
+
}
|
|
96
|
+
export interface ConversationPage {
|
|
97
|
+
conversations: Conversation[];
|
|
98
|
+
messages: Message[];
|
|
99
|
+
deletedConversations: Array<{
|
|
100
|
+
conversationId: string;
|
|
101
|
+
historyClearedAt: string | null;
|
|
102
|
+
}>;
|
|
103
|
+
}
|
|
104
|
+
export interface MessagePage {
|
|
105
|
+
messages: Message[];
|
|
106
|
+
has_more: boolean;
|
|
107
|
+
next_before: string | null;
|
|
108
|
+
next_before_id: string | null;
|
|
109
|
+
}
|
|
110
|
+
export interface UploadTicket {
|
|
111
|
+
media: Media;
|
|
112
|
+
signedUrl: string;
|
|
113
|
+
token: string;
|
|
114
|
+
path: string;
|
|
115
|
+
thumbnailUpload: {
|
|
116
|
+
signedUrl: string;
|
|
117
|
+
token: string;
|
|
118
|
+
path: string;
|
|
119
|
+
} | null;
|
|
120
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type DeliveryHint = {
|
|
2
|
+
version: 1;
|
|
3
|
+
id: string;
|
|
4
|
+
type: "sync_available";
|
|
5
|
+
client_id: string;
|
|
6
|
+
grant_id: string;
|
|
7
|
+
cursor: string;
|
|
8
|
+
};
|
|
9
|
+
/** SERVER ONLY. Verify the exact raw request body, before parsing or using it.
|
|
10
|
+
* Keep signing keys on your backend. Deduplicate id after durably queuing the hint.
|
|
11
|
+
*/
|
|
12
|
+
export declare function verifyWebhook(input: {
|
|
13
|
+
body: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
signature: string;
|
|
16
|
+
secret: string;
|
|
17
|
+
nowMs?: number;
|
|
18
|
+
crypto?: Crypto;
|
|
19
|
+
}): Promise<DeliveryHint>;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** SERVER ONLY. Verify the exact raw request body, before parsing or using it.
|
|
2
|
+
* Keep signing keys on your backend. Deduplicate id after durably queuing the hint.
|
|
3
|
+
*/
|
|
4
|
+
export async function verifyWebhook(input) {
|
|
5
|
+
const engine = input.crypto ?? globalThis.crypto;
|
|
6
|
+
const timestamp = Number(input.timestamp);
|
|
7
|
+
if (input.secret.length < 32 || !/^\d{10}$/.test(input.timestamp) ||
|
|
8
|
+
Math.abs((input.nowMs ?? Date.now()) / 1000 - timestamp) > 300 ||
|
|
9
|
+
!/^v1=[a-f0-9]{64}$/.test(input.signature) || input.body.length > 8192)
|
|
10
|
+
throw new Error("Invalid webhook");
|
|
11
|
+
const key = await engine.subtle.importKey("raw", new TextEncoder().encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
12
|
+
const bytes = Uint8Array.from(input.signature.slice(3).match(/../g), (part) => parseInt(part, 16));
|
|
13
|
+
if (!await engine.subtle.verify("HMAC", key, bytes, new TextEncoder().encode(`${input.timestamp}.${input.body}`)))
|
|
14
|
+
throw new Error("Invalid webhook");
|
|
15
|
+
const data = JSON.parse(input.body);
|
|
16
|
+
if (data.version !== 1 || data.type !== "sync_available" ||
|
|
17
|
+
typeof data.id !== "string" ||
|
|
18
|
+
typeof data.client_id !== "string" || typeof data.grant_id !== "string" ||
|
|
19
|
+
typeof data.cursor !== "string" || !/^\d+$/.test(data.cursor))
|
|
20
|
+
throw new Error("Invalid webhook");
|
|
21
|
+
return data;
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@robono/linked-apps",
|
|
3
|
+
"version": "0.1.0-preview.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "User-authorized companion-app access to Robono (development preview)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"INTEGRATION.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"prepublishOnly": "npm test"
|
|
27
|
+
},
|
|
28
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
29
|
+
"homepage": "https://robono.com/linked-apps",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://robono.com/contact",
|
|
32
|
+
"email": "support@robono.com"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"tag": "preview"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"robono",
|
|
40
|
+
"messaging",
|
|
41
|
+
"linked-apps",
|
|
42
|
+
"sdk",
|
|
43
|
+
"typescript"
|
|
44
|
+
],
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "Add to Loop LLC",
|
|
47
|
+
"email": "support@robono.com",
|
|
48
|
+
"url": "https://robono.com"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/IMLX16/robono-sdks.git",
|
|
53
|
+
"directory": "packages/linked-apps"
|
|
54
|
+
},
|
|
55
|
+
"sideEffects": false,
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"typescript": "6.0.3"
|
|
58
|
+
}
|
|
59
|
+
}
|