whisp-js 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -2
- package/dist/cjs/client.d.ts +164 -0
- package/dist/cjs/client.js +460 -0
- package/dist/cjs/constants.d.ts +43 -0
- package/dist/cjs/constants.js +59 -0
- package/dist/cjs/errors.d.ts +96 -0
- package/dist/cjs/errors.js +115 -0
- package/dist/cjs/index.d.ts +19 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/listener.d.ts +93 -0
- package/dist/cjs/listener.js +468 -0
- package/dist/cjs/media.d.ts +40 -0
- package/dist/cjs/media.js +181 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/refs.d.ts +4 -0
- package/dist/cjs/refs.js +38 -0
- package/dist/cjs/runtime.d.ts +24 -0
- package/dist/cjs/runtime.js +93 -0
- package/dist/cjs/sse.d.ts +32 -0
- package/dist/cjs/sse.js +105 -0
- package/dist/cjs/transport.d.ts +61 -0
- package/dist/cjs/transport.js +181 -0
- package/dist/cjs/types.d.ts +516 -0
- package/dist/cjs/types.js +14 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/webhook.d.ts +27 -0
- package/dist/cjs/webhook.js +128 -0
- package/dist/esm/client.d.ts +164 -0
- package/dist/esm/client.js +456 -0
- package/dist/esm/constants.d.ts +43 -0
- package/dist/esm/constants.js +56 -0
- package/dist/esm/errors.d.ts +96 -0
- package/dist/esm/errors.js +107 -0
- package/dist/esm/index.d.ts +19 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/listener.d.ts +93 -0
- package/dist/esm/listener.js +463 -0
- package/dist/esm/media.d.ts +40 -0
- package/dist/esm/media.js +175 -0
- package/dist/esm/refs.d.ts +4 -0
- package/dist/esm/refs.js +33 -0
- package/dist/esm/runtime.d.ts +24 -0
- package/dist/esm/runtime.js +84 -0
- package/dist/esm/sse.d.ts +32 -0
- package/dist/esm/sse.js +100 -0
- package/dist/esm/transport.d.ts +61 -0
- package/dist/esm/transport.js +172 -0
- package/dist/esm/types.d.ts +516 -0
- package/dist/esm/types.js +13 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/webhook.d.ts +27 -0
- package/dist/esm/webhook.js +123 -0
- package/package.json +37 -5
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { type EventHandlers } from './listener.js';
|
|
2
|
+
import { type MediaSource } from './media.js';
|
|
3
|
+
import { type Timers } from './runtime.js';
|
|
4
|
+
import type { Chat, ChatListQuery, ChatMessagesQuery, Message, PhoneCheck, ProjectInfo, PublicEventType, SendMessageBody, SendStrategy, WebhookEvent, WhatsAppNumber } from './types.js';
|
|
5
|
+
import { type VerifyWebhookInput } from './webhook.js';
|
|
6
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
8
|
+
/** `downloadMedia` waits for a file being prepared at most this long. */
|
|
9
|
+
export declare const MEDIA_WAIT_MAX_MS = 60000;
|
|
10
|
+
export interface WhispOptions {
|
|
11
|
+
/** The project token (`wsp_live_…`). Default: WHISP_API_TOKEN, read on each call until set. */
|
|
12
|
+
token?: string;
|
|
13
|
+
/** Phones (`972501234567`, `+972 50-123-4567`) or number ids (`num_7Hq2kD3x`); none = the whole project. */
|
|
14
|
+
phone?: string | readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* How a pool picks its sender: 'healthiest' (the server's default), 'random' or 'round-robin'. Only used
|
|
17
|
+
* with several numbers or none.
|
|
18
|
+
*/
|
|
19
|
+
strategy?: SendStrategy;
|
|
20
|
+
/** Keep a recipient on the number that last talked to them. Default true. */
|
|
21
|
+
sticky?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Send from a number only while it has sent fewer than this many messages in the last 24 hours (1 to
|
|
24
|
+
* 10,000), on every send of this instance. A number's own daily cap still applies: the lower one wins. The
|
|
25
|
+
* server counts and enforces it; a pool skips numbers at their cap, a single number answers
|
|
26
|
+
* DAILY_CAP_REACHED.
|
|
27
|
+
*/
|
|
28
|
+
dailyCap?: number;
|
|
29
|
+
/** Default: WHISP_API_URL, else https://api.whisp.lue.my. */
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
/** Per attempt. Default 30,000 ms. */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Attempts after the first, for network errors, timeouts, 429 and 5xx. Default 2. */
|
|
34
|
+
maxRetries?: number;
|
|
35
|
+
/** Resume the event stream after this cursor (a saved `whisp.cursor`). */
|
|
36
|
+
cursor?: string;
|
|
37
|
+
/** Any WHATWG fetch. Default: the global one, looked up on each call. */
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* The SDK refuses to run in a browser, where every visitor could copy the
|
|
41
|
+
* token. Even with this set, the API answers 403 TOKEN_IN_BROWSER.
|
|
42
|
+
*/
|
|
43
|
+
dangerouslyAllowBrowser?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Test hooks; not part of the published options. */
|
|
46
|
+
export interface InternalWhispOptions extends WhispOptions {
|
|
47
|
+
timers?: Timers;
|
|
48
|
+
/** Where errors go when nobody listens to `'error'`. Default console.error. */
|
|
49
|
+
report?: (error: Error) => void;
|
|
50
|
+
}
|
|
51
|
+
export interface SendOptions {
|
|
52
|
+
/** A `msg_` id or a WhatsApp id of a message in the same chat. */
|
|
53
|
+
replyTo?: string;
|
|
54
|
+
/** Your own id for this send (1-128 characters), echoed on the message and its events. */
|
|
55
|
+
reference?: string;
|
|
56
|
+
/** Wait for the outcome: true is 15 s, or a number of ms from 1,000 to 20,000. */
|
|
57
|
+
wait?: boolean | number;
|
|
58
|
+
/** This send's daily cap (1 to 10,000), in place of the instance's `dailyCap`. */
|
|
59
|
+
dailyCap?: number;
|
|
60
|
+
/** Makes retries safe across your own restarts too. Default: a random UUID per call, kept across its retries. */
|
|
61
|
+
idempotencyKey?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface SendFileOptions extends SendOptions {
|
|
64
|
+
caption?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface MessagesQuery {
|
|
67
|
+
reference: string;
|
|
68
|
+
}
|
|
69
|
+
export interface DownloadOptions {
|
|
70
|
+
variant?: 'original' | 'thumb';
|
|
71
|
+
}
|
|
72
|
+
/** One page of a list, and the cursor of the next one (null on the last). */
|
|
73
|
+
export interface Page<T> {
|
|
74
|
+
items: T[];
|
|
75
|
+
nextCursor: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** Await it for the first page, or `for await` it for every item across pages. */
|
|
78
|
+
export type PageResult<T> = Promise<Page<T>> & AsyncIterable<T>;
|
|
79
|
+
export interface VerifyResult extends ProjectInfo {
|
|
80
|
+
numbers: WhatsAppNumber[];
|
|
81
|
+
}
|
|
82
|
+
export declare class Whisp {
|
|
83
|
+
#private;
|
|
84
|
+
constructor(options?: WhispOptions);
|
|
85
|
+
/**
|
|
86
|
+
* The cursor after the last event delivered to handlers (6.5): save it and
|
|
87
|
+
* pass it as `cursor` to resume after a restart. Not an event's `evt_` id,
|
|
88
|
+
* which is `event.event.id` in a handler. Before the first event it is
|
|
89
|
+
* where the stream started (its `ready`), so a save then resumes from
|
|
90
|
+
* there; null until the stream first opens, unless `cursor` was given
|
|
91
|
+
* (sdk-reconnect-without-cursor-loses-events).
|
|
92
|
+
*/
|
|
93
|
+
get cursor(): string | null;
|
|
94
|
+
/** A copy that sends from, reads and listens on this one number (a phone or a `num_` id). */
|
|
95
|
+
number(phoneOrId: string): Whisp;
|
|
96
|
+
/**
|
|
97
|
+
* Checks the token and the instance's numbers (GET /project, then GET
|
|
98
|
+
* /numbers): throws NUMBER_NOT_IN_PROJECT listing every phone or id that
|
|
99
|
+
* isn't the token's project's. Optional: the first send or listen reports
|
|
100
|
+
* the same.
|
|
101
|
+
*/
|
|
102
|
+
verify(): Promise<VerifyResult>;
|
|
103
|
+
/** Sends text (`POST /messages`) from the instance's number, pool or project. Resolves the message (`pending`, or its status after `wait`). */
|
|
104
|
+
send(to: string, text: string, options?: SendOptions): Promise<Message>;
|
|
105
|
+
sendImage(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
106
|
+
sendVideo(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
107
|
+
sendDocument(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
108
|
+
sendVoice(to: string, media: MediaSource, options?: SendOptions): Promise<Message>;
|
|
109
|
+
/**
|
|
110
|
+
* `POST /messages` with a body you build (5.6). The instance's `from`,
|
|
111
|
+
* `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
|
|
112
|
+
*/
|
|
113
|
+
sendMessage(body: SendMessageBody, options?: Pick<SendOptions, 'idempotencyKey'>): Promise<Message>;
|
|
114
|
+
/** Every number of the project, any status. */
|
|
115
|
+
numbers(): Promise<WhatsAppNumber[]>;
|
|
116
|
+
/** One message of the project's numbers; 404 MESSAGE_NOT_FOUND otherwise. */
|
|
117
|
+
message(messageId: string): Promise<Message>;
|
|
118
|
+
/** What earlier sends with this `reference` made, newest first, at most 50. */
|
|
119
|
+
messages(query: MessagesQuery): Promise<Message[]>;
|
|
120
|
+
/** Is this phone on WhatsApp, asked through the instance's number. */
|
|
121
|
+
check(phone: string): Promise<PhoneCheck>;
|
|
122
|
+
/** The number's chats, newest activity first. */
|
|
123
|
+
chats(query?: ChatListQuery): PageResult<Chat>;
|
|
124
|
+
/** One chat: a phone, a `cht_` id or a group JID (a merged-away id resolves to the chat it joined). */
|
|
125
|
+
chat(chat: string): Promise<Chat>;
|
|
126
|
+
/**
|
|
127
|
+
* A chat's messages, newest first, history included. Each next page is
|
|
128
|
+
* older (`nextCursor` goes in `before`); after an `after` request, each next
|
|
129
|
+
* page is newer (`nextCursor` goes in `after`).
|
|
130
|
+
*/
|
|
131
|
+
chatMessages(chat: string, query?: ChatMessagesQuery): PageResult<Message>;
|
|
132
|
+
/**
|
|
133
|
+
* A message's file as a fetch `Response` (12.3). Asks the API for a
|
|
134
|
+
* 5-minute link with the token, then fetches the link with no token and no
|
|
135
|
+
* redirects. A file still being prepared (409 MEDIA_NOT_READY with
|
|
136
|
+
* Retry-After) is waited for, up to 60 s.
|
|
137
|
+
*/
|
|
138
|
+
downloadMedia(message: Message | string, options?: DownloadOptions): Promise<Response>;
|
|
139
|
+
/**
|
|
140
|
+
* Calls `handler` for each event of this name (12.4): any of the twelve
|
|
141
|
+
* types, `'message'` (= `message.received`), `'*'`, or the stream's own
|
|
142
|
+
* `'open'`, `'close'`, `'gap'` and `'error'`. The first event handler
|
|
143
|
+
* opens the stream. Returns `off()`.
|
|
144
|
+
*/
|
|
145
|
+
on(event: '*', handler: EventHandlers['*']): () => void;
|
|
146
|
+
on(event: 'message', handler: EventHandlers['message']): () => void;
|
|
147
|
+
on(event: 'open', handler: EventHandlers['open']): () => void;
|
|
148
|
+
on(event: 'close', handler: EventHandlers['close']): () => void;
|
|
149
|
+
on(event: 'gap', handler: EventHandlers['gap']): () => void;
|
|
150
|
+
on(event: 'error', handler: EventHandlers['error']): () => void;
|
|
151
|
+
on<T extends PublicEventType>(event: T, handler: EventHandlers[T]): () => void;
|
|
152
|
+
/** Removes a handler; the stream closes with the last event handler. */
|
|
153
|
+
off(event: '*', handler: EventHandlers['*']): void;
|
|
154
|
+
off(event: 'message', handler: EventHandlers['message']): void;
|
|
155
|
+
off(event: 'open', handler: EventHandlers['open']): void;
|
|
156
|
+
off(event: 'close', handler: EventHandlers['close']): void;
|
|
157
|
+
off(event: 'gap', handler: EventHandlers['gap']): void;
|
|
158
|
+
off(event: 'error', handler: EventHandlers['error']): void;
|
|
159
|
+
off<T extends PublicEventType>(event: T, handler: EventHandlers[T]): void;
|
|
160
|
+
/** Ends the stream, forgets every handler, and stops retries still waiting for their turn. */
|
|
161
|
+
close(): void;
|
|
162
|
+
/** The standalone `verifyWebhook`, for convenience. */
|
|
163
|
+
verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>;
|
|
164
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Whisp` client (phase2-design 12.2, 12.3).
|
|
3
|
+
*
|
|
4
|
+
* Nothing is called at construction. The token is read when a call needs it
|
|
5
|
+
* (`token`, else WHISP_API_TOKEN), and so is the base URL (`baseUrl`, else
|
|
6
|
+
* WHISP_API_URL, else the hosted API), so a value set after import still
|
|
7
|
+
* counts. Phones are checked at once: a typo throws here, not on the first
|
|
8
|
+
* send.
|
|
9
|
+
*
|
|
10
|
+
* An instance sends from its `phone`: one number, a pool (an array, picked
|
|
11
|
+
* server-side by `strategy`, `healthiest` unless set, sticky by default), or
|
|
12
|
+
* none, the whole project (its default number, else every number as a
|
|
13
|
+
* pool). `number(x)` returns a copy scoped to one number, which the reads
|
|
14
|
+
* that need one number require. `dailyCap` (phase3-design 12.4) goes with
|
|
15
|
+
* every send; the server counts and enforces it, never the SDK, since
|
|
16
|
+
* several app instances share one number.
|
|
17
|
+
*/
|
|
18
|
+
import { API_TOKEN_ENV, API_URL_ENV, DAILY_CAP_MAX, DAILY_CAP_MIN, DEFAULT_BASE_URL, FROM_MAX, SEND_STRATEGIES, TOKEN_PATTERN, WAIT_DEFAULT_MS, } from './constants.js';
|
|
19
|
+
import { localError, WhispError } from './errors.js';
|
|
20
|
+
import { Listener } from './listener.js';
|
|
21
|
+
import { prepareFile, putUpload } from './media.js';
|
|
22
|
+
import { requireNumberRef } from './refs.js';
|
|
23
|
+
import { isBrowser, randomId, readEnv, realTimers, sleep, userAgent } from './runtime.js';
|
|
24
|
+
import { request } from './transport.js';
|
|
25
|
+
import { verifyWebhook } from './webhook.js';
|
|
26
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
export const DEFAULT_MAX_RETRIES = 2;
|
|
28
|
+
/** `downloadMedia` waits for a file being prepared at most this long. */
|
|
29
|
+
export const MEDIA_WAIT_MAX_MS = 60_000;
|
|
30
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
const invalidResponse = (what) => new WhispError({ code: 'INVALID_RESPONSE', message: `The API's answer to ${what} is not in the documented shape` });
|
|
32
|
+
/** The body as T when `check` holds, else INVALID_RESPONSE. The API only grows (4.1), so only what the SDK reads is checked. */
|
|
33
|
+
const expect = (body, what, check) => {
|
|
34
|
+
if (!isRecord(body) || !check(body))
|
|
35
|
+
throw invalidResponse(what);
|
|
36
|
+
return body;
|
|
37
|
+
};
|
|
38
|
+
const segment = (value) => encodeURIComponent(value);
|
|
39
|
+
const positiveInt = (value, fallback, name, min, max) => {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return fallback;
|
|
42
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
43
|
+
throw localError('OPTIONS_INVALID', `${name} must be a whole number from ${min} to ${max}`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
/** A `dailyCap` as the API takes it (1 to 10,000, whole), or undefined when none was given. Checked before any call. */
|
|
48
|
+
const checkDailyCap = (value, name) => {
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < DAILY_CAP_MIN || value > DAILY_CAP_MAX) {
|
|
52
|
+
throw localError('OPTIONS_INVALID', `${name} must be a whole number from ${DAILY_CAP_MIN} to 10,000`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const checkBaseUrl = (value, source) => {
|
|
57
|
+
let protocol = '';
|
|
58
|
+
try {
|
|
59
|
+
protocol = new URL(value).protocol;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Reported below.
|
|
63
|
+
}
|
|
64
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
65
|
+
throw localError('OPTIONS_INVALID', `${source} must be an http(s) URL`);
|
|
66
|
+
}
|
|
67
|
+
return value.replace(/\/+$/, '');
|
|
68
|
+
};
|
|
69
|
+
const normalizeFrom = (phone) => {
|
|
70
|
+
if (phone === undefined)
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof phone === 'string')
|
|
73
|
+
return requireNumberRef(phone, 'phone');
|
|
74
|
+
if (!Array.isArray(phone))
|
|
75
|
+
throw localError('OPTIONS_INVALID', 'phone must be a string or an array of strings');
|
|
76
|
+
if (phone.length === 0)
|
|
77
|
+
throw localError('OPTIONS_INVALID', 'phone is an empty list; leave it out to use the whole project');
|
|
78
|
+
const refs = [...new Set(phone.map(ref => requireNumberRef(ref, 'phone')))];
|
|
79
|
+
if (refs.length > FROM_MAX)
|
|
80
|
+
throw localError('OPTIONS_INVALID', `A pool has at most ${FROM_MAX} numbers`);
|
|
81
|
+
return refs;
|
|
82
|
+
};
|
|
83
|
+
const waitMs = (wait) => wait === true ? WAIT_DEFAULT_MS : typeof wait === 'number' && Number.isFinite(wait) ? wait : 0;
|
|
84
|
+
const defaultReport = (error) => {
|
|
85
|
+
console.error(`[whisp-sdk] ${error.name}: ${error.message}`);
|
|
86
|
+
};
|
|
87
|
+
export class Whisp {
|
|
88
|
+
#options;
|
|
89
|
+
#scope;
|
|
90
|
+
#timeoutMs;
|
|
91
|
+
#maxRetries;
|
|
92
|
+
#timers;
|
|
93
|
+
#listener;
|
|
94
|
+
/** Aborted by close(): retries waiting for their turn give up. Replaced, so later calls retry again. */
|
|
95
|
+
#retries = new AbortController();
|
|
96
|
+
constructor(options = {}) {
|
|
97
|
+
const internal = options;
|
|
98
|
+
if (isBrowser() && options.dangerouslyAllowBrowser !== true) {
|
|
99
|
+
throw localError('TOKEN_IN_BROWSER', 'The Whisp SDK runs on your server: in a browser, every visitor could copy the token and send from your numbers.');
|
|
100
|
+
}
|
|
101
|
+
if (options.strategy !== undefined && !SEND_STRATEGIES.includes(options.strategy)) {
|
|
102
|
+
throw localError('OPTIONS_INVALID', "strategy must be 'healthiest', 'random' or 'round-robin'");
|
|
103
|
+
}
|
|
104
|
+
if (options.sticky !== undefined && typeof options.sticky !== 'boolean') {
|
|
105
|
+
throw localError('OPTIONS_INVALID', 'sticky must be true or false');
|
|
106
|
+
}
|
|
107
|
+
checkDailyCap(options.dailyCap, 'dailyCap');
|
|
108
|
+
if (options.baseUrl !== undefined)
|
|
109
|
+
checkBaseUrl(options.baseUrl, 'baseUrl');
|
|
110
|
+
if (options.cursor !== undefined && (typeof options.cursor !== 'string' || options.cursor === '')) {
|
|
111
|
+
throw localError('OPTIONS_INVALID', 'cursor must be a cursor string saved from whisp.cursor');
|
|
112
|
+
}
|
|
113
|
+
this.#options = { ...internal };
|
|
114
|
+
this.#scope = { from: normalizeFrom(options.phone) };
|
|
115
|
+
this.#timeoutMs = positiveInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs', 1, 600_000);
|
|
116
|
+
this.#maxRetries = positiveInt(options.maxRetries, DEFAULT_MAX_RETRIES, 'maxRetries', 0, 10);
|
|
117
|
+
this.#timers = internal.timers ?? realTimers;
|
|
118
|
+
const numbers = this.#scope.from === null ? null : [this.#scope.from].flat();
|
|
119
|
+
this.#listener = new Listener({
|
|
120
|
+
resolve: () => this.#target(),
|
|
121
|
+
numbers,
|
|
122
|
+
cursor: options.cursor ?? null,
|
|
123
|
+
connectTimeoutMs: this.#timeoutMs,
|
|
124
|
+
timers: this.#timers,
|
|
125
|
+
report: internal.report ?? defaultReport,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The cursor after the last event delivered to handlers (6.5): save it and
|
|
130
|
+
* pass it as `cursor` to resume after a restart. Not an event's `evt_` id,
|
|
131
|
+
* which is `event.event.id` in a handler. Before the first event it is
|
|
132
|
+
* where the stream started (its `ready`), so a save then resumes from
|
|
133
|
+
* there; null until the stream first opens, unless `cursor` was given
|
|
134
|
+
* (sdk-reconnect-without-cursor-loses-events).
|
|
135
|
+
*/
|
|
136
|
+
get cursor() {
|
|
137
|
+
return this.#listener.cursor;
|
|
138
|
+
}
|
|
139
|
+
/** A copy that sends from, reads and listens on this one number (a phone or a `num_` id). */
|
|
140
|
+
number(phoneOrId) {
|
|
141
|
+
const ref = requireNumberRef(phoneOrId, 'number()');
|
|
142
|
+
return new Whisp({ ...this.#options, phone: ref });
|
|
143
|
+
}
|
|
144
|
+
// Connection ---------------------------------------------------------------------------------
|
|
145
|
+
#fetch() {
|
|
146
|
+
const custom = this.#options.fetch;
|
|
147
|
+
// Looked up per call, so a fetch installed after import is used; called unbound, or window.fetch throws.
|
|
148
|
+
return custom ? (input, init) => custom(input, init) : (input, init) => globalThis.fetch(input, init);
|
|
149
|
+
}
|
|
150
|
+
#target() {
|
|
151
|
+
const token = this.#options.token?.trim() || readEnv(API_TOKEN_ENV);
|
|
152
|
+
if (!token) {
|
|
153
|
+
throw localError('TOKEN_MISSING', `No token: pass { token } or set ${API_TOKEN_ENV} (the project's API & SDK tab)`);
|
|
154
|
+
}
|
|
155
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
156
|
+
throw localError('TOKEN_INVALID', "The token is not a Whisp project token (wsp_live_…). Copy it again from the project's API & SDK tab.");
|
|
157
|
+
}
|
|
158
|
+
const baseUrl = this.#options.baseUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_BASE_URL;
|
|
159
|
+
return {
|
|
160
|
+
token,
|
|
161
|
+
baseUrl: checkBaseUrl(baseUrl, this.#options.baseUrl !== undefined ? 'baseUrl' : API_URL_ENV),
|
|
162
|
+
fetch: this.#fetch(),
|
|
163
|
+
userAgent: userAgent(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
#transport() {
|
|
167
|
+
return {
|
|
168
|
+
...this.#target(),
|
|
169
|
+
timeoutMs: this.#timeoutMs,
|
|
170
|
+
maxRetries: this.#maxRetries,
|
|
171
|
+
timers: this.#timers,
|
|
172
|
+
retrySignal: this.#retries.signal,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async #call(call) {
|
|
176
|
+
return (await request(this.#transport(), call)).body;
|
|
177
|
+
}
|
|
178
|
+
#single(method) {
|
|
179
|
+
const { from } = this.#scope;
|
|
180
|
+
const single = typeof from === 'string' ? from : from?.length === 1 ? from[0] : undefined;
|
|
181
|
+
if (single === undefined) {
|
|
182
|
+
throw localError('NUMBER_REQUIRED', `${method}() reads one number's data: call it on whisp.number('<phone or num_ id>')`);
|
|
183
|
+
}
|
|
184
|
+
return single;
|
|
185
|
+
}
|
|
186
|
+
// Project -------------------------------------------------------------------------------------
|
|
187
|
+
/**
|
|
188
|
+
* Checks the token and the instance's numbers (GET /project, then GET
|
|
189
|
+
* /numbers): throws NUMBER_NOT_IN_PROJECT listing every phone or id that
|
|
190
|
+
* isn't the token's project's. Optional: the first send or listen reports
|
|
191
|
+
* the same.
|
|
192
|
+
*/
|
|
193
|
+
async verify() {
|
|
194
|
+
const info = expect(await this.#call({ method: 'GET', path: '/project' }), 'GET /project', body => isRecord(body.project) && isRecord(body.token));
|
|
195
|
+
const numbers = await this.numbers();
|
|
196
|
+
const { from } = this.#scope;
|
|
197
|
+
const missing = (from === null ? [] : [from].flat()).filter(ref => !numbers.some(number => number.id === ref || number.phone === ref));
|
|
198
|
+
if (missing.length > 0) {
|
|
199
|
+
throw new WhispError({
|
|
200
|
+
code: 'NUMBER_NOT_IN_PROJECT',
|
|
201
|
+
message: `${missing.join(', ')} ${missing.length === 1 ? 'is not a number' : 'are not numbers'} of this project`,
|
|
202
|
+
params: { number: missing.join(',') },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return { ...info, numbers };
|
|
206
|
+
}
|
|
207
|
+
// Sending -------------------------------------------------------------------------------------
|
|
208
|
+
/** Sends text (`POST /messages`) from the instance's number, pool or project. Resolves the message (`pending`, or its status after `wait`). */
|
|
209
|
+
async send(to, text, options = {}) {
|
|
210
|
+
// Async, so a bad option rejects like every other failure instead of throwing from the call.
|
|
211
|
+
return this.#send({ to, type: 'text', text, ...this.#common(options) }, options);
|
|
212
|
+
}
|
|
213
|
+
sendImage(to, media, options = {}) {
|
|
214
|
+
return this.#sendFile('image', to, media, options, options.caption);
|
|
215
|
+
}
|
|
216
|
+
sendVideo(to, media, options = {}) {
|
|
217
|
+
return this.#sendFile('video', to, media, options, options.caption);
|
|
218
|
+
}
|
|
219
|
+
sendDocument(to, media, options = {}) {
|
|
220
|
+
return this.#sendFile('document', to, media, options, options.caption);
|
|
221
|
+
}
|
|
222
|
+
sendVoice(to, media, options = {}) {
|
|
223
|
+
return this.#sendFile('voice', to, media, options, undefined);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* `POST /messages` with a body you build (5.6). The instance's `from`,
|
|
227
|
+
* `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
|
|
228
|
+
*/
|
|
229
|
+
sendMessage(body, options = {}) {
|
|
230
|
+
const defaults = { ...this.#pool(), ...this.#cap(undefined) };
|
|
231
|
+
const filled = { ...body };
|
|
232
|
+
for (const [key, value] of Object.entries(defaults))
|
|
233
|
+
if (filled[key] === undefined)
|
|
234
|
+
filled[key] = value;
|
|
235
|
+
return this.#send(filled, { ...options, wait: body.wait });
|
|
236
|
+
}
|
|
237
|
+
/** `from`, `strategy` and `sticky` for this instance; strategy and sticky only where a pool picks. */
|
|
238
|
+
#pool() {
|
|
239
|
+
const { from } = this.#scope;
|
|
240
|
+
const out = {};
|
|
241
|
+
if (from !== null)
|
|
242
|
+
out.from = from;
|
|
243
|
+
if (from === null || Array.isArray(from)) {
|
|
244
|
+
if (this.#options.strategy !== undefined)
|
|
245
|
+
out.strategy = this.#options.strategy;
|
|
246
|
+
if (this.#options.sticky !== undefined)
|
|
247
|
+
out.sticky = this.#options.sticky;
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/** `dailyCap` for one send: the call's, else the instance's; none when neither set one. */
|
|
252
|
+
#cap(perCall) {
|
|
253
|
+
const cap = checkDailyCap(perCall, 'dailyCap') ?? this.#options.dailyCap;
|
|
254
|
+
return cap === undefined ? {} : { dailyCap: cap };
|
|
255
|
+
}
|
|
256
|
+
#common(options) {
|
|
257
|
+
const out = { ...this.#pool(), ...this.#cap(options.dailyCap) };
|
|
258
|
+
if (options.replyTo !== undefined)
|
|
259
|
+
out.replyTo = options.replyTo;
|
|
260
|
+
if (options.reference !== undefined)
|
|
261
|
+
out.reference = options.reference;
|
|
262
|
+
if (options.wait !== undefined)
|
|
263
|
+
out.wait = options.wait;
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
async #send(body, options) {
|
|
267
|
+
const idempotencyKey = options.idempotencyKey ?? randomId();
|
|
268
|
+
// The server holds a `wait` send open up to 20 s; the attempt's timeout leaves room for it.
|
|
269
|
+
const wait = waitMs(options.wait);
|
|
270
|
+
const answer = await this.#call({
|
|
271
|
+
method: 'POST',
|
|
272
|
+
path: '/messages',
|
|
273
|
+
body,
|
|
274
|
+
idempotencyKey,
|
|
275
|
+
...(wait > 0 ? { timeoutMs: Math.max(this.#timeoutMs, wait + 10_000) } : {}),
|
|
276
|
+
});
|
|
277
|
+
return expect(answer, 'POST /messages', message => typeof message.id === 'string');
|
|
278
|
+
}
|
|
279
|
+
async #sendFile(kind, to, media, options, caption) {
|
|
280
|
+
// The options first: a bad one fails before any file is uploaded.
|
|
281
|
+
const common = this.#common(options);
|
|
282
|
+
let input;
|
|
283
|
+
if (isRecord(media) && typeof media.url === 'string') {
|
|
284
|
+
input = { url: media.url };
|
|
285
|
+
if (typeof media.fileName === 'string')
|
|
286
|
+
input.fileName = media.fileName;
|
|
287
|
+
if (typeof media.mime === 'string')
|
|
288
|
+
input.mime = media.mime;
|
|
289
|
+
}
|
|
290
|
+
else if (isRecord(media) && 'file' in media && media.file !== undefined && media.file !== null) {
|
|
291
|
+
input = { uploadId: await this.#upload(kind, media) };
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
throw localError('MEDIA_INVALID', `send${kind[0]?.toUpperCase()}${kind.slice(1)}() takes { url } or { file }`);
|
|
295
|
+
}
|
|
296
|
+
const body = { to, type: kind, media: input, ...common };
|
|
297
|
+
if (caption !== undefined)
|
|
298
|
+
body.caption = caption;
|
|
299
|
+
return this.#send(body, options);
|
|
300
|
+
}
|
|
301
|
+
/** `POST /uploads`, then the PUT to storage; resolves the upload id a send takes. */
|
|
302
|
+
async #upload(kind, media) {
|
|
303
|
+
const file = await prepareFile(kind, media);
|
|
304
|
+
const upload = expect(await this.#call({
|
|
305
|
+
method: 'POST',
|
|
306
|
+
path: '/uploads',
|
|
307
|
+
body: { kind, mime: file.mime, size: file.size, name: file.name },
|
|
308
|
+
}), 'POST /uploads', body => typeof body.uploadId === 'string' && typeof body.url === 'string' && isRecord(body.headers));
|
|
309
|
+
await putUpload({
|
|
310
|
+
fetch: this.#fetch(),
|
|
311
|
+
timers: this.#timers,
|
|
312
|
+
timeoutMs: this.#timeoutMs,
|
|
313
|
+
maxRetries: this.#maxRetries,
|
|
314
|
+
retrySignal: this.#retries.signal,
|
|
315
|
+
}, upload.url, upload.headers, file);
|
|
316
|
+
return upload.uploadId;
|
|
317
|
+
}
|
|
318
|
+
// Reads ---------------------------------------------------------------------------------------
|
|
319
|
+
/** Every number of the project, any status. */
|
|
320
|
+
async numbers() {
|
|
321
|
+
const list = expect(await this.#call({ method: 'GET', path: '/numbers' }), 'GET /numbers', body => Array.isArray(body.numbers));
|
|
322
|
+
return list.numbers;
|
|
323
|
+
}
|
|
324
|
+
/** One message of the project's numbers; 404 MESSAGE_NOT_FOUND otherwise. */
|
|
325
|
+
async message(messageId) {
|
|
326
|
+
return expect(await this.#call({ method: 'GET', path: `/messages/${segment(messageId)}` }), 'GET /messages/:messageId', body => typeof body.id === 'string');
|
|
327
|
+
}
|
|
328
|
+
/** What earlier sends with this `reference` made, newest first, at most 50. */
|
|
329
|
+
async messages(query) {
|
|
330
|
+
const list = expect(await this.#call({ method: 'GET', path: '/messages', query: { reference: query.reference } }), 'GET /messages', body => Array.isArray(body.messages));
|
|
331
|
+
return list.messages;
|
|
332
|
+
}
|
|
333
|
+
/** Is this phone on WhatsApp, asked through the instance's number. */
|
|
334
|
+
async check(phone) {
|
|
335
|
+
const number = this.#single('check');
|
|
336
|
+
return expect(await this.#call({ method: 'GET', path: `/numbers/${segment(number)}/check/${segment(phone)}` }), 'GET /numbers/:number/check/:phone', body => typeof body.exists === 'boolean');
|
|
337
|
+
}
|
|
338
|
+
/** The number's chats, newest activity first. */
|
|
339
|
+
chats(query = {}) {
|
|
340
|
+
const number = this.#single('chats');
|
|
341
|
+
const fetchPage = async (cursor) => {
|
|
342
|
+
const list = expect(await this.#call({
|
|
343
|
+
method: 'GET',
|
|
344
|
+
path: `/numbers/${segment(number)}/chats`,
|
|
345
|
+
query: { ...query, ...(cursor !== undefined ? { cursor } : {}) },
|
|
346
|
+
}), 'GET /numbers/:number/chats', body => Array.isArray(body.chats));
|
|
347
|
+
return { items: list.chats, nextCursor: list.nextCursor ?? null };
|
|
348
|
+
};
|
|
349
|
+
return paginate(fetchPage);
|
|
350
|
+
}
|
|
351
|
+
/** One chat: a phone, a `cht_` id or a group JID (a merged-away id resolves to the chat it joined). */
|
|
352
|
+
async chat(chat) {
|
|
353
|
+
const number = this.#single('chat');
|
|
354
|
+
return expect(await this.#call({ method: 'GET', path: `/numbers/${segment(number)}/chats/${segment(chat)}` }), 'GET /numbers/:number/chats/:chat', body => typeof body.id === 'string');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A chat's messages, newest first, history included. Each next page is
|
|
358
|
+
* older (`nextCursor` goes in `before`); after an `after` request, each next
|
|
359
|
+
* page is newer (`nextCursor` goes in `after`).
|
|
360
|
+
*/
|
|
361
|
+
chatMessages(chat, query = {}) {
|
|
362
|
+
const number = this.#single('chatMessages');
|
|
363
|
+
const direction = query.after !== undefined && query.before === undefined ? 'after' : 'before';
|
|
364
|
+
const fetchPage = async (cursor) => {
|
|
365
|
+
const list = expect(await this.#call({
|
|
366
|
+
method: 'GET',
|
|
367
|
+
path: `/numbers/${segment(number)}/chats/${segment(chat)}/messages`,
|
|
368
|
+
query: { ...query, ...(cursor !== undefined ? { [direction]: cursor } : {}) },
|
|
369
|
+
}), 'GET /numbers/:number/chats/:chat/messages', body => Array.isArray(body.messages));
|
|
370
|
+
return { items: list.messages, nextCursor: list.nextCursor ?? null };
|
|
371
|
+
};
|
|
372
|
+
return paginate(fetchPage);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A message's file as a fetch `Response` (12.3). Asks the API for a
|
|
376
|
+
* 5-minute link with the token, then fetches the link with no token and no
|
|
377
|
+
* redirects. A file still being prepared (409 MEDIA_NOT_READY with
|
|
378
|
+
* Retry-After) is waited for, up to 60 s.
|
|
379
|
+
*/
|
|
380
|
+
async downloadMedia(message, options = {}) {
|
|
381
|
+
const id = typeof message === 'string' ? message : message?.id;
|
|
382
|
+
if (typeof id !== 'string' || id === '')
|
|
383
|
+
throw localError('OPTIONS_INVALID', 'downloadMedia() takes a message or its id');
|
|
384
|
+
const started = this.#timers.now();
|
|
385
|
+
let link;
|
|
386
|
+
for (;;) {
|
|
387
|
+
try {
|
|
388
|
+
link = expect(await this.#call({
|
|
389
|
+
method: 'GET',
|
|
390
|
+
path: `/messages/${segment(id)}/media`,
|
|
391
|
+
query: { redirect: '0', variant: options.variant },
|
|
392
|
+
}), 'GET /messages/:messageId/media', body => typeof body.url === 'string');
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
const pending = error instanceof WhispError && error.code === 'MEDIA_NOT_READY' && error.retryAfter !== null;
|
|
397
|
+
const waitFor = pending ? Math.max(1, error.retryAfter) * 1_000 : 0;
|
|
398
|
+
if (!pending || this.#timers.now() - started + waitFor > MEDIA_WAIT_MAX_MS)
|
|
399
|
+
throw error;
|
|
400
|
+
await sleep(this.#timers, waitFor, this.#retries.signal);
|
|
401
|
+
if (this.#retries.signal.aborted)
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
let response;
|
|
406
|
+
try {
|
|
407
|
+
// A new request: no Authorization, and a redirect is an error rather than a hop.
|
|
408
|
+
response = await this.#fetch()(link.url, { method: 'GET', redirect: 'error' });
|
|
409
|
+
}
|
|
410
|
+
catch (cause) {
|
|
411
|
+
throw new WhispError({ code: 'MEDIA_DOWNLOAD_FAILED', message: 'The file link could not be fetched', cause });
|
|
412
|
+
}
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
await response.body?.cancel().catch(() => { });
|
|
415
|
+
throw new WhispError({
|
|
416
|
+
code: 'MEDIA_DOWNLOAD_FAILED',
|
|
417
|
+
status: response.status,
|
|
418
|
+
message: `The file link answered ${response.status}`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return response;
|
|
422
|
+
}
|
|
423
|
+
on(event, handler) {
|
|
424
|
+
if (typeof handler !== 'function')
|
|
425
|
+
throw localError('OPTIONS_INVALID', `on('${String(event)}') needs a function`);
|
|
426
|
+
this.#listener.add(event, handler);
|
|
427
|
+
return () => this.#listener.remove(event, handler);
|
|
428
|
+
}
|
|
429
|
+
off(event, handler) {
|
|
430
|
+
this.#listener.remove(event, handler);
|
|
431
|
+
}
|
|
432
|
+
/** Ends the stream, forgets every handler, and stops retries still waiting for their turn. */
|
|
433
|
+
close() {
|
|
434
|
+
this.#listener.close();
|
|
435
|
+
this.#retries.abort();
|
|
436
|
+
this.#retries = new AbortController();
|
|
437
|
+
}
|
|
438
|
+
/** The standalone `verifyWebhook`, for convenience. */
|
|
439
|
+
verifyWebhook(input) {
|
|
440
|
+
return verifyWebhook(input);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** A first page fetched at once, awaitable, and iterable across every page. */
|
|
444
|
+
const paginate = (fetchPage) => {
|
|
445
|
+
const first = fetchPage(undefined);
|
|
446
|
+
const iterate = async function* () {
|
|
447
|
+
let page = await first;
|
|
448
|
+
for (;;) {
|
|
449
|
+
yield* page.items;
|
|
450
|
+
if (page.nextCursor === null || page.items.length === 0)
|
|
451
|
+
return;
|
|
452
|
+
page = await fetchPage(page.nextCursor);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
return Object.assign(first, { [Symbol.asyncIterator]: iterate });
|
|
456
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The npm name (16.2 decision 1: `@whisphq/sdk` if the `@whisp` scope isn't the owner's). */
|
|
2
|
+
export declare const SDK_PACKAGE_NAME = "whisp-js";
|
|
3
|
+
/** Where the token is read from when none is passed, on the first call. */
|
|
4
|
+
export declare const API_TOKEN_ENV = "WHISP_API_TOKEN";
|
|
5
|
+
/** Where a different base URL is read from (agent stacks, local tests). */
|
|
6
|
+
export declare const API_URL_ENV = "WHISP_API_URL";
|
|
7
|
+
export declare const DEFAULT_BASE_URL = "https://api.whisp.lue.my";
|
|
8
|
+
/** `whisp-sdk/<version> (<runtime>/<version>)`. */
|
|
9
|
+
export declare const USER_AGENT_PREFIX = "whisp-sdk";
|
|
10
|
+
/** The webhook signature header, lowercase as HTTP header names compare. */
|
|
11
|
+
export declare const SIGNATURE_HEADER = "x-whisp-signature";
|
|
12
|
+
/** Every project token starts with it. */
|
|
13
|
+
export declare const TOKEN_PREFIX = "wsp_live_";
|
|
14
|
+
/** Every webhook signing secret starts with it. */
|
|
15
|
+
export declare const WEBHOOK_SECRET_PREFIX = "wsp_whs_";
|
|
16
|
+
/** An event cursor (the SSE `id:`) starts with it; the rest is opaque. */
|
|
17
|
+
export declare const EVENT_CURSOR_PREFIX = "c_";
|
|
18
|
+
/** How old a webhook signature's timestamp may be, by default (seconds). */
|
|
19
|
+
export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
20
|
+
/** A project token's whole shape: the prefix, 32 base62 and a 6-character checksum (core's `TOKEN_PATTERN`). */
|
|
21
|
+
export declare const TOKEN_PATTERN: RegExp;
|
|
22
|
+
/** A webhook signing secret's whole shape (core's `WEBHOOK_SECRET_PATTERN`). */
|
|
23
|
+
export declare const WEBHOOK_SECRET_PATTERN: RegExp;
|
|
24
|
+
/** A number id: `num_` and 8 characters of core's base58 id alphabet (no 0, O, I or l). */
|
|
25
|
+
export declare const NUMBER_ID_PATTERN: RegExp;
|
|
26
|
+
/** At most this many numbers in one pool (`from`, PUBLIC_LIMITS.fromMax). */
|
|
27
|
+
export declare const FROM_MAX = 20;
|
|
28
|
+
/** `wait: true` waits this long on the server (PUBLIC_LIMITS.waitDefaultMs). */
|
|
29
|
+
export declare const WAIT_DEFAULT_MS = 15000;
|
|
30
|
+
/** How a pool picks its number (core SEND_STRATEGIES); the first, `healthiest`, is the server's default. */
|
|
31
|
+
export declare const SEND_STRATEGIES: readonly ["healthiest", "random", "round-robin"];
|
|
32
|
+
/** A send's own `dailyCap`: a whole number in this range (core SEND_DAILY_CAP). */
|
|
33
|
+
export declare const DAILY_CAP_MIN = 1;
|
|
34
|
+
export declare const DAILY_CAP_MAX = 10000;
|
|
35
|
+
/** The upload cap of each kind (core's `uploadMaxBytes`); a stream is never read past it. */
|
|
36
|
+
export declare const UPLOAD_MAX_BYTES: {
|
|
37
|
+
readonly image: number;
|
|
38
|
+
readonly video: number;
|
|
39
|
+
readonly voice: number;
|
|
40
|
+
readonly document: number;
|
|
41
|
+
};
|
|
42
|
+
/** PLAN's twelve, in the order the API lists them. */
|
|
43
|
+
export declare const PUBLIC_EVENT_TYPES: readonly ["message.received", "message.sent", "message.status", "message.reaction", "message.deleted", "chat.updated", "presence.updated", "group.updated", "group.participants", "number.status", "number.risk", "call.received"];
|