@rawdash/connector-twilio 0.0.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/dist/index.js ADDED
@@ -0,0 +1,574 @@
1
+ // ../../connector-shared/dist/index.js
2
+ var HTTP_CLIENT_VERSION = "0.0.0";
3
+ var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
4
+ function connectorUserAgent(connectorId) {
5
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
6
+ }
7
+ function sanitizeAllowedUrl(options) {
8
+ const { url, host, pathname, protocol = "https:" } = options;
9
+ if (url === null) {
10
+ return null;
11
+ }
12
+ try {
13
+ const u = new URL(url);
14
+ if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {
15
+ return null;
16
+ }
17
+ return u.toString();
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ function parseEpoch(value, unit) {
23
+ if (value === null || value === void 0) {
24
+ return null;
25
+ }
26
+ if (unit === "iso") {
27
+ if (typeof value !== "string") {
28
+ return null;
29
+ }
30
+ const ms = new Date(value).getTime();
31
+ return Number.isFinite(ms) ? ms : null;
32
+ }
33
+ if (typeof value === "string" && value.trim() === "") {
34
+ return null;
35
+ }
36
+ const n = typeof value === "number" ? value : Number(value);
37
+ if (!Number.isFinite(n)) {
38
+ return null;
39
+ }
40
+ const result = unit === "s" ? n * 1e3 : n;
41
+ return Number.isFinite(result) ? result : null;
42
+ }
43
+
44
+ // src/twilio.ts
45
+ import {
46
+ BaseConnector,
47
+ defineConfigFields,
48
+ defineConnectorDoc,
49
+ defineResources,
50
+ makeChunkedCursorGuard,
51
+ metricSample,
52
+ paginateChunked,
53
+ schemasFromResources,
54
+ selectActivePhases
55
+ } from "@rawdash/core";
56
+ import { z } from "zod";
57
+ var TWILIO_API_HOST = "api.twilio.com";
58
+ var TWILIO_API_BASE = `https://${TWILIO_API_HOST}`;
59
+ var API_VERSION = "2010-04-01";
60
+ var PAGE_SIZE = 1e3;
61
+ var MS_PER_DAY = 864e5;
62
+ var DEFAULT_LOOKBACK_DAYS = 30;
63
+ var INCREMENTAL_LOOKBACK_DAYS = 2;
64
+ var configFields = defineConfigFields(
65
+ z.object({
66
+ accountSid: z.string().min(1).meta({
67
+ label: "Account SID",
68
+ description: "Twilio Account SID (starts with AC). Found on the Twilio Console dashboard. Used as the Basic auth username and in every request path.",
69
+ placeholder: "AC..."
70
+ }),
71
+ authToken: z.object({ $secret: z.string().min(1) }).meta({
72
+ label: "Auth token",
73
+ description: "Twilio Auth token for the account, used as the Basic auth password. Prefer a standard Auth token; an API key secret also works when paired with its SID as the Account SID.",
74
+ placeholder: "TWILIO_AUTH_TOKEN",
75
+ secret: true
76
+ }),
77
+ resources: z.array(
78
+ z.enum([
79
+ "twilio_message",
80
+ "twilio_call",
81
+ "twilio_usage_count",
82
+ "twilio_usage_price"
83
+ ])
84
+ ).nonempty().optional().meta({
85
+ label: "Resources",
86
+ description: "Which Twilio resources to sync. Omit to sync all of them. The two usage metrics share one upstream call to the daily Usage Records report."
87
+ }),
88
+ lookbackDays: z.number().int().positive().max(365).optional().meta({
89
+ label: "Backfill window (days)",
90
+ description: "How many days of usage history to fetch on a full sync. Defaults to 30. Message and call backfill is bounded by the same window.",
91
+ placeholder: "30"
92
+ })
93
+ })
94
+ );
95
+ var doc = defineConnectorDoc({
96
+ displayName: "Twilio",
97
+ category: "engineering",
98
+ brandColor: "#F22F46",
99
+ tagline: "Track SMS and voice volume, delivery and error rates, and per-category spend from the Twilio REST API.",
100
+ vendor: {
101
+ name: "Twilio",
102
+ domain: "twilio.com",
103
+ apiDocs: "https://www.twilio.com/docs/usage/api",
104
+ website: "https://twilio.com"
105
+ },
106
+ auth: {
107
+ summary: "Authenticates over HTTP Basic auth using the Twilio Account SID as the username and the Auth token as the password. Read access to messages, calls, and usage records is sufficient.",
108
+ setup: [
109
+ "Open the Twilio Console dashboard and copy your Account SID (starts with AC).",
110
+ "Copy the Auth token shown next to it, or create a standard API key (Console -> Account -> API keys & tokens) and use its SID and secret.",
111
+ "Store the token as a secret (e.g. TWILIO_AUTH_TOKEN).",
112
+ 'Reference it from config as `authToken: secret("TWILIO_AUTH_TOKEN")` alongside `accountSid: "AC..."`.'
113
+ ]
114
+ },
115
+ rateLimit: "Twilio returns 429 with a Retry-After header when the per-account concurrency budget is exceeded; the shared HTTP client honors it. List endpoints paginate via a relative next_page_uri with a configurable PageSize (capped at 1000 here).",
116
+ limitations: [
117
+ "Monetary amounts (message/call price, usage price) are reported by Twilio as negative-signed decimal strings; the connector stores their absolute value as a positive number.",
118
+ "Message and call events are bounded by the backfill window; very high-volume accounts should sync the usage metrics rather than per-message events for spend and volume trends.",
119
+ "Usage is read from the daily Usage Records report (1-day granularity); sub-daily usage is not exposed."
120
+ ]
121
+ });
122
+ var PHASE_ORDER = ["messages", "calls", "usage"];
123
+ var isTwilioSyncCursor = makeChunkedCursorGuard(PHASE_ORDER);
124
+ var RESOURCES_BY_PHASE = {
125
+ messages: ["twilio_message"],
126
+ calls: ["twilio_call"],
127
+ usage: ["twilio_usage_count", "twilio_usage_price"]
128
+ };
129
+ var ENDPOINT_PATH = {
130
+ messages: `/${API_VERSION}/Accounts/{sid}/Messages.json`,
131
+ calls: `/${API_VERSION}/Accounts/{sid}/Calls.json`,
132
+ usage: `/${API_VERSION}/Accounts/{sid}/Usage/Records/Daily.json`
133
+ };
134
+ var messageSchema = z.object({
135
+ sid: z.string().min(1),
136
+ status: z.string().nullish(),
137
+ error_code: z.number().int().nullish(),
138
+ direction: z.string().nullish(),
139
+ price: z.string().nullish(),
140
+ price_unit: z.string().nullish(),
141
+ date_sent: z.string().nullish(),
142
+ date_created: z.string().nullish(),
143
+ from: z.string().nullish(),
144
+ to: z.string().nullish(),
145
+ num_segments: z.string().nullish(),
146
+ num_media: z.string().nullish(),
147
+ messaging_service_sid: z.string().nullish()
148
+ });
149
+ var callSchema = z.object({
150
+ sid: z.string().min(1),
151
+ status: z.string().nullish(),
152
+ direction: z.string().nullish(),
153
+ duration: z.string().nullish(),
154
+ price: z.string().nullish(),
155
+ price_unit: z.string().nullish(),
156
+ start_time: z.string().nullish(),
157
+ end_time: z.string().nullish(),
158
+ date_created: z.string().nullish(),
159
+ from: z.string().nullish(),
160
+ to: z.string().nullish()
161
+ });
162
+ var usageRecordSchema = z.object({
163
+ category: z.string().min(1),
164
+ description: z.string().nullish(),
165
+ count: z.string().nullish(),
166
+ count_unit: z.string().nullish(),
167
+ usage: z.string().nullish(),
168
+ usage_unit: z.string().nullish(),
169
+ price: z.string().nullish(),
170
+ price_unit: z.string().nullish(),
171
+ start_date: z.string().nullish(),
172
+ end_date: z.string().nullish()
173
+ });
174
+ var messagesResponseSchema = z.object({
175
+ messages: z.array(messageSchema),
176
+ next_page_uri: z.string().nullish()
177
+ });
178
+ var callsResponseSchema = z.object({
179
+ calls: z.array(callSchema),
180
+ next_page_uri: z.string().nullish()
181
+ });
182
+ var usageResponseSchema = z.object({
183
+ usage_records: z.array(usageRecordSchema),
184
+ next_page_uri: z.string().nullish()
185
+ });
186
+ var USAGE_DIMENSIONS = [
187
+ {
188
+ name: "category",
189
+ description: "Twilio usage category (e.g. sms, sms-inbound, sms-outbound, calls, calls-inbound, calls-outbound, verify, whatsapp)."
190
+ },
191
+ {
192
+ name: "description",
193
+ description: "Human-readable label for the category, or null."
194
+ }
195
+ ];
196
+ var twilioResources = defineResources({
197
+ twilio_message: {
198
+ shape: "event",
199
+ filterable: [],
200
+ description: "SMS / MMS message attempts with status, error code, direction, and price, timestamped at the time the message was sent.",
201
+ endpoint: "GET /2010-04-01/Accounts/{AccountSid}/Messages.json",
202
+ notes: "start_ts is date_sent when present, falling back to date_created. Messages whose timestamp cannot be parsed are skipped.",
203
+ fields: [
204
+ { name: "sid", description: "Twilio message SID." },
205
+ {
206
+ name: "status",
207
+ description: "Delivery status (queued, sending, sent, delivered, undelivered, failed, received, ...)."
208
+ },
209
+ {
210
+ name: "errorCode",
211
+ description: "Twilio error code if the message failed, else null."
212
+ },
213
+ {
214
+ name: "direction",
215
+ description: "Message direction (inbound, outbound-api, outbound-call, outbound-reply)."
216
+ },
217
+ {
218
+ name: "price",
219
+ description: "Absolute price charged for the message in priceUnit, or null if not yet priced."
220
+ },
221
+ {
222
+ name: "priceUnit",
223
+ description: "ISO currency code for price, or null."
224
+ },
225
+ { name: "from", description: "Sender address or number." },
226
+ { name: "to", description: "Recipient address or number." },
227
+ {
228
+ name: "numSegments",
229
+ description: "Number of message segments billed."
230
+ },
231
+ { name: "numMedia", description: "Number of media attachments." },
232
+ {
233
+ name: "messagingServiceSid",
234
+ description: "Messaging Service SID the message was sent through, or null."
235
+ }
236
+ ],
237
+ responses: { messages: messagesResponseSchema }
238
+ },
239
+ twilio_call: {
240
+ shape: "event",
241
+ filterable: [],
242
+ description: "Voice call attempts with status, direction, duration, and price, timestamped at the call start time.",
243
+ endpoint: "GET /2010-04-01/Accounts/{AccountSid}/Calls.json",
244
+ notes: "start_ts is start_time when present, falling back to date_created. Calls whose timestamp cannot be parsed are skipped.",
245
+ fields: [
246
+ { name: "sid", description: "Twilio call SID." },
247
+ {
248
+ name: "status",
249
+ description: "Call status (queued, ringing, in-progress, completed, busy, failed, no-answer, canceled)."
250
+ },
251
+ {
252
+ name: "direction",
253
+ description: "Call direction (inbound, outbound-api, outbound-dial)."
254
+ },
255
+ {
256
+ name: "duration",
257
+ description: "Call duration in seconds.",
258
+ unit: "seconds"
259
+ },
260
+ {
261
+ name: "price",
262
+ description: "Absolute price charged for the call in priceUnit, or null if not yet priced."
263
+ },
264
+ {
265
+ name: "priceUnit",
266
+ description: "ISO currency code for price, or null."
267
+ },
268
+ { name: "from", description: "Caller number." },
269
+ { name: "to", description: "Callee number." }
270
+ ],
271
+ responses: { calls: callsResponseSchema }
272
+ },
273
+ twilio_usage_count: {
274
+ shape: "metric",
275
+ description: "Daily usage count per Twilio billing category, from the daily Usage Records report.",
276
+ endpoint: "GET /2010-04-01/Accounts/{AccountSid}/Usage/Records/Daily.json",
277
+ unit: "count",
278
+ granularity: "daily",
279
+ dimensions: [...USAGE_DIMENSIONS],
280
+ measures: [
281
+ {
282
+ name: "usage",
283
+ description: "Raw usage amount in the category usage_unit (may differ from count, e.g. seconds for voice)."
284
+ }
285
+ ],
286
+ notes: "Sample value is the Usage Record count. Written from the same usage call as twilio_usage_price.",
287
+ responses: { usage_records: usageResponseSchema }
288
+ },
289
+ twilio_usage_price: {
290
+ shape: "metric",
291
+ description: "Daily spend per Twilio billing category, from the daily Usage Records report.",
292
+ endpoint: "GET /2010-04-01/Accounts/{AccountSid}/Usage/Records/Daily.json",
293
+ unit: "currency",
294
+ granularity: "daily",
295
+ dimensions: [...USAGE_DIMENSIONS],
296
+ notes: "Sample value is the absolute Usage Record price in priceUnit. Written alongside twilio_usage_count from one usage call."
297
+ }
298
+ });
299
+ var twilioCredentials = {
300
+ authToken: {
301
+ description: "Twilio Auth token (Basic auth password)",
302
+ auth: "required"
303
+ }
304
+ };
305
+ var id = "twilio";
306
+ function absNumber(value) {
307
+ if (value === null || value === void 0 || value.trim() === "") {
308
+ return null;
309
+ }
310
+ const n = Number.parseFloat(value);
311
+ return Number.isFinite(n) ? Math.abs(n) : null;
312
+ }
313
+ function intCount(value) {
314
+ if (value === null || value === void 0 || value.trim() === "") {
315
+ return 0;
316
+ }
317
+ const n = Number.parseFloat(value);
318
+ return Number.isFinite(n) ? n : 0;
319
+ }
320
+ function messageStartTs(message) {
321
+ return parseEpoch(message.date_sent ?? null, "iso") ?? parseEpoch(message.date_created ?? null, "iso");
322
+ }
323
+ function callStartTs(call) {
324
+ return parseEpoch(call.start_time ?? null, "iso") ?? parseEpoch(call.date_created ?? null, "iso");
325
+ }
326
+ function buildMessageEvents(messages) {
327
+ const events = [];
328
+ for (const m of messages) {
329
+ const ts = messageStartTs(m);
330
+ if (ts === null) {
331
+ continue;
332
+ }
333
+ events.push({
334
+ name: "twilio_message",
335
+ start_ts: ts,
336
+ end_ts: null,
337
+ attributes: {
338
+ sid: m.sid,
339
+ status: m.status ?? null,
340
+ errorCode: m.error_code ?? null,
341
+ direction: m.direction ?? null,
342
+ price: absNumber(m.price),
343
+ priceUnit: m.price_unit ?? null,
344
+ from: m.from ?? null,
345
+ to: m.to ?? null,
346
+ numSegments: intCount(m.num_segments),
347
+ numMedia: intCount(m.num_media),
348
+ messagingServiceSid: m.messaging_service_sid ?? null
349
+ }
350
+ });
351
+ }
352
+ return events;
353
+ }
354
+ function buildCallEvents(calls) {
355
+ const events = [];
356
+ for (const c of calls) {
357
+ const ts = callStartTs(c);
358
+ if (ts === null) {
359
+ continue;
360
+ }
361
+ events.push({
362
+ name: "twilio_call",
363
+ start_ts: ts,
364
+ end_ts: null,
365
+ attributes: {
366
+ sid: c.sid,
367
+ status: c.status ?? null,
368
+ direction: c.direction ?? null,
369
+ duration: intCount(c.duration),
370
+ price: absNumber(c.price),
371
+ priceUnit: c.price_unit ?? null,
372
+ from: c.from ?? null,
373
+ to: c.to ?? null
374
+ }
375
+ });
376
+ }
377
+ return events;
378
+ }
379
+ function buildUsageSamples(records) {
380
+ const counts = [];
381
+ const prices = [];
382
+ for (const r of records) {
383
+ const ts = parseEpoch(r.start_date ?? null, "iso");
384
+ if (ts === null) {
385
+ continue;
386
+ }
387
+ const dims = {
388
+ category: r.category,
389
+ description: r.description ?? null
390
+ };
391
+ counts.push(
392
+ metricSample(twilioResources, "twilio_usage_count", {
393
+ ts,
394
+ value: intCount(r.count),
395
+ attributes: { ...dims, usage: intCount(r.usage) }
396
+ })
397
+ );
398
+ prices.push(
399
+ metricSample(twilioResources, "twilio_usage_price", {
400
+ ts,
401
+ value: absNumber(r.price) ?? 0,
402
+ attributes: { ...dims }
403
+ })
404
+ );
405
+ }
406
+ return { counts, prices };
407
+ }
408
+ function resourceToPhase(resource) {
409
+ for (const phase of PHASE_ORDER) {
410
+ if (RESOURCES_BY_PHASE[phase].includes(resource)) {
411
+ return phase;
412
+ }
413
+ }
414
+ throw new Error(`twilio: unmapped resource ${resource}`);
415
+ }
416
+ function toDate(ms) {
417
+ return new Date(ms).toISOString().slice(0, 10);
418
+ }
419
+ var TwilioConnector = class _TwilioConnector extends BaseConnector {
420
+ static id = id;
421
+ static resources = twilioResources;
422
+ static schemas = schemasFromResources(twilioResources);
423
+ static create(input, ctx) {
424
+ const parsed = configFields.parse(input);
425
+ return new _TwilioConnector(
426
+ {
427
+ accountSid: parsed.accountSid,
428
+ resources: parsed.resources,
429
+ lookbackDays: parsed.lookbackDays
430
+ },
431
+ { authToken: parsed.authToken },
432
+ ctx
433
+ );
434
+ }
435
+ id = id;
436
+ credentials = twilioCredentials;
437
+ buildHeaders() {
438
+ const basic = btoa(
439
+ `${this.settings.accountSid}:${String(this.creds.authToken)}`
440
+ );
441
+ return {
442
+ Authorization: `Basic ${basic}`,
443
+ Accept: "application/json",
444
+ "User-Agent": connectorUserAgent(this.id)
445
+ };
446
+ }
447
+ fetch(url, resource, signal) {
448
+ return this.get(url, {
449
+ resource,
450
+ headers: this.buildHeaders(),
451
+ signal
452
+ });
453
+ }
454
+ phasePath(phase) {
455
+ return ENDPOINT_PATH[phase].replace("{sid}", this.settings.accountSid);
456
+ }
457
+ buildInitialUrl(phase, options, lookbackDays, now) {
458
+ const url = new URL(`${TWILIO_API_BASE}${this.phasePath(phase)}`);
459
+ url.searchParams.set("PageSize", String(PAGE_SIZE));
460
+ if (phase === "usage") {
461
+ const days = options.mode === "latest" ? INCREMENTAL_LOOKBACK_DAYS : lookbackDays;
462
+ const sinceMs2 = options.since ? parseEpoch(options.since, "iso") : null;
463
+ const startMs = sinceMs2 !== null ? Math.min(sinceMs2, now - INCREMENTAL_LOOKBACK_DAYS * MS_PER_DAY) : now - days * MS_PER_DAY;
464
+ url.searchParams.set("StartDate", toDate(startMs));
465
+ url.searchParams.set("EndDate", toDate(now));
466
+ return url.toString();
467
+ }
468
+ const sinceMs = options.since ? parseEpoch(options.since, "iso") : now - lookbackDays * MS_PER_DAY;
469
+ if (sinceMs !== null) {
470
+ const field = phase === "messages" ? "DateSent>" : "StartTime>";
471
+ url.searchParams.set(field, toDate(sinceMs));
472
+ }
473
+ return url.toString();
474
+ }
475
+ nextUrl(phase, nextPageUri) {
476
+ if (!nextPageUri) {
477
+ return null;
478
+ }
479
+ return sanitizeAllowedUrl({
480
+ url: `${TWILIO_API_BASE}${nextPageUri}`,
481
+ host: TWILIO_API_HOST,
482
+ pathname: this.phasePath(phase)
483
+ });
484
+ }
485
+ async writePhase(storage, phase, items) {
486
+ switch (phase) {
487
+ case "messages":
488
+ await storage.events(buildMessageEvents(items), {
489
+ names: ["twilio_message"]
490
+ });
491
+ return;
492
+ case "calls":
493
+ await storage.events(buildCallEvents(items), {
494
+ names: ["twilio_call"]
495
+ });
496
+ return;
497
+ case "usage": {
498
+ const { counts, prices } = buildUsageSamples(
499
+ items
500
+ );
501
+ await storage.metrics(counts, { names: ["twilio_usage_count"] });
502
+ await storage.metrics(prices, { names: ["twilio_usage_price"] });
503
+ return;
504
+ }
505
+ }
506
+ }
507
+ parsePage(phase, body) {
508
+ switch (phase) {
509
+ case "messages": {
510
+ const parsed = messagesResponseSchema.parse(body);
511
+ return {
512
+ items: parsed.messages,
513
+ nextPageUri: parsed.next_page_uri ?? null
514
+ };
515
+ }
516
+ case "calls": {
517
+ const parsed = callsResponseSchema.parse(body);
518
+ return {
519
+ items: parsed.calls,
520
+ nextPageUri: parsed.next_page_uri ?? null
521
+ };
522
+ }
523
+ case "usage": {
524
+ const parsed = usageResponseSchema.parse(body);
525
+ return {
526
+ items: parsed.usage_records,
527
+ nextPageUri: parsed.next_page_uri ?? null
528
+ };
529
+ }
530
+ }
531
+ }
532
+ async sync(options, storage, signal) {
533
+ const cursor = isTwilioSyncCursor(options.cursor) ? options.cursor : void 0;
534
+ const lookbackDays = this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
535
+ const now = Date.now();
536
+ const phases = selectActivePhases(
537
+ resourceToPhase,
538
+ PHASE_ORDER,
539
+ this.settings.resources
540
+ );
541
+ return paginateChunked({
542
+ phases,
543
+ cursor,
544
+ signal,
545
+ logger: this.logger,
546
+ fetchPage: async (phase, page, sig) => {
547
+ const url = page ?? this.buildInitialUrl(phase, options, lookbackDays, now);
548
+ const res = await this.fetch(url, phase, sig);
549
+ const { items, nextPageUri } = this.parsePage(phase, res.body);
550
+ return { items, next: this.nextUrl(phase, nextPageUri) };
551
+ },
552
+ writeBatch: async (phase, items) => {
553
+ await this.writePhase(storage, phase, items);
554
+ }
555
+ });
556
+ }
557
+ };
558
+
559
+ // src/index.ts
560
+ var index_default = TwilioConnector;
561
+ export {
562
+ TwilioConnector,
563
+ buildCallEvents,
564
+ buildMessageEvents,
565
+ buildUsageSamples,
566
+ callStartTs,
567
+ configFields,
568
+ index_default as default,
569
+ doc,
570
+ id,
571
+ messageStartTs,
572
+ twilioResources as resources
573
+ };
574
+ //# sourceMappingURL=index.js.map