@saltcorn/meta-marketing-api 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/index.js ADDED
@@ -0,0 +1,516 @@
1
+ const db = require("@saltcorn/data/db");
2
+ const Form = require("@saltcorn/data/models/form");
3
+ const Workflow = require("@saltcorn/data/models/workflow");
4
+ const Plugin = require("@saltcorn/data/models/plugin");
5
+ const { getState } = require("@saltcorn/data/db/state");
6
+ const { div, p } = require("@saltcorn/markup/tags");
7
+ const {
8
+ DEFAULT_API_VERSION,
9
+ graphFetch,
10
+ getMe,
11
+ getAdAccounts,
12
+ getAdAccount,
13
+ getBusinesses,
14
+ getBusinessAdAccounts,
15
+ getCampaigns,
16
+ getCampaign,
17
+ getAdSets,
18
+ getCampaignAdSets,
19
+ getAdSet,
20
+ getAds,
21
+ getCampaignAds,
22
+ getAdSetAds,
23
+ getAd,
24
+ getAdCreatives,
25
+ getAdCreative,
26
+ getAdPreview,
27
+ getInsights,
28
+ getInsightsAsync,
29
+ exchangeLongLivedToken,
30
+ debugToken,
31
+ } = require("./api");
32
+
33
+ const PLUGIN_NAME = "meta-marketing-api";
34
+
35
+ const configuration_workflow = () =>
36
+ new Workflow({
37
+ onDone: async (ctx) => {
38
+ // A user token that expires can be swapped for one that lasts ~60 days
39
+ if (ctx.exchange_token && ctx.app_id && ctx.app_secret) {
40
+ const res = await exchangeLongLivedToken(
41
+ ctx.app_id,
42
+ ctx.app_secret,
43
+ ctx.access_token,
44
+ ctx,
45
+ );
46
+ if (res?.access_token)
47
+ return {
48
+ ...ctx,
49
+ access_token: res.access_token,
50
+ token_expires: res.expires_in
51
+ ? new Date(Date.now() + res.expires_in * 1000).toISOString()
52
+ : undefined,
53
+ };
54
+ }
55
+ return ctx;
56
+ },
57
+ steps: [
58
+ {
59
+ name: "Credentials",
60
+ form: async () =>
61
+ new Form({
62
+ blurb: p(
63
+ "Read the ads in your Meta ad accounts. You need an access token with the ",
64
+ "ads_read",
65
+ " permission, from a Meta app that has access to the ad account. A system user token from Meta Business Manager does not expire and is the easiest to use.",
66
+ ),
67
+ fields: [
68
+ {
69
+ name: "access_token",
70
+ label: "Access token",
71
+ sublabel: "System user token, or a long lived user token",
72
+ type: "String",
73
+ fieldview: "textarea",
74
+ required: true,
75
+ },
76
+ {
77
+ name: "exchange_token",
78
+ label: "Exchange for a long lived token",
79
+ sublabel:
80
+ "When saving, swap the token above for one that lasts about 60 days. Leave off for system user tokens.",
81
+ type: "Bool",
82
+ },
83
+ {
84
+ name: "app_id",
85
+ label: "App ID",
86
+ sublabel:
87
+ "Optional. Needed to refresh the token or to check when it expires.",
88
+ type: "String",
89
+ },
90
+ {
91
+ name: "app_secret",
92
+ label: "App secret",
93
+ sublabel: "Optional, as above",
94
+ type: "String",
95
+ },
96
+
97
+ {
98
+ name: "use_appsecret_proof",
99
+ label: "Send app secret proof",
100
+ sublabel:
101
+ "Switch on if your Meta app requires proof of the app secret on API calls",
102
+ type: "Bool",
103
+ },
104
+ {
105
+ name: "max_pages",
106
+ label: "Maximum pages",
107
+ sublabel:
108
+ "How many pages of results to read before stopping, when not set elsewhere",
109
+ type: "Integer",
110
+ default: 10,
111
+ },
112
+ {
113
+ name: "max_retries",
114
+ label: "Retries",
115
+ sublabel:
116
+ "How often to try again when Meta reports a temporary error or rate limit",
117
+ type: "Integer",
118
+ default: 3,
119
+ },
120
+ {
121
+ name: "log_requests",
122
+ label: "Log requests",
123
+ sublabel: "Write every call to Meta to the server log",
124
+ type: "Bool",
125
+ },
126
+ ],
127
+ }),
128
+ },
129
+ {
130
+ name: "Ad account",
131
+ form: async (context) => {
132
+ let options = [];
133
+ let error;
134
+ try {
135
+ const accounts = await getAdAccounts({ limit: 200 }, context);
136
+ options = accounts.map((acc) => ({
137
+ label: `${acc.name || acc.id} (${acc.id})`,
138
+ name: acc.id,
139
+ }));
140
+ } catch (e) {
141
+ error = e.message;
142
+ }
143
+ return new Form({
144
+ blurb: error
145
+ ? div(
146
+ { class: "alert alert-warning" },
147
+ "Could not read your ad accounts: ",
148
+ error,
149
+ p(
150
+ "You can still type an ad account id below, or go back and correct the token.",
151
+ ),
152
+ )
153
+ : undefined,
154
+ fields: [
155
+ {
156
+ name: "ad_account_id",
157
+ label: "Default ad account",
158
+ sublabel:
159
+ "Used whenever a table or action does not name an ad account of its own",
160
+ type: "String",
161
+ attributes: options.length ? { options } : {},
162
+ },
163
+ ],
164
+ });
165
+ },
166
+ },
167
+ ],
168
+ });
169
+
170
+ /** Every function takes an optional final argument overriding the plugin configuration */
171
+ module.exports = {
172
+ sc_plugin_api_version: 1,
173
+ plugin_name: PLUGIN_NAME,
174
+ configuration_workflow,
175
+ table_providers: (cfg) => require("./table-provider.js")(cfg),
176
+ functions: (cfg) => ({
177
+ meta_auth_fetch: {
178
+ async run(path, query, cfgOverRide) {
179
+ return await graphFetch(path, { query }, { ...cfg, ...cfgOverRide });
180
+ },
181
+ isAsync: true,
182
+ description:
183
+ "Authenticated read from any Meta Graph API endpoint, for example /me/adaccounts",
184
+ arguments: [
185
+ { name: "path", type: "String" },
186
+ { name: "query", type: "JSON" },
187
+ ],
188
+ },
189
+ get_meta_me: {
190
+ async run(query, cfgOverRide) {
191
+ return await getMe(query, { ...cfg, ...cfgOverRide });
192
+ },
193
+ isAsync: true,
194
+ description: "Get the user or system user the token belongs to",
195
+ arguments: [],
196
+ },
197
+ get_meta_ad_accounts: {
198
+ async run(query, cfgOverRide) {
199
+ return await getAdAccounts(query, { ...cfg, ...cfgOverRide });
200
+ },
201
+ isAsync: true,
202
+ description: "Get the Meta ad accounts this token can read",
203
+ arguments: [{ name: "query", type: "JSON" }],
204
+ },
205
+ get_meta_ad_account: {
206
+ async run(accountId, query, cfgOverRide) {
207
+ return await getAdAccount(accountId, query, {
208
+ ...cfg,
209
+ ...cfgOverRide,
210
+ });
211
+ },
212
+ isAsync: true,
213
+ description: "Get one Meta ad account",
214
+ arguments: [
215
+ { name: "accountId", type: "String" },
216
+ { name: "query", type: "JSON" },
217
+ ],
218
+ },
219
+ get_meta_businesses: {
220
+ async run(query, cfgOverRide) {
221
+ return await getBusinesses(query, { ...cfg, ...cfgOverRide });
222
+ },
223
+ isAsync: true,
224
+ description: "Get the Meta businesses this token can read",
225
+ arguments: [{ name: "query", type: "JSON" }],
226
+ },
227
+ get_meta_business_ad_accounts: {
228
+ async run(businessId, query, cfgOverRide) {
229
+ return await getBusinessAdAccounts(businessId, query, {
230
+ ...cfg,
231
+ ...cfgOverRide,
232
+ });
233
+ },
234
+ isAsync: true,
235
+ description: "Get the ad accounts owned by a Meta business",
236
+ arguments: [
237
+ { name: "businessId", type: "String" },
238
+ { name: "query", type: "JSON" },
239
+ ],
240
+ },
241
+ get_meta_campaigns: {
242
+ async run(accountId, query, cfgOverRide) {
243
+ return await getCampaigns(accountId || cfg?.ad_account_id, query, {
244
+ ...cfg,
245
+ ...cfgOverRide,
246
+ });
247
+ },
248
+ isAsync: true,
249
+ description: "Get the campaigns in a Meta ad account",
250
+ arguments: [
251
+ { name: "accountId", type: "String" },
252
+ { name: "query", type: "JSON" },
253
+ ],
254
+ },
255
+ get_meta_campaign: {
256
+ async run(campaignId, query, cfgOverRide) {
257
+ return await getCampaign(campaignId, query, {
258
+ ...cfg,
259
+ ...cfgOverRide,
260
+ });
261
+ },
262
+ isAsync: true,
263
+ description: "Get one Meta campaign",
264
+ arguments: [
265
+ { name: "campaignId", type: "String" },
266
+ { name: "query", type: "JSON" },
267
+ ],
268
+ },
269
+ get_meta_adsets: {
270
+ async run(accountId, query, cfgOverRide) {
271
+ return await getAdSets(accountId || cfg?.ad_account_id, query, {
272
+ ...cfg,
273
+ ...cfgOverRide,
274
+ });
275
+ },
276
+ isAsync: true,
277
+ description: "Get the ad sets in a Meta ad account",
278
+ arguments: [
279
+ { name: "accountId", type: "String" },
280
+ { name: "query", type: "JSON" },
281
+ ],
282
+ },
283
+ get_meta_campaign_adsets: {
284
+ async run(campaignId, query, cfgOverRide) {
285
+ return await getCampaignAdSets(campaignId, query, {
286
+ ...cfg,
287
+ ...cfgOverRide,
288
+ });
289
+ },
290
+ isAsync: true,
291
+ description: "Get the ad sets in a Meta campaign",
292
+ arguments: [
293
+ { name: "campaignId", type: "String" },
294
+ { name: "query", type: "JSON" },
295
+ ],
296
+ },
297
+ get_meta_adset: {
298
+ async run(adSetId, query, cfgOverRide) {
299
+ return await getAdSet(adSetId, query, { ...cfg, ...cfgOverRide });
300
+ },
301
+ isAsync: true,
302
+ description: "Get one Meta ad set",
303
+ arguments: [
304
+ { name: "adSetId", type: "String" },
305
+ { name: "query", type: "JSON" },
306
+ ],
307
+ },
308
+ get_meta_ads: {
309
+ async run(accountId, query, cfgOverRide) {
310
+ return await getAds(accountId || cfg?.ad_account_id, query, {
311
+ ...cfg,
312
+ ...cfgOverRide,
313
+ });
314
+ },
315
+ isAsync: true,
316
+ description: "Get the ads in a Meta ad account",
317
+ arguments: [
318
+ { name: "accountId", type: "String" },
319
+ { name: "query", type: "JSON" },
320
+ ],
321
+ },
322
+ get_meta_campaign_ads: {
323
+ async run(campaignId, query, cfgOverRide) {
324
+ return await getCampaignAds(campaignId, query, {
325
+ ...cfg,
326
+ ...cfgOverRide,
327
+ });
328
+ },
329
+ isAsync: true,
330
+ description: "Get the ads in a Meta campaign",
331
+ arguments: [
332
+ { name: "campaignId", type: "String" },
333
+ { name: "query", type: "JSON" },
334
+ ],
335
+ },
336
+ get_meta_adset_ads: {
337
+ async run(adSetId, query, cfgOverRide) {
338
+ return await getAdSetAds(adSetId, query, { ...cfg, ...cfgOverRide });
339
+ },
340
+ isAsync: true,
341
+ description: "Get the ads in a Meta ad set",
342
+ arguments: [
343
+ { name: "adSetId", type: "String" },
344
+ { name: "query", type: "JSON" },
345
+ ],
346
+ },
347
+ get_meta_ad: {
348
+ async run(adId, query, cfgOverRide) {
349
+ return await getAd(adId, query, { ...cfg, ...cfgOverRide });
350
+ },
351
+ isAsync: true,
352
+ description: "Get one Meta ad",
353
+ arguments: [
354
+ { name: "adId", type: "String" },
355
+ { name: "query", type: "JSON" },
356
+ ],
357
+ },
358
+ get_meta_ad_creatives: {
359
+ async run(accountId, query, cfgOverRide) {
360
+ return await getAdCreatives(accountId || cfg?.ad_account_id, query, {
361
+ ...cfg,
362
+ ...cfgOverRide,
363
+ });
364
+ },
365
+ isAsync: true,
366
+ description: "Get the ad creatives in a Meta ad account",
367
+ arguments: [
368
+ { name: "accountId", type: "String" },
369
+ { name: "query", type: "JSON" },
370
+ ],
371
+ },
372
+ get_meta_ad_creative: {
373
+ async run(creativeId, query, cfgOverRide) {
374
+ return await getAdCreative(creativeId, query, {
375
+ ...cfg,
376
+ ...cfgOverRide,
377
+ });
378
+ },
379
+ isAsync: true,
380
+ description: "Get one Meta ad creative",
381
+ arguments: [
382
+ { name: "creativeId", type: "String" },
383
+ { name: "query", type: "JSON" },
384
+ ],
385
+ },
386
+ get_meta_ad_preview: {
387
+ async run(adId, adFormat, cfgOverRide) {
388
+ return await getAdPreview(adId, adFormat, {
389
+ ...cfg,
390
+ ...cfgOverRide,
391
+ });
392
+ },
393
+ isAsync: true,
394
+ description:
395
+ "Get the HTML preview of an ad, for example in format MOBILE_FEED_STANDARD",
396
+ arguments: [
397
+ { name: "adId", type: "String" },
398
+ { name: "adFormat", type: "String" },
399
+ ],
400
+ },
401
+ get_meta_insights: {
402
+ async run(objectId, query, cfgOverRide) {
403
+ /* Query example:
404
+ {
405
+ level: "campaign",
406
+ date_preset: "last_30d",
407
+ time_increment: 1,
408
+ breakdowns: "age,gender",
409
+ fields: "campaign_name,impressions,clicks,spend",
410
+ };
411
+ */
412
+ return await getInsights(objectId || cfg?.ad_account_id, query, {
413
+ ...cfg,
414
+ ...cfgOverRide,
415
+ });
416
+ },
417
+ isAsync: true,
418
+ description:
419
+ "Get performance figures for an ad account, campaign, ad set or ad",
420
+ arguments: [
421
+ { name: "objectId", type: "String" },
422
+ { name: "query", type: "JSON" },
423
+ ],
424
+ },
425
+ get_meta_insights_async: {
426
+ async run(objectId, query, cfgOverRide) {
427
+ return await getInsightsAsync(
428
+ objectId || cfg?.ad_account_id,
429
+ query,
430
+ { ...cfg, ...cfgOverRide },
431
+ {},
432
+ );
433
+ },
434
+ isAsync: true,
435
+ description:
436
+ "Get performance figures as a background report. Slower to start, for large date ranges.",
437
+ arguments: [
438
+ { name: "objectId", type: "String" },
439
+ { name: "query", type: "JSON" },
440
+ ],
441
+ },
442
+ get_meta_long_lived_token: {
443
+ async run(app_id, app_secret, access_token, cfgOverRide) {
444
+ return await exchangeLongLivedToken(
445
+ app_id || cfg?.app_id,
446
+ app_secret || cfg?.app_secret,
447
+ access_token || cfg?.access_token,
448
+ { ...cfg, ...cfgOverRide },
449
+ );
450
+ },
451
+ isAsync: true,
452
+ description: "Exchange an access token for a long lived one",
453
+ arguments: [
454
+ { name: "app_id", type: "String" },
455
+ { name: "app_secret", type: "String" },
456
+ { name: "access_token", type: "String" },
457
+ ],
458
+ },
459
+ debug_meta_token: {
460
+ async run(access_token, cfgOverRide) {
461
+ const useCfg = { ...cfg, ...cfgOverRide };
462
+ return await debugToken(
463
+ access_token || useCfg.access_token,
464
+ useCfg.app_id,
465
+ useCfg.app_secret,
466
+ useCfg,
467
+ );
468
+ },
469
+ isAsync: true,
470
+ description:
471
+ "Check an access token: which app it belongs to, its permissions and when it expires",
472
+ arguments: [{ name: "access_token", type: "String" }],
473
+ },
474
+ }),
475
+ actions: (cfg) => ({
476
+ refresh_meta_token: {
477
+ description:
478
+ "Exchange the stored Meta access token for a fresh long lived one and save it",
479
+ run: async () => {
480
+ if (!cfg?.app_id || !cfg?.app_secret)
481
+ throw new Error(
482
+ "Set the App ID and App secret in the Meta Marketing API settings to refresh the token",
483
+ );
484
+ const { access_token, expires_in } = await exchangeLongLivedToken(
485
+ cfg.app_id,
486
+ cfg.app_secret,
487
+ cfg.access_token,
488
+ cfg,
489
+ );
490
+ if (!access_token)
491
+ throw new Error("Meta did not return a new access token");
492
+ let plugin = await Plugin.findOne({ name: PLUGIN_NAME });
493
+ if (!plugin)
494
+ plugin = await Plugin.findOne({
495
+ name: `@saltcorn/${PLUGIN_NAME}`,
496
+ });
497
+ if (!plugin) throw new Error("Meta Marketing API plugin not found");
498
+ plugin.configuration = {
499
+ ...(plugin.configuration || {}),
500
+ access_token,
501
+ token_expires: expires_in
502
+ ? new Date(Date.now() + expires_in * 1000).toISOString()
503
+ : undefined,
504
+ };
505
+ await plugin.upsert();
506
+ getState().processSend({
507
+ refresh_plugin_cfg: plugin.name,
508
+ tenant: db.getTenantSchema(),
509
+ });
510
+ console.log("updated Meta access token");
511
+ return { success: "Meta access token refreshed" };
512
+ },
513
+ },
514
+ meta_sync: require("./sync-action.js")(cfg),
515
+ }),
516
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@saltcorn/meta-marketing-api",
3
+ "version": "0.1.0",
4
+ "description": "Read ads on Facebook and Instagram with the Meta Marketing API",
5
+ "main": "index.js",
6
+ "dependencies": {
7
+ "@saltcorn/markup": "^1.0.0",
8
+ "@saltcorn/data": "^1.0.0",
9
+ "node-fetch": "2.6.9"
10
+ },
11
+ "devDependencies": {
12
+ "jest": "^29.7.0"
13
+ },
14
+ "scripts": {
15
+ "test": "jest tests --runInBand"
16
+ },
17
+ "author": "Tom Nielsen",
18
+ "license": "MIT",
19
+ "eslintConfig": {
20
+ "extends": "eslint:recommended",
21
+ "parserOptions": {
22
+ "ecmaVersion": 2022
23
+ },
24
+ "env": {
25
+ "node": true,
26
+ "es6": true,
27
+ "jest/globals": true
28
+ },
29
+ "rules": {
30
+ "no-unused-vars": "off",
31
+ "no-case-declarations": "off",
32
+ "no-empty": "warn",
33
+ "no-fallthrough": "warn"
34
+ }
35
+ }
36
+ }