@funnelchat-hq/sandbox 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 +549 -2
- package/dist/chunk-PF4CGK6J.js +46437 -0
- package/dist/cli.js +73 -0
- package/dist/index.d.ts +490 -0
- package/dist/index.js +3 -0
- package/funnelchat-sandbox.config.example.json +15 -0
- package/package.json +77 -4
- package/spec/procedencia.json +64 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ConfigError, parseArgs, VERSION, DEFAULT_PORT, DEFAULT_CONFIG_FILE, resolveConfig, createSandbox } from './chunk-PF4CGK6J.js';
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
var HELP = `funnelchat-sandbox ${VERSION}
|
|
6
|
+
Imitates the Funnelchat public API on your machine and sends signed webhooks. Nothing goes out to WhatsApp.
|
|
7
|
+
|
|
8
|
+
Usage: npx funnelchat-sandbox [options]
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
-p, --port <n> Port (default ${DEFAULT_PORT}; 0 takes a free one).
|
|
12
|
+
--host <host> Where to listen (default 127.0.0.1; in Docker, 0.0.0.0).
|
|
13
|
+
-c, --config <file> JSON configuration (default ./${DEFAULT_CONFIG_FILE}, if it exists).
|
|
14
|
+
--status-delays <s,d,r> Seconds until sent, delivered and read (default 1,2,3; "-" stops there).
|
|
15
|
+
--retry-delays <a,b,c> Seconds between retries of a webhook (default 60,300,1800).
|
|
16
|
+
--webhook-timeout <s> How long your receiver may take (default 10).
|
|
17
|
+
--require-https Only accept https subscriptions, like the real API.
|
|
18
|
+
--no-window Do not apply the 24-hour window.
|
|
19
|
+
--no-antiblock Do not apply the spam brakes.
|
|
20
|
+
--no-rate-limit Do not apply the per-number pace.
|
|
21
|
+
--template-approval <s> Seconds until a new template is approved (default 5; "-" leaves it pending).
|
|
22
|
+
-q, --quiet No line per request and per delivery.
|
|
23
|
+
-h, --help This help.
|
|
24
|
+
-v, --version The version.
|
|
25
|
+
|
|
26
|
+
Options win over the configuration file. Panel: http://localhost:${DEFAULT_PORT}/_sandbox`;
|
|
27
|
+
async function main() {
|
|
28
|
+
const args = parseArgs(process.argv.slice(2));
|
|
29
|
+
if (args.help) {
|
|
30
|
+
console.log(HELP);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (args.version) {
|
|
34
|
+
console.log(VERSION);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const { config, configFile } = resolveConfig(args);
|
|
38
|
+
const sandbox = createSandbox({ config });
|
|
39
|
+
const { url } = await sandbox.listen();
|
|
40
|
+
console.log(`funnelchat-sandbox ${VERSION} at ${url}`);
|
|
41
|
+
if (configFile) console.log(`configuration: ${configFile}`);
|
|
42
|
+
console.log(` API: ${url}/api/v1/... (Authorization: Bearer 1|fc_live_sandbox, or any well-formed key)`);
|
|
43
|
+
console.log(` panel: ${url}/_sandbox`);
|
|
44
|
+
console.log(" example numbers: 3 (official, with the 24-hour window) and 4 (QR, no window); contact 2001; template 45");
|
|
45
|
+
for (const s of sandbox.app.store.subscriptions.values()) {
|
|
46
|
+
console.log(` subscription from the configuration: ${s.id} \u2192 ${s.url} (${s.events.join(", ")})`);
|
|
47
|
+
}
|
|
48
|
+
let closing = false;
|
|
49
|
+
const stop = (signal) => {
|
|
50
|
+
if (closing) return;
|
|
51
|
+
closing = true;
|
|
52
|
+
console.log(`
|
|
53
|
+
${signal}: closing the sandbox`);
|
|
54
|
+
void sandbox.close().then(() => process.exit(0));
|
|
55
|
+
};
|
|
56
|
+
process.on("SIGINT", () => stop("SIGINT"));
|
|
57
|
+
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
58
|
+
}
|
|
59
|
+
main().catch((error) => {
|
|
60
|
+
if (error instanceof ConfigError) {
|
|
61
|
+
console.error(`funnelchat-sandbox: ${error.message}`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
const code = error.code;
|
|
65
|
+
if (code === "EADDRINUSE") {
|
|
66
|
+
console.error("funnelchat-sandbox: the port is in use. Try --port <another>.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.error(error);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=cli.js.map
|
|
73
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { Server } from 'node:http';
|
|
2
|
+
|
|
3
|
+
/** A subscription created when the sandbox starts (and after every reset), with a fixed secret. */
|
|
4
|
+
interface PresetSubscription {
|
|
5
|
+
url: string;
|
|
6
|
+
events: string[];
|
|
7
|
+
description?: string | null;
|
|
8
|
+
/** Optional: a fixed secret, so your receiver keeps working across restarts. One is generated otherwise. */
|
|
9
|
+
secret?: string;
|
|
10
|
+
/** Optional: a fixed id (UUID). */
|
|
11
|
+
id?: string;
|
|
12
|
+
}
|
|
13
|
+
interface SandboxConfig {
|
|
14
|
+
port: number;
|
|
15
|
+
host: string;
|
|
16
|
+
/** Seconds from one state to the next of an accepted message: queued → sent → delivered → read. `null` stops there. */
|
|
17
|
+
statusDelays: {
|
|
18
|
+
sent: number;
|
|
19
|
+
delivered: number | null;
|
|
20
|
+
read: number | null;
|
|
21
|
+
};
|
|
22
|
+
webhooks: {
|
|
23
|
+
/** Seconds to wait before attempts 2, 3 and 4 (the portal: 60, 300, 1800). */
|
|
24
|
+
retryDelays: number[];
|
|
25
|
+
/** A delivery counts as failed if the receiver takes longer (the portal: 10 s). */
|
|
26
|
+
timeoutSeconds: number;
|
|
27
|
+
/** Consecutive failed deliveries that disable a subscription (accounts: 5). */
|
|
28
|
+
maxConsecutiveFailures: number;
|
|
29
|
+
/** The real API only accepts public `https` URLs. The sandbox accepts `http` and local addresses unless this is `true`. */
|
|
30
|
+
requireHttps: boolean;
|
|
31
|
+
};
|
|
32
|
+
rules: {
|
|
33
|
+
/** The 24-hour window on official (Cloud) numbers: `422 window_closed`. */
|
|
34
|
+
window: boolean;
|
|
35
|
+
/** The spam brakes: `422 antiblock_duplicate_content` and `422 antiblock_unanswered_limit`. */
|
|
36
|
+
antiblock: boolean;
|
|
37
|
+
/** The pace of official numbers: `429 rate_limited` with `Retry-After`. */
|
|
38
|
+
rateLimit: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** Seconds until a template created through the API goes from `PENDING` to `APPROVED`. `null`: it stays pending. */
|
|
41
|
+
templateApprovalSeconds: number | null;
|
|
42
|
+
/** Subscriptions to create at start and after every reset. */
|
|
43
|
+
subscriptions: PresetSubscription[];
|
|
44
|
+
/** How many requests and deliveries the panel keeps. */
|
|
45
|
+
historyLimit: number;
|
|
46
|
+
/** One line per request and per delivery on the console. */
|
|
47
|
+
log: boolean;
|
|
48
|
+
}
|
|
49
|
+
declare function defaultConfig(): SandboxConfig;
|
|
50
|
+
type ConfigInput = {
|
|
51
|
+
[K in keyof SandboxConfig]?: SandboxConfig[K] extends Record<string, unknown> ? Partial<SandboxConfig[K]> : SandboxConfig[K];
|
|
52
|
+
};
|
|
53
|
+
/** Merges a partial configuration over a full one, checking every value. */
|
|
54
|
+
declare function mergeConfig(base: SandboxConfig, input: ConfigInput): SandboxConfig;
|
|
55
|
+
|
|
56
|
+
type PublicStatus = 'queued' | 'sent' | 'delivered' | 'read' | 'failed';
|
|
57
|
+
interface MessageError {
|
|
58
|
+
code: number | null;
|
|
59
|
+
title: string | null;
|
|
60
|
+
reason: string | null;
|
|
61
|
+
}
|
|
62
|
+
type DeviceKind = 'cloud' | 'qr';
|
|
63
|
+
interface Device {
|
|
64
|
+
id: number;
|
|
65
|
+
alias: string;
|
|
66
|
+
/** Digits only, without `+`. */
|
|
67
|
+
number: string;
|
|
68
|
+
/** WhatsAppProviderEnum of accounts: 1 Z-API (QR), 2 WhatsApp Cloud (official). */
|
|
69
|
+
provider: 1 | 2;
|
|
70
|
+
kind: DeviceKind;
|
|
71
|
+
connected: boolean;
|
|
72
|
+
wabaId: string | null;
|
|
73
|
+
phoneNumberId: string | null;
|
|
74
|
+
createdAt: Date;
|
|
75
|
+
}
|
|
76
|
+
interface Contact {
|
|
77
|
+
id: number;
|
|
78
|
+
name: string | null;
|
|
79
|
+
email: string | null;
|
|
80
|
+
/** Digits only: the API stores the phone without `+`. */
|
|
81
|
+
phone: string | null;
|
|
82
|
+
whatsappId: string | null;
|
|
83
|
+
image: string | null;
|
|
84
|
+
notes: string | null;
|
|
85
|
+
status: number;
|
|
86
|
+
activeConversation: boolean;
|
|
87
|
+
favorite: boolean;
|
|
88
|
+
unreadMessages: number;
|
|
89
|
+
manuallyUnread: boolean;
|
|
90
|
+
deviceId: number | null;
|
|
91
|
+
adviserId: number | null;
|
|
92
|
+
pausedAt: Date | null;
|
|
93
|
+
tags: Map<number, Date>;
|
|
94
|
+
customFields: Map<number, {
|
|
95
|
+
value: string;
|
|
96
|
+
createdAt: Date;
|
|
97
|
+
updatedAt: Date;
|
|
98
|
+
}>;
|
|
99
|
+
createdAt: Date;
|
|
100
|
+
updatedAt: Date;
|
|
101
|
+
}
|
|
102
|
+
interface Message {
|
|
103
|
+
id: number;
|
|
104
|
+
contactId: number;
|
|
105
|
+
deviceId: number | null;
|
|
106
|
+
own: boolean;
|
|
107
|
+
/** Public type name (`text`, `image`, `template`, `contact`, `note`…). */
|
|
108
|
+
type: string;
|
|
109
|
+
text: string | null;
|
|
110
|
+
file: string | null;
|
|
111
|
+
status: PublicStatus | null;
|
|
112
|
+
error: MessageError | null;
|
|
113
|
+
clientReference: string | null;
|
|
114
|
+
uid: string;
|
|
115
|
+
/** Sent with an API key: it has the queued → sent → delivered → read lifecycle and emits its states. */
|
|
116
|
+
viaApi: boolean;
|
|
117
|
+
templateId: number | null;
|
|
118
|
+
reactions: {
|
|
119
|
+
emoji: string;
|
|
120
|
+
own: boolean;
|
|
121
|
+
}[];
|
|
122
|
+
createdAt: Date;
|
|
123
|
+
updatedAt: Date;
|
|
124
|
+
}
|
|
125
|
+
interface Tag {
|
|
126
|
+
id: number;
|
|
127
|
+
name: string;
|
|
128
|
+
color: string | null;
|
|
129
|
+
createdAt: Date;
|
|
130
|
+
updatedAt: Date;
|
|
131
|
+
}
|
|
132
|
+
interface CustomField {
|
|
133
|
+
id: number;
|
|
134
|
+
name: string;
|
|
135
|
+
type: number;
|
|
136
|
+
createdAt: Date;
|
|
137
|
+
updatedAt: Date;
|
|
138
|
+
}
|
|
139
|
+
type TemplateStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED' | 'DISABLED';
|
|
140
|
+
interface Template {
|
|
141
|
+
id: number;
|
|
142
|
+
uid: string;
|
|
143
|
+
name: string;
|
|
144
|
+
body: string;
|
|
145
|
+
header: string | null;
|
|
146
|
+
footer: string | null;
|
|
147
|
+
category: string;
|
|
148
|
+
type: string;
|
|
149
|
+
status: TemplateStatus;
|
|
150
|
+
languageCode: string;
|
|
151
|
+
deviceId: number;
|
|
152
|
+
file: string | null;
|
|
153
|
+
params: string[] | null;
|
|
154
|
+
buttons: {
|
|
155
|
+
text?: string;
|
|
156
|
+
type?: string;
|
|
157
|
+
url?: string;
|
|
158
|
+
phone_number?: string;
|
|
159
|
+
}[] | null;
|
|
160
|
+
rejectionReason: string | null;
|
|
161
|
+
createdAt: Date;
|
|
162
|
+
updatedAt: Date;
|
|
163
|
+
}
|
|
164
|
+
type SubscriptionStatus = 'active' | 'paused' | 'disabled';
|
|
165
|
+
interface Subscription {
|
|
166
|
+
id: string;
|
|
167
|
+
url: string;
|
|
168
|
+
description: string | null;
|
|
169
|
+
events: string[];
|
|
170
|
+
filters: {
|
|
171
|
+
campaign_ids?: string[];
|
|
172
|
+
};
|
|
173
|
+
status: SubscriptionStatus;
|
|
174
|
+
disabledReason: 'too_many_failures' | 'key_revoked' | null;
|
|
175
|
+
consecutiveFailures: number;
|
|
176
|
+
secret: string;
|
|
177
|
+
lastDeliveryAt: Date | null;
|
|
178
|
+
lastSuccessAt: Date | null;
|
|
179
|
+
secretRotatedAt: Date | null;
|
|
180
|
+
createdAt: Date;
|
|
181
|
+
updatedAt: Date;
|
|
182
|
+
}
|
|
183
|
+
interface Delivery {
|
|
184
|
+
id: number;
|
|
185
|
+
subscriptionId: string;
|
|
186
|
+
eventId: string;
|
|
187
|
+
eventType: string;
|
|
188
|
+
attempt: number;
|
|
189
|
+
url: string;
|
|
190
|
+
responseStatus: number | null;
|
|
191
|
+
succeeded: boolean;
|
|
192
|
+
error: string | null;
|
|
193
|
+
durationMs: number | null;
|
|
194
|
+
attemptedAt: Date;
|
|
195
|
+
/** Sandbox only (the panel): the start of what the receiver answered, and the delivered body. */
|
|
196
|
+
responseBody: string | null;
|
|
197
|
+
requestBody: string;
|
|
198
|
+
/** Sandbox only: when the next attempt is due, if there is one. */
|
|
199
|
+
nextAttemptAt: Date | null;
|
|
200
|
+
}
|
|
201
|
+
/** One version of a WhatsApp form (conversations `whatsapp_forms`); its family is the stable form. */
|
|
202
|
+
interface FormVersion {
|
|
203
|
+
id: number;
|
|
204
|
+
familyId: number;
|
|
205
|
+
/** Unique per number; a new version is `<label>__v<n>`. */
|
|
206
|
+
name: string;
|
|
207
|
+
category: string;
|
|
208
|
+
status: 'draft' | 'published';
|
|
209
|
+
version: number;
|
|
210
|
+
definition: Record<string, unknown>;
|
|
211
|
+
/** The number it was created with (`primary`). */
|
|
212
|
+
deviceId: number;
|
|
213
|
+
/** The other numbers (`whatsapp_form_copies`). */
|
|
214
|
+
copies: Map<number, {
|
|
215
|
+
status: 'draft' | 'publishing' | 'published' | 'failed';
|
|
216
|
+
error: string | null;
|
|
217
|
+
}>;
|
|
218
|
+
metaFlowId: string | null;
|
|
219
|
+
publishedAt: Date | null;
|
|
220
|
+
createdAt: Date;
|
|
221
|
+
updatedAt: Date;
|
|
222
|
+
}
|
|
223
|
+
interface FormFamily {
|
|
224
|
+
id: number;
|
|
225
|
+
/** The published version a send uses. */
|
|
226
|
+
currentVersionId: number | null;
|
|
227
|
+
}
|
|
228
|
+
/** One send of a form to a contact, and its answers (conversations `whatsapp_form_runs`). */
|
|
229
|
+
interface FormRun {
|
|
230
|
+
id: number;
|
|
231
|
+
familyId: number;
|
|
232
|
+
versionId: number;
|
|
233
|
+
contactId: number;
|
|
234
|
+
deviceId: number;
|
|
235
|
+
messageId: number;
|
|
236
|
+
status: 'pending' | 'completed';
|
|
237
|
+
answers: Record<string, unknown> | null;
|
|
238
|
+
sentAt: Date;
|
|
239
|
+
respondedAt: Date | null;
|
|
240
|
+
}
|
|
241
|
+
interface RequestLogEntry {
|
|
242
|
+
id: number;
|
|
243
|
+
at: Date;
|
|
244
|
+
/** The `X-Request-Id` of the answer. */
|
|
245
|
+
requestId: string;
|
|
246
|
+
method: string;
|
|
247
|
+
path: string;
|
|
248
|
+
status: number;
|
|
249
|
+
durationMs: number;
|
|
250
|
+
operation: string | null;
|
|
251
|
+
requestBody: unknown;
|
|
252
|
+
responseBody: unknown;
|
|
253
|
+
}
|
|
254
|
+
/** A failure armed with `POST /_sandbox/failures` or the `X-Sandbox-*` headers. */
|
|
255
|
+
interface ArmedFailure {
|
|
256
|
+
id: number;
|
|
257
|
+
/**
|
|
258
|
+
* `reject`: the send answers an error right away. `fail`: it is accepted and then ends `failed`. `edge`: the next
|
|
259
|
+
* API call (any route) gets an error of the edge.
|
|
260
|
+
*/
|
|
261
|
+
mode: 'reject' | 'fail' | 'edge';
|
|
262
|
+
code: string;
|
|
263
|
+
remaining: number;
|
|
264
|
+
contactId: number | null;
|
|
265
|
+
/** `edge` only: the call reached the domain and was applied; only the answer was lost. */
|
|
266
|
+
applied?: boolean;
|
|
267
|
+
}
|
|
268
|
+
declare class Sequence {
|
|
269
|
+
#private;
|
|
270
|
+
readonly start: number;
|
|
271
|
+
constructor(start: number);
|
|
272
|
+
next(): number;
|
|
273
|
+
bump(atLeast: number): void;
|
|
274
|
+
}
|
|
275
|
+
declare class Store {
|
|
276
|
+
readonly historyLimit: number;
|
|
277
|
+
devices: Map<number, Device>;
|
|
278
|
+
contacts: Map<number, Contact>;
|
|
279
|
+
messages: Map<number, Message>;
|
|
280
|
+
tags: Map<number, Tag>;
|
|
281
|
+
customFields: Map<number, CustomField>;
|
|
282
|
+
templates: Map<number, Template>;
|
|
283
|
+
subscriptions: Map<string, Subscription>;
|
|
284
|
+
deliveries: Delivery[];
|
|
285
|
+
requests: RequestLogEntry[];
|
|
286
|
+
failures: ArmedFailure[];
|
|
287
|
+
forms: Map<number, FormVersion>;
|
|
288
|
+
formFamilies: Map<number, FormFamily>;
|
|
289
|
+
formRuns: Map<number, FormRun>;
|
|
290
|
+
/** Last inbound message per `contactId:deviceId`: the 24-hour window. */
|
|
291
|
+
lastInbound: Map<string, Date>;
|
|
292
|
+
/** API-key sends per contact since its last inbound message: the antiblock counters. */
|
|
293
|
+
unanswered: Map<number, {
|
|
294
|
+
at: Date;
|
|
295
|
+
hash: string;
|
|
296
|
+
}[]>;
|
|
297
|
+
/** Recent API-key sends per number and per `deviceId:contactId`: the pace. */
|
|
298
|
+
pace: Map<string, number[]>;
|
|
299
|
+
/** The MAC state of the account (`POST /_sandbox/mac`): the 24-hour lockout, and contacts active or inactive in the cycle. */
|
|
300
|
+
mac: {
|
|
301
|
+
lockout: boolean;
|
|
302
|
+
activeContacts: Set<number>;
|
|
303
|
+
inactiveContacts: Set<number>;
|
|
304
|
+
};
|
|
305
|
+
/** Salt of the deterministic event ids: new on every reset, so ids never repeat across sessions. */
|
|
306
|
+
eventSalt: string;
|
|
307
|
+
readonly ids: {
|
|
308
|
+
contact: Sequence;
|
|
309
|
+
message: Sequence;
|
|
310
|
+
tag: Sequence;
|
|
311
|
+
customField: Sequence;
|
|
312
|
+
template: Sequence;
|
|
313
|
+
delivery: Sequence;
|
|
314
|
+
request: Sequence;
|
|
315
|
+
failure: Sequence;
|
|
316
|
+
note: Sequence;
|
|
317
|
+
formFamily: Sequence;
|
|
318
|
+
formVersion: Sequence;
|
|
319
|
+
formRun: Sequence;
|
|
320
|
+
};
|
|
321
|
+
constructor(historyLimit?: number);
|
|
322
|
+
logRequest(entry: Omit<RequestLogEntry, 'id'>): RequestLogEntry;
|
|
323
|
+
addDelivery(entry: Omit<Delivery, 'id'>): Delivery;
|
|
324
|
+
findContactByPhone(phone: string): Contact | undefined;
|
|
325
|
+
contactMessages(contactId: number): Message[];
|
|
326
|
+
windowOpen(contactId: number, deviceId: number, now?: Date): boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Emission and delivery of events to the webhook subscriptions, like accounts' unified subscriptions
|
|
331
|
+
* (`WebhookEventDispatcher`, `DeliverSubscriptionEventJob`, `SubscriptionWebhookSender`, platform-gate
|
|
332
|
+
* `RetryPolicy`):
|
|
333
|
+
*
|
|
334
|
+
* - one envelope `{id, type, version, occurred_at, data}` per event, serialized once: every subscription and every
|
|
335
|
+
* retry gets the same bytes and the same `id`;
|
|
336
|
+
* - one signed `POST` per subscription, with `X-Funnelchat-Signature` (a fresh `t` per attempt),
|
|
337
|
+
* `X-Funnelchat-Event` and `X-Funnelchat-Delivery` (a new UUID per attempt), no redirects, a 10 s timeout;
|
|
338
|
+
* - up to 4 attempts, waiting 60 s, 300 s and 1800 s (configurable); a network error, a timeout, a `5xx` or a
|
|
339
|
+
* `429` is retried, any other answer that is not `2xx` is final;
|
|
340
|
+
* - a delivery that runs out of attempts counts one consecutive failure; at 5 the subscription is disabled
|
|
341
|
+
* (`too_many_failures`); a success resets the count;
|
|
342
|
+
* - each attempt checks again that the subscription exists, is `active` and still wants the type.
|
|
343
|
+
*/
|
|
344
|
+
|
|
345
|
+
interface EmitOptions {
|
|
346
|
+
id?: string;
|
|
347
|
+
occurredAt?: Date;
|
|
348
|
+
}
|
|
349
|
+
interface Envelope$1 {
|
|
350
|
+
id: string;
|
|
351
|
+
type: string;
|
|
352
|
+
version: number;
|
|
353
|
+
occurred_at: string;
|
|
354
|
+
data: unknown;
|
|
355
|
+
}
|
|
356
|
+
interface SendResult {
|
|
357
|
+
status: number | null;
|
|
358
|
+
error: string | null;
|
|
359
|
+
durationMs: number;
|
|
360
|
+
body: string | null;
|
|
361
|
+
}
|
|
362
|
+
declare class Webhooks {
|
|
363
|
+
private readonly app;
|
|
364
|
+
constructor(app: SandboxApp);
|
|
365
|
+
get maxAttempts(): number;
|
|
366
|
+
/** Builds the envelope, exactly as it is delivered. */
|
|
367
|
+
envelope(type: string, data: unknown, options?: EmitOptions): Envelope$1;
|
|
368
|
+
/** Emits one event to every active subscription that wants it. Returns the envelope and how many got it. */
|
|
369
|
+
emit(type: string, data: unknown, options?: EmitOptions): {
|
|
370
|
+
envelope: Envelope$1;
|
|
371
|
+
subscriptions: number;
|
|
372
|
+
};
|
|
373
|
+
/** One signed POST. Never throws: the result says what happened. */
|
|
374
|
+
send(subscription: Subscription, type: string, body: string): Promise<SendResult>;
|
|
375
|
+
private shouldRetry;
|
|
376
|
+
private attempt;
|
|
377
|
+
/** `POST /api/v1/webhooks/{id}/test`: one synchronous `webhook.test`, no retries, no counters touched. */
|
|
378
|
+
test(subscription: Subscription): Promise<Record<string, unknown>>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
interface Logger {
|
|
382
|
+
info(message: string): void;
|
|
383
|
+
warn(message: string): void;
|
|
384
|
+
}
|
|
385
|
+
/** Timers of the sandbox (message states, retries, template approvals): all cleared on reset and on close. */
|
|
386
|
+
declare class Scheduler {
|
|
387
|
+
#private;
|
|
388
|
+
after(ms: number, task: () => unknown): void;
|
|
389
|
+
get pending(): number;
|
|
390
|
+
clearAll(): void;
|
|
391
|
+
}
|
|
392
|
+
/** Everything a request handler needs: configuration, state, webhooks and timers. */
|
|
393
|
+
declare class SandboxApp {
|
|
394
|
+
readonly config: SandboxConfig;
|
|
395
|
+
readonly logger: Logger;
|
|
396
|
+
store: Store;
|
|
397
|
+
readonly scheduler: Scheduler;
|
|
398
|
+
readonly webhooks: Webhooks;
|
|
399
|
+
/** Grows on every reset, so work started before a reset does not touch the new state. */
|
|
400
|
+
generation: number;
|
|
401
|
+
constructor(config: SandboxConfig, logger?: Logger);
|
|
402
|
+
private populate;
|
|
403
|
+
/** `POST /_sandbox/reset`: empty state, the example data again, timers cancelled. */
|
|
404
|
+
reset(): void;
|
|
405
|
+
close(): void;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* `X-Request-Id`: a fresh UUID v4 on every answer, errors included, like api-edge v0.3.0 and platform-gate 0.8. Only
|
|
410
|
+
* in the header, never in the body; one the client sends is ignored.
|
|
411
|
+
*/
|
|
412
|
+
declare const REQUEST_ID_HEADER = "X-Request-Id";
|
|
413
|
+
interface Sandbox {
|
|
414
|
+
app: SandboxApp;
|
|
415
|
+
server: Server;
|
|
416
|
+
/** Starts listening; resolves with the URL. Port `0` takes a free one. */
|
|
417
|
+
listen(): Promise<{
|
|
418
|
+
url: string;
|
|
419
|
+
port: number;
|
|
420
|
+
}>;
|
|
421
|
+
close(): Promise<void>;
|
|
422
|
+
}
|
|
423
|
+
interface CreateSandboxOptions {
|
|
424
|
+
config?: ConfigInput | SandboxConfig;
|
|
425
|
+
logger?: Logger;
|
|
426
|
+
}
|
|
427
|
+
declare function createSandbox(options?: CreateSandboxOptions): Sandbox;
|
|
428
|
+
|
|
429
|
+
declare const SIGNATURE_HEADER = "X-Funnelchat-Signature";
|
|
430
|
+
declare const EVENT_HEADER = "X-Funnelchat-Event";
|
|
431
|
+
declare const DELIVERY_HEADER = "X-Funnelchat-Delivery";
|
|
432
|
+
declare function signature(secret: string, body: string | Uint8Array, timestamp: number): string;
|
|
433
|
+
|
|
434
|
+
interface Envelope {
|
|
435
|
+
id: string;
|
|
436
|
+
type: string;
|
|
437
|
+
version: number;
|
|
438
|
+
occurred_at: string;
|
|
439
|
+
data: unknown;
|
|
440
|
+
}
|
|
441
|
+
interface CatalogEntry {
|
|
442
|
+
type: string;
|
|
443
|
+
area: string;
|
|
444
|
+
/** `active`: accounts accepts and delivers it. `planned`: you can subscribe, but nothing is emitted yet. */
|
|
445
|
+
status: 'active' | 'planned';
|
|
446
|
+
is_critical: boolean;
|
|
447
|
+
label: string;
|
|
448
|
+
description: string;
|
|
449
|
+
subscribable: boolean;
|
|
450
|
+
example: Envelope | null;
|
|
451
|
+
}
|
|
452
|
+
declare const catalog: CatalogEntry[];
|
|
453
|
+
|
|
454
|
+
/** The edge's own errors (api-edge v0.3.0 `src/index.ts`): `{error, message}`, no `Retry-After`. */
|
|
455
|
+
declare const EDGE_ERRORS: Record<string, {
|
|
456
|
+
status: number;
|
|
457
|
+
message: string;
|
|
458
|
+
}>;
|
|
459
|
+
|
|
460
|
+
/** Errors of a rejected send: code → HTTP status and the message of conversations' `lang/es/public_api.php`. */
|
|
461
|
+
declare const REJECTIONS: Record<string, {
|
|
462
|
+
status: number;
|
|
463
|
+
message: string;
|
|
464
|
+
}>;
|
|
465
|
+
/** `error.reason` of a message Funnelchat accepted and then stopped, with its title (`public_api.send_failure`, es). */
|
|
466
|
+
declare const FAILURE_REASONS: Record<string, string>;
|
|
467
|
+
/** Frequent error codes of the WhatsApp Cloud API, with the `title` Meta sends. */
|
|
468
|
+
declare const META_ERRORS: Record<number, string>;
|
|
469
|
+
|
|
470
|
+
declare const SEND_FILE_TYPES: readonly ["image", "video", "audio", "document", "sticker"];
|
|
471
|
+
type SendFileType = (typeof SEND_FILE_TYPES)[number];
|
|
472
|
+
/** `SupportedFileExtensionEnum`, mapped to the type of `messageTypeByFileExtension`. */
|
|
473
|
+
declare const SUPPORTED_EXTENSIONS: Record<string, SendFileType>;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The five starting points of `POST /api/v1/forms` (`template_key`), copied from conversations v1.18.10
|
|
477
|
+
* `database/seeders/WhatsappFormTemplatesSeeder.php`, in their `sort` order.
|
|
478
|
+
*/
|
|
479
|
+
interface FormTemplate {
|
|
480
|
+
key: string;
|
|
481
|
+
title: string;
|
|
482
|
+
description: string;
|
|
483
|
+
category: string;
|
|
484
|
+
definition: Record<string, unknown>;
|
|
485
|
+
}
|
|
486
|
+
declare const FORM_TEMPLATES: FormTemplate[];
|
|
487
|
+
|
|
488
|
+
declare const VERSION: string;
|
|
489
|
+
|
|
490
|
+
export { type CatalogEntry, type ConfigInput, type CreateSandboxOptions, DELIVERY_HEADER, EDGE_ERRORS, EVENT_HEADER, FAILURE_REASONS, FORM_TEMPLATES, META_ERRORS, type PresetSubscription, REJECTIONS, REQUEST_ID_HEADER, SEND_FILE_TYPES, SIGNATURE_HEADER, SUPPORTED_EXTENSIONS, type Sandbox, type SandboxConfig, VERSION, catalog, createSandbox, defaultConfig, mergeConfig, signature };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { DELIVERY_HEADER, EDGE_ERRORS, EVENT_HEADER, FAILURE_REASONS, FORM_TEMPLATES, META_ERRORS, REJECTIONS, REQUEST_ID_HEADER, SEND_FILE_TYPES, SIGNATURE_HEADER, SUPPORTED_EXTENSIONS, VERSION, catalog, createSandbox, defaultConfig, mergeConfig, signature } from './chunk-PF4CGK6J.js';
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
3
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 4010,
|
|
3
|
+
"statusDelays": { "sent": 1, "delivered": 2, "read": 3 },
|
|
4
|
+
"webhooks": { "retryDelays": [5, 15, 30], "timeoutSeconds": 10 },
|
|
5
|
+
"rules": { "window": true, "antiblock": true, "rateLimit": true },
|
|
6
|
+
"templateApprovalSeconds": 5,
|
|
7
|
+
"subscriptions": [
|
|
8
|
+
{
|
|
9
|
+
"url": "http://localhost:3000/hooks/funnelchat",
|
|
10
|
+
"events": ["message.received", "message.status_changed", "contact.created"],
|
|
11
|
+
"description": "My local receiver",
|
|
12
|
+
"secret": "whsec_desarrolloLocalNoUsarEnProduccion"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@funnelchat-hq/sandbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A local server that imitates the Funnelchat public API and sends signed webhooks, with no real effects",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Funnelchat",
|
|
8
|
+
"email": "soporte@funnelchat.com"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://developers-portal.funnelchat.app/docs/",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Funnelchat20/funnelchat-sandbox.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Funnelchat20/funnelchat-sandbox/issues",
|
|
17
|
+
"email": "soporte@funnelchat.com"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"funnelchat",
|
|
21
|
+
"whatsapp",
|
|
22
|
+
"api",
|
|
23
|
+
"sandbox",
|
|
24
|
+
"mock",
|
|
25
|
+
"webhooks"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.11"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"funnelchat-sandbox": "dist/cli.js"
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist/**/*.js",
|
|
48
|
+
"dist/**/*.d.ts",
|
|
49
|
+
"spec/procedencia.json",
|
|
50
|
+
"funnelchat-sandbox.config.example.json",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"generate": "node scripts/generate.mjs",
|
|
56
|
+
"generate:check": "node scripts/generate.mjs --check",
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"start": "node dist/cli.js",
|
|
59
|
+
"dev": "tsup --watch --onSuccess \"node dist/cli.js\"",
|
|
60
|
+
"lint": "eslint .",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:watch": "vitest",
|
|
64
|
+
"prepack": "npm run build"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"ajv": "^8.17.1",
|
|
68
|
+
"ajv-formats": "^3.0.1"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@eslint/js": "^9.39.5",
|
|
72
|
+
"@types/node": "^20.19.43",
|
|
73
|
+
"eslint": "^9.39.5",
|
|
74
|
+
"tsup": "^8.5.1",
|
|
75
|
+
"typescript": "~5.9.3",
|
|
76
|
+
"typescript-eslint": "^8.71.2",
|
|
77
|
+
"vitest": "^4.1.12"
|
|
78
|
+
}
|
|
79
|
+
}
|