@messagebird/n8n-nodes-bird 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/credentials/BirdApi.credentials.js +53 -0
- package/dist/credentials/bird-dark.svg +4 -0
- package/dist/credentials/bird.svg +4 -0
- package/dist/index.js +9 -0
- package/dist/nodes/Bird/Bird.node.js +121 -0
- package/dist/nodes/Bird/Bird.node.json +23 -0
- package/dist/nodes/Bird/BirdTrigger.node.js +161 -0
- package/dist/nodes/Bird/BirdTrigger.node.json +23 -0
- package/dist/nodes/Bird/bird-dark.svg +4 -0
- package/dist/nodes/Bird/bird.svg +4 -0
- package/dist/nodes/Bird/generated/events.gen.js +59 -0
- package/dist/nodes/Bird/generated/pickers.gen.js +28 -0
- package/dist/nodes/Bird/generated/properties.gen.js +10560 -0
- package/dist/nodes/Bird/generated/routing.gen.js +182 -0
- package/dist/nodes/Bird/generated/scopes.gen.js +5 -0
- package/dist/nodes/Bird/pickers.js +116 -0
- package/dist/nodes/Bird/transport.js +484 -0
- package/dist/nodes/Bird/webhook-verify.js +46 -0
- package/package.json +64 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The core every operation's route executes through — the analog of the SDK's
|
|
3
|
+
// client.ts. What lives here is the behavior a route table cannot express:
|
|
4
|
+
// region derivation, blank omission, pagination, binary and error handling.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ROW_PROPERTY_DEFAULT = exports.ROW_FROM_ITEM = exports.ROW_PROPERTY = exports.ROW_TYPE = void 0;
|
|
7
|
+
exports.baseUrlForKey = baseUrlForKey;
|
|
8
|
+
exports.birdErrorParts = birdErrorParts;
|
|
9
|
+
exports.completeDateTime = completeDateTime;
|
|
10
|
+
exports.compact = compact;
|
|
11
|
+
exports.asNodeError = asNodeError;
|
|
12
|
+
exports.unwrapLocator = unwrapLocator;
|
|
13
|
+
exports.resolveBinary = resolveBinary;
|
|
14
|
+
exports.buildRequest = buildRequest;
|
|
15
|
+
exports.collectPages = collectPages;
|
|
16
|
+
exports.birdRequest = birdRequest;
|
|
17
|
+
const REGION = /^[a-z]{2}[0-9]+$/;
|
|
18
|
+
function baseUrlForKey(key) {
|
|
19
|
+
const [prefix, region, ...rest] = key.split("_");
|
|
20
|
+
if (prefix !== "bk" ||
|
|
21
|
+
!region ||
|
|
22
|
+
!REGION.test(region) ||
|
|
23
|
+
rest.join("_") === "") {
|
|
24
|
+
throw new Error("Bird API key is not in the expected bk_{region}_{token} format. Check the credential.");
|
|
25
|
+
}
|
|
26
|
+
return `https://${region}.platform.bird.com`;
|
|
27
|
+
}
|
|
28
|
+
function birdErrorParts(statusCode, body, headers = {}) {
|
|
29
|
+
const err = (typeof body === "object" && body !== null ? body : {})
|
|
30
|
+
.error ?? {};
|
|
31
|
+
const message = err.message ?? `Bird returned an unexpected error (HTTP ${statusCode}).`;
|
|
32
|
+
const parts = [];
|
|
33
|
+
if (err.remediation)
|
|
34
|
+
parts.push(err.remediation);
|
|
35
|
+
for (const d of err.details ?? []) {
|
|
36
|
+
if (d.param && d.message)
|
|
37
|
+
parts.push(`${d.param}: ${d.message}`);
|
|
38
|
+
}
|
|
39
|
+
// A rate-limited loop over items is n8n's common failure mode, so the wait
|
|
40
|
+
// the gateway already announced is worth repeating to the user.
|
|
41
|
+
if (statusCode === 429) {
|
|
42
|
+
const reset = headers["retry-after"] ?? headers["ratelimit-reset"];
|
|
43
|
+
if ((typeof reset === "string" && reset !== "") ||
|
|
44
|
+
typeof reset === "number") {
|
|
45
|
+
parts.push(`Retry after ${reset} second(s).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { message, description: parts.join(" ") };
|
|
49
|
+
}
|
|
50
|
+
// n8n's dateTime picker stores a naive local timestamp, and Bird requires an
|
|
51
|
+
// RFC 3339 offset. The n8n-native reading of a naive value is the workflow's
|
|
52
|
+
// timezone; two zoneOffset passes because the offset itself can shift at a
|
|
53
|
+
// DST boundary near the picked instant.
|
|
54
|
+
const NAIVE_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
|
|
55
|
+
function zoneOffset(zone, at) {
|
|
56
|
+
const name = new Intl.DateTimeFormat("en-US", {
|
|
57
|
+
timeZone: zone,
|
|
58
|
+
timeZoneName: "longOffset",
|
|
59
|
+
})
|
|
60
|
+
.formatToParts(at)
|
|
61
|
+
.find((p) => p.type === "timeZoneName")?.value;
|
|
62
|
+
const m = /^GMT([+-]\d{2}:\d{2})?$/.exec(name ?? "");
|
|
63
|
+
if (!m)
|
|
64
|
+
throw new Error(`unresolvable zone ${zone}`);
|
|
65
|
+
return m[1] ?? "+00:00";
|
|
66
|
+
}
|
|
67
|
+
function completeDateTime(value, zone) {
|
|
68
|
+
if (!NAIVE_DATETIME.test(value))
|
|
69
|
+
return value;
|
|
70
|
+
const base = value.length === 16 ? `${value}:00` : value;
|
|
71
|
+
try {
|
|
72
|
+
const guess = zoneOffset(zone, new Date(`${base}Z`));
|
|
73
|
+
return base + zoneOffset(zone, new Date(base + guess));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return `${base}Z`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Bird reads an empty filter as one nothing matches, so a blank is omitted
|
|
80
|
+
// rather than sent. 0 and false are real values and stay.
|
|
81
|
+
function compact(values) {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [k, v] of Object.entries(values)) {
|
|
84
|
+
if (v === undefined || v === null)
|
|
85
|
+
continue;
|
|
86
|
+
if (typeof v === "string") {
|
|
87
|
+
const t = v.trim();
|
|
88
|
+
if (t === "")
|
|
89
|
+
continue;
|
|
90
|
+
out[k] = t;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(v)) {
|
|
94
|
+
const list = v
|
|
95
|
+
.map((e) => (typeof e === "string" ? e.trim() : e))
|
|
96
|
+
.filter((e) => e !== undefined && e !== null && e !== "");
|
|
97
|
+
if (list.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
out[k] = list;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
out[k] = v;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
107
|
+
// A NodeOperationError already names a configuration mistake and the item it
|
|
108
|
+
// happened on; wrapping one would relabel it as something the API said.
|
|
109
|
+
function asNodeError(node, error, itemIndex) {
|
|
110
|
+
if (error instanceof n8n_workflow_1.NodeApiError || error instanceof n8n_workflow_1.NodeOperationError) {
|
|
111
|
+
return error;
|
|
112
|
+
}
|
|
113
|
+
return new n8n_workflow_1.NodeApiError(node, error, { itemIndex });
|
|
114
|
+
}
|
|
115
|
+
// foldPairs turns the editor's {pairs: [{key, value}]} rows into a map,
|
|
116
|
+
// dropping blank keys; anything else (an expression's own object) passes.
|
|
117
|
+
function foldPairs(raw) {
|
|
118
|
+
if (typeof raw !== "object" || raw === null)
|
|
119
|
+
return raw;
|
|
120
|
+
const pairs = raw.pairs;
|
|
121
|
+
if (!Array.isArray(pairs))
|
|
122
|
+
return raw;
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const p of pairs) {
|
|
125
|
+
const { key, value } = (p ?? {});
|
|
126
|
+
if (typeof key === "string" && key.trim() !== "")
|
|
127
|
+
out[key.trim()] = value;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function foldMembers(members) {
|
|
132
|
+
const folded = {};
|
|
133
|
+
for (const [key, value] of Object.entries(members)) {
|
|
134
|
+
const fv = foldPairs(value);
|
|
135
|
+
if (typeof fv === "object" &&
|
|
136
|
+
fv !== null &&
|
|
137
|
+
Object.keys(fv).length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
if (typeof fv === "string" && fv.trim() === "")
|
|
140
|
+
continue;
|
|
141
|
+
folded[key] = fv;
|
|
142
|
+
}
|
|
143
|
+
return folded;
|
|
144
|
+
}
|
|
145
|
+
// A row of a repeatable fixedCollection folds exactly like a struct, which is
|
|
146
|
+
// what lets a list carry a map member with no descriptor of its own.
|
|
147
|
+
function listRows(raw) {
|
|
148
|
+
const rows = Array.isArray(raw) ? raw : raw.items;
|
|
149
|
+
// An untouched fixedCollection stores {}, which is a blank rather than a
|
|
150
|
+
// mistake; anything else non-array is a value the API would reject.
|
|
151
|
+
if (rows === undefined)
|
|
152
|
+
return Object.keys(raw).length === 0 ? [] : undefined;
|
|
153
|
+
if (!Array.isArray(rows))
|
|
154
|
+
return undefined;
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
if (typeof row !== "object" || row === null) {
|
|
158
|
+
out.push(row);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const folded = foldMembers(row);
|
|
162
|
+
if (Object.keys(folded).length > 0)
|
|
163
|
+
out.push(folded);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
// unwrapStructured rewrites collection storage into API objects. A string
|
|
168
|
+
// is parsed as JSON and a plain object passes through, so expressions keep
|
|
169
|
+
// working; a field that unwraps to nothing is dropped like any blank.
|
|
170
|
+
function unwrapStructured(node, itemIndex, route, v) {
|
|
171
|
+
for (const [name, kind] of Object.entries(route.structured)) {
|
|
172
|
+
let raw = v[name];
|
|
173
|
+
if (raw === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
if (typeof raw === "string") {
|
|
176
|
+
try {
|
|
177
|
+
raw = JSON.parse(raw);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new n8n_workflow_1.NodeOperationError(node, kind === "list"
|
|
181
|
+
? `${name} is not valid JSON. Provide an array, for example [{"name": "value"}].`
|
|
182
|
+
: `${name} is not valid JSON. Provide an object, for example {"key": "value"}.`, { itemIndex });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (typeof raw !== "object" || raw === null)
|
|
186
|
+
continue;
|
|
187
|
+
if (kind === "list") {
|
|
188
|
+
const rows = listRows(raw);
|
|
189
|
+
if (rows === undefined) {
|
|
190
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${name} must be a list. Provide an array, for example [{"name": "value"}].`, { itemIndex });
|
|
191
|
+
}
|
|
192
|
+
if (rows.length === 0)
|
|
193
|
+
delete v[name];
|
|
194
|
+
else
|
|
195
|
+
v[name] = rows;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (kind === "map") {
|
|
199
|
+
raw = foldPairs(raw);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const group = raw.values;
|
|
203
|
+
const members = (typeof group === "object" && group !== null ? group : raw);
|
|
204
|
+
raw = foldMembers(members);
|
|
205
|
+
}
|
|
206
|
+
if (typeof raw === "object" &&
|
|
207
|
+
raw !== null &&
|
|
208
|
+
Object.keys(raw).length === 0) {
|
|
209
|
+
delete v[name];
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
v[name] = raw;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A Resource Locator stores {__rl, mode, value} whichever mode picked it, so
|
|
216
|
+
// the raw id is taken once here rather than at every read site. A bare string
|
|
217
|
+
// passes through: an expression supplying an id resolves to one.
|
|
218
|
+
function unwrapLocator(value) {
|
|
219
|
+
if (typeof value === "object" &&
|
|
220
|
+
value !== null &&
|
|
221
|
+
value.__rl === true) {
|
|
222
|
+
return value.value;
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
// The defaults live here because a row left alone arrives carrying neither —
|
|
227
|
+
// see integrations/AGENTS.md on what n8n stores.
|
|
228
|
+
exports.ROW_TYPE = "attachmentType";
|
|
229
|
+
exports.ROW_PROPERTY = "binaryPropertyName";
|
|
230
|
+
const ROW_CONTENT = "content";
|
|
231
|
+
const ROW_FILENAME = "filename";
|
|
232
|
+
const ROW_CONTENT_TYPE = "content_type";
|
|
233
|
+
// ROW_FROM_ITEM doubles as the selector's default: what a row means when the
|
|
234
|
+
// member is absent has to be what the property declares as its default.
|
|
235
|
+
exports.ROW_FROM_ITEM = "binary";
|
|
236
|
+
exports.ROW_PROPERTY_DEFAULT = "data";
|
|
237
|
+
// The API's `birdContentBudgetBytes`: decimal not binary, over the encoded
|
|
238
|
+
// value, and shared with the html and text bodies counted into the same total.
|
|
239
|
+
const MESSAGE_LIMIT = 20_000_000;
|
|
240
|
+
// Which shape the rows arrived in decides what an absent member means, so it has
|
|
241
|
+
// to survive the read. A JSON string is neither shape; buildRequest parses that.
|
|
242
|
+
function storedRows(raw) {
|
|
243
|
+
if (Array.isArray(raw))
|
|
244
|
+
return { rows: raw, fromEditor: false };
|
|
245
|
+
if (typeof raw === "object" && raw !== null) {
|
|
246
|
+
const items = raw.items;
|
|
247
|
+
if (Array.isArray(items))
|
|
248
|
+
return { rows: items, fromEditor: true };
|
|
249
|
+
}
|
|
250
|
+
return { rows: [], fromEditor: false };
|
|
251
|
+
}
|
|
252
|
+
function filled(v) {
|
|
253
|
+
return typeof v === "string" && v.trim() !== "";
|
|
254
|
+
}
|
|
255
|
+
function megabytes(bytes) {
|
|
256
|
+
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
257
|
+
}
|
|
258
|
+
// Rows are replaced rather than mutated: getNodeParameter can hand back the same
|
|
259
|
+
// stored object on every item, which would send item one's file with item two.
|
|
260
|
+
async function resolveBinary(ctx, route, values, itemIndex) {
|
|
261
|
+
let encoded = 0;
|
|
262
|
+
for (const name of route.binaryFields) {
|
|
263
|
+
const { rows: stored, fromEditor } = storedRows(values[name]);
|
|
264
|
+
if (stored.length === 0)
|
|
265
|
+
continue;
|
|
266
|
+
const rows = [];
|
|
267
|
+
for (const source of stored) {
|
|
268
|
+
if (typeof source !== "object" || source === null) {
|
|
269
|
+
rows.push(source);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const src = source;
|
|
273
|
+
const row = { ...src };
|
|
274
|
+
delete row[exports.ROW_TYPE];
|
|
275
|
+
delete row[exports.ROW_PROPERTY];
|
|
276
|
+
// An absent selector means the emitted default only for an editor row — see
|
|
277
|
+
// integrations/AGENTS.md on what an absent member means per author.
|
|
278
|
+
const fromItem = filled(src[exports.ROW_TYPE])
|
|
279
|
+
? src[exports.ROW_TYPE] === exports.ROW_FROM_ITEM
|
|
280
|
+
: fromEditor && !filled(src[ROW_CONTENT]);
|
|
281
|
+
// Blank is the user clearing it, which omits the row; a name the item lacks
|
|
282
|
+
// is a wrong value instead, and assertBinaryData is left to raise on that.
|
|
283
|
+
const prop = exports.ROW_PROPERTY in src
|
|
284
|
+
? String(src[exports.ROW_PROPERTY] ?? "").trim()
|
|
285
|
+
: exports.ROW_PROPERTY_DEFAULT;
|
|
286
|
+
if (fromItem && prop !== "") {
|
|
287
|
+
const meta = ctx.helpers.assertBinaryData(itemIndex, prop);
|
|
288
|
+
// Under n8n's filesystem binary mode `data` holds a reference, not the
|
|
289
|
+
// payload; only the helper resolves both modes.
|
|
290
|
+
const buffer = await ctx.helpers.getBinaryDataBuffer(itemIndex, prop);
|
|
291
|
+
row[ROW_CONTENT] = buffer.toString("base64");
|
|
292
|
+
if (!filled(row[ROW_FILENAME])) {
|
|
293
|
+
row[ROW_FILENAME] = filled(meta.fileName)
|
|
294
|
+
? meta.fileName
|
|
295
|
+
: meta.fileExtension
|
|
296
|
+
? `${prop}.${meta.fileExtension}`
|
|
297
|
+
: prop;
|
|
298
|
+
}
|
|
299
|
+
if (!filled(row[ROW_CONTENT_TYPE]) && filled(meta.mimeType)) {
|
|
300
|
+
row[ROW_CONTENT_TYPE] = meta.mimeType;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Keeps the Input Binary Field promise: without this a cleared row survives
|
|
304
|
+
// on a stray filename and reaches the API with no bytes, which is a 422.
|
|
305
|
+
if (fromEditor && fromItem && prop === "")
|
|
306
|
+
continue;
|
|
307
|
+
if (typeof row[ROW_CONTENT] === "string")
|
|
308
|
+
encoded += row[ROW_CONTENT].length;
|
|
309
|
+
rows.push(row);
|
|
310
|
+
}
|
|
311
|
+
values[name] = rows;
|
|
312
|
+
}
|
|
313
|
+
// Pointing at a file makes the ceiling easy to cross without noticing, and
|
|
314
|
+
// the API answers with a 413 about a body the user never assembled.
|
|
315
|
+
if (encoded > MESSAGE_LIMIT) {
|
|
316
|
+
throw new n8n_workflow_1.NodeOperationError(ctx.getNode(), `The attachments come to ${megabytes(encoded)} once base64-encoded, over the ${megabytes(MESSAGE_LIMIT)} a message may be. Encoding adds about a third, so the files themselves have to stay under roughly ${megabytes(MESSAGE_LIMIT * 0.75)} — less, since the body counts against the same budget.`, { itemIndex });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function buildRequest(node, route, values, zone, itemIndex) {
|
|
320
|
+
for (const [name, stored] of Object.entries(values)) {
|
|
321
|
+
values[name] = unwrapLocator(stored);
|
|
322
|
+
}
|
|
323
|
+
// Capture explicit nulls on nullable fields before compaction drops them:
|
|
324
|
+
// on these fields null is the API's clear operation, not a blank.
|
|
325
|
+
for (const [prop, wire] of Object.entries(route.renames)) {
|
|
326
|
+
if (prop in values) {
|
|
327
|
+
values[wire] = values[prop];
|
|
328
|
+
delete values[prop];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const cleared = route.nullableFields.filter((n) => values[n] === null);
|
|
332
|
+
const v = compact(values);
|
|
333
|
+
unwrapStructured(node, itemIndex, route, v);
|
|
334
|
+
for (const n of cleared)
|
|
335
|
+
v[n] = null;
|
|
336
|
+
// A dotted name (template.components, labels.add) is a member inside an
|
|
337
|
+
// already-unwrapped struct.
|
|
338
|
+
const locate = (name) => {
|
|
339
|
+
const [head, member] = name.split(".");
|
|
340
|
+
if (member === undefined)
|
|
341
|
+
return [v, head];
|
|
342
|
+
return [v[head], member];
|
|
343
|
+
};
|
|
344
|
+
for (const name of route.jsonFields) {
|
|
345
|
+
const [holder, key] = locate(name);
|
|
346
|
+
const raw = holder?.[key];
|
|
347
|
+
if (!holder || typeof raw !== "string")
|
|
348
|
+
continue;
|
|
349
|
+
try {
|
|
350
|
+
holder[key] = JSON.parse(raw);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${name} is not valid JSON. Provide an object, for example {"key": "value"}.`, { itemIndex });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const name of route.arrayFields) {
|
|
357
|
+
const [holder, key] = locate(name);
|
|
358
|
+
const raw = holder?.[key];
|
|
359
|
+
if (!holder || typeof raw !== "string")
|
|
360
|
+
continue;
|
|
361
|
+
const list = raw
|
|
362
|
+
.split(",")
|
|
363
|
+
.map((e) => e.trim())
|
|
364
|
+
.filter((e) => e !== "");
|
|
365
|
+
if (list.length === 0)
|
|
366
|
+
delete holder[key];
|
|
367
|
+
else
|
|
368
|
+
holder[key] = list;
|
|
369
|
+
}
|
|
370
|
+
if (zone) {
|
|
371
|
+
for (const name of route.dateTimeFields) {
|
|
372
|
+
const [holder, key] = locate(name);
|
|
373
|
+
const raw = holder?.[key];
|
|
374
|
+
if (holder && typeof raw === "string")
|
|
375
|
+
holder[key] = completeDateTime(raw, zone);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (route.rawBodyField !== "") {
|
|
379
|
+
const raw = v[route.rawBodyField];
|
|
380
|
+
let parsed = raw;
|
|
381
|
+
if (typeof raw === "string") {
|
|
382
|
+
try {
|
|
383
|
+
parsed = JSON.parse(raw);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${route.rawBodyField} is not valid JSON. Provide a JSON array.`, { itemIndex });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!Array.isArray(parsed)) {
|
|
390
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${route.rawBodyField} must be a JSON array, as the API reference documents.`, { itemIndex });
|
|
391
|
+
}
|
|
392
|
+
let path = route.path;
|
|
393
|
+
for (const p of route.pathParams) {
|
|
394
|
+
const val = v[p];
|
|
395
|
+
if (val === undefined) {
|
|
396
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${p} is required and cannot be blank.`, { itemIndex });
|
|
397
|
+
}
|
|
398
|
+
path = path.replace(`{${p}}`, encodeURIComponent(String(val)));
|
|
399
|
+
}
|
|
400
|
+
const pick = (keys) => Object.fromEntries(keys.filter((k) => k in v).map((k) => [k, v[k]]));
|
|
401
|
+
return {
|
|
402
|
+
path,
|
|
403
|
+
qs: pick(route.query),
|
|
404
|
+
body: parsed,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
let path = route.path;
|
|
408
|
+
for (const p of route.pathParams) {
|
|
409
|
+
const val = v[p];
|
|
410
|
+
if (val === undefined) {
|
|
411
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${p} is required and cannot be blank.`, { itemIndex });
|
|
412
|
+
}
|
|
413
|
+
path = path.replace(`{${p}}`, encodeURIComponent(String(val)));
|
|
414
|
+
}
|
|
415
|
+
requireTopLevel(node, route, v, itemIndex);
|
|
416
|
+
const pick = (keys) => Object.fromEntries(keys.filter((k) => k in v).map((k) => [k, v[k]]));
|
|
417
|
+
return { path, qs: pick(route.query), body: pick(route.body) };
|
|
418
|
+
}
|
|
419
|
+
// n8n's own canvas check misses an empty required multiOptions field, so this
|
|
420
|
+
// names it at execution instead — docs/roadmap/n8n.md carries why.
|
|
421
|
+
function requireTopLevel(node, route, values, itemIndex) {
|
|
422
|
+
for (const name of route.topLevel) {
|
|
423
|
+
// topLevel names the property; the value is under its wire name by now.
|
|
424
|
+
const wire = route.renames[name] ?? name;
|
|
425
|
+
if (route.pathParams.includes(wire))
|
|
426
|
+
continue;
|
|
427
|
+
if (values[wire] === undefined) {
|
|
428
|
+
throw new n8n_workflow_1.NodeOperationError(node, `${name} is required and cannot be empty.`, { itemIndex });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function collectPages(fetchPage, returnAll, limit) {
|
|
433
|
+
const out = [];
|
|
434
|
+
let cursor;
|
|
435
|
+
do {
|
|
436
|
+
const page = await fetchPage(cursor);
|
|
437
|
+
out.push(...(page.data ?? []));
|
|
438
|
+
cursor = page.next_cursor;
|
|
439
|
+
} while (cursor && (returnAll || out.length < limit));
|
|
440
|
+
return returnAll ? out : out.slice(0, limit);
|
|
441
|
+
}
|
|
442
|
+
async function birdRequest(ctx, opts) {
|
|
443
|
+
const creds = await ctx.getCredentials("birdApi");
|
|
444
|
+
let baseUrl;
|
|
445
|
+
try {
|
|
446
|
+
baseUrl = baseUrlForKey(String(creds.apiKey ?? ""));
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
throw new n8n_workflow_1.NodeOperationError(ctx.getNode(), e.message, {
|
|
450
|
+
itemIndex: opts.itemIndex,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// ignoreHttpStatusErrors is what lets Bird's error body survive into
|
|
454
|
+
// birdErrorParts instead of being swallowed by the helper's own throw.
|
|
455
|
+
const res = await ctx.helpers.httpRequestWithAuthentication.call(ctx, "birdApi", {
|
|
456
|
+
method: opts.method,
|
|
457
|
+
url: baseUrl + opts.path,
|
|
458
|
+
headers: opts.headers && Object.keys(opts.headers).length > 0
|
|
459
|
+
? opts.headers
|
|
460
|
+
: undefined,
|
|
461
|
+
qs: opts.qs && Object.keys(opts.qs).length > 0
|
|
462
|
+
? opts.qs
|
|
463
|
+
: undefined,
|
|
464
|
+
body: opts.body && Object.keys(opts.body).length > 0
|
|
465
|
+
? opts.body
|
|
466
|
+
: undefined,
|
|
467
|
+
json: true,
|
|
468
|
+
returnFullResponse: true,
|
|
469
|
+
ignoreHttpStatusErrors: true,
|
|
470
|
+
});
|
|
471
|
+
const statusCode = res.statusCode;
|
|
472
|
+
const body = res.body;
|
|
473
|
+
if (statusCode >= 400) {
|
|
474
|
+
const headers = res.headers ?? {};
|
|
475
|
+
const { message, description } = birdErrorParts(statusCode, body, headers);
|
|
476
|
+
throw new n8n_workflow_1.NodeApiError(ctx.getNode(), (body ?? {}), {
|
|
477
|
+
message,
|
|
478
|
+
description,
|
|
479
|
+
httpCode: String(statusCode),
|
|
480
|
+
itemIndex: opts.itemIndex,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return (body ?? {});
|
|
484
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyStandardWebhook = verifyStandardWebhook;
|
|
4
|
+
// Standard Webhooks verification (https://www.standardwebhooks.com): Bird
|
|
5
|
+
// signs {webhook-id}.{webhook-timestamp}.{raw body} with HMAC-SHA256, keyed
|
|
6
|
+
// by the base64 bytes behind the secret's whsec_ prefix. Hand-rolled because
|
|
7
|
+
// verified community nodes cannot carry runtime dependencies.
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const TOLERANCE_SECONDS = 300;
|
|
10
|
+
function verifyStandardWebhook(secret, webhookId, webhookTimestamp, rawBody, signatureHeader, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
11
|
+
if (!secret || !webhookId || !signatureHeader)
|
|
12
|
+
return false;
|
|
13
|
+
const ts = Number(webhookTimestamp);
|
|
14
|
+
if (!Number.isFinite(ts))
|
|
15
|
+
return false;
|
|
16
|
+
if (Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS)
|
|
17
|
+
return false;
|
|
18
|
+
let key;
|
|
19
|
+
try {
|
|
20
|
+
key = Buffer.from(secret.startsWith("whsec_") ? secret.slice(6) : secret, "base64");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (key.length === 0)
|
|
26
|
+
return false;
|
|
27
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", key)
|
|
28
|
+
.update(`${webhookId}.${webhookTimestamp}.${rawBody}`)
|
|
29
|
+
.digest();
|
|
30
|
+
for (const part of signatureHeader.split(" ")) {
|
|
31
|
+
const [version, sig] = part.split(",");
|
|
32
|
+
if (version !== "v1" || !sig)
|
|
33
|
+
continue;
|
|
34
|
+
let candidate;
|
|
35
|
+
try {
|
|
36
|
+
candidate = Buffer.from(sig, "base64");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (candidate.length === expected.length &&
|
|
42
|
+
(0, node_crypto_1.timingSafeEqual)(candidate, expected))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@messagebird/n8n-nodes-bird",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The official Bird node for n8n: email, SMS, WhatsApp, and verification, plus a webhook trigger for Bird events.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Bird",
|
|
8
|
+
"email": "connectors@bird.com"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"n8n-community-node-package",
|
|
12
|
+
"bird",
|
|
13
|
+
"messagebird",
|
|
14
|
+
"email",
|
|
15
|
+
"transactional email",
|
|
16
|
+
"sms",
|
|
17
|
+
"whatsapp",
|
|
18
|
+
"voice",
|
|
19
|
+
"verify",
|
|
20
|
+
"otp",
|
|
21
|
+
"lookup",
|
|
22
|
+
"webhooks"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://bird.com",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/messagebird/n8n-nodes-bird.git"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"main": "dist/index.js",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"n8n": {
|
|
37
|
+
"n8nNodesApiVersion": 1,
|
|
38
|
+
"credentials": [
|
|
39
|
+
"dist/credentials/BirdApi.credentials.js"
|
|
40
|
+
],
|
|
41
|
+
"nodes": [
|
|
42
|
+
"dist/nodes/Bird/Bird.node.js",
|
|
43
|
+
"dist/nodes/Bird/BirdTrigger.node.js"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "rimraf dist && tsc6 && cp nodes/Bird/bird.svg nodes/Bird/bird-dark.svg nodes/Bird/Bird.node.json nodes/Bird/BirdTrigger.node.json dist/nodes/Bird/ && cp credentials/bird.svg credentials/bird-dark.svg dist/credentials/",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"typecheck": "tsc6 --noEmit",
|
|
50
|
+
"lint": "node scan.mjs",
|
|
51
|
+
"prepack": "npm run build"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"n8n-workflow": "*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@n8n/scan-community-package": "0.33.0",
|
|
58
|
+
"@types/node": "22.19.18",
|
|
59
|
+
"n8n-workflow": "2.16.0",
|
|
60
|
+
"rimraf": "6.1.3",
|
|
61
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
62
|
+
"vitest": "4.1.10"
|
|
63
|
+
}
|
|
64
|
+
}
|