@adport/provider-google 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/dist/index.js ADDED
@@ -0,0 +1,888 @@
1
+ // src/client.ts
2
+ import { AdportError } from "@adport/core";
3
+ var TOKEN_URL = "https://oauth2.googleapis.com/token";
4
+ var API_BASE = "https://googleads.googleapis.com";
5
+ var DEFAULT_API_VERSION = "v24";
6
+ function normalizeCustomerId(id) {
7
+ const normalized = id.replace(/^customers\//, "").replace(/-/g, "").trim();
8
+ if (!/^\d{10}$/.test(normalized)) {
9
+ throw new AdportError("INVALID_INPUT", `"${id}" is not a valid Google Ads customer id (expected 10 digits).`);
10
+ }
11
+ return normalized;
12
+ }
13
+ var GoogleAdsRestClient = class {
14
+ constructor(credentials, version = process.env.GOOGLE_ADS_API_VERSION ?? DEFAULT_API_VERSION, fetchImpl = fetch) {
15
+ this.credentials = credentials;
16
+ this.version = version;
17
+ this.fetchImpl = fetchImpl;
18
+ }
19
+ credentials;
20
+ version;
21
+ fetchImpl;
22
+ accessToken;
23
+ async getAccessToken() {
24
+ if (this.accessToken && this.accessToken.expiresAt > Date.now()) {
25
+ return this.accessToken.token;
26
+ }
27
+ const response = await this.fetchImpl(TOKEN_URL, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/x-www-form-urlencoded" },
30
+ body: new URLSearchParams({
31
+ client_id: this.credentials.clientId,
32
+ client_secret: this.credentials.clientSecret,
33
+ refresh_token: this.credentials.refreshToken,
34
+ grant_type: "refresh_token"
35
+ })
36
+ });
37
+ if (!response.ok) {
38
+ const body = await response.text();
39
+ throw new AdportError(
40
+ "PROVIDER_ERROR",
41
+ `Google OAuth token refresh failed (${response.status}). Re-run \`adport connect google\` if the refresh token was revoked.`,
42
+ safeJson(body)
43
+ );
44
+ }
45
+ const data = await response.json();
46
+ this.accessToken = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 60) * 1e3 };
47
+ return this.accessToken.token;
48
+ }
49
+ async request(path, init) {
50
+ const token = await this.getAccessToken();
51
+ const headers = {
52
+ authorization: `Bearer ${token}`,
53
+ "developer-token": this.credentials.developerToken,
54
+ "content-type": "application/json"
55
+ };
56
+ if (this.credentials.loginCustomerId) {
57
+ headers["login-customer-id"] = normalizeCustomerId(this.credentials.loginCustomerId);
58
+ }
59
+ const response = await this.fetchImpl(`${API_BASE}/${this.version}/${path}`, {
60
+ method: init.method,
61
+ headers,
62
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
63
+ });
64
+ const raw = await response.text();
65
+ if (!response.ok) {
66
+ throw new AdportError("PROVIDER_ERROR", formatGoogleAdsError(response.status, raw), safeJson(raw));
67
+ }
68
+ return raw ? JSON.parse(raw) : {};
69
+ }
70
+ async listAccessibleCustomers() {
71
+ const data = await this.request("customers:listAccessibleCustomers", {
72
+ method: "GET"
73
+ });
74
+ return (data.resourceNames ?? []).map((name) => name.replace("customers/", ""));
75
+ }
76
+ /** Paged GAQL search; accumulates rows up to options.maxRows (default 1000). */
77
+ async search(customerId, query, options = {}) {
78
+ const maxRows = options.maxRows ?? 1e3;
79
+ const rows = [];
80
+ let pageToken;
81
+ do {
82
+ const data = await this.request(
83
+ `customers/${normalizeCustomerId(customerId)}/googleAds:search`,
84
+ { method: "POST", body: { query, pageToken } }
85
+ );
86
+ rows.push(...data.results ?? []);
87
+ pageToken = data.nextPageToken;
88
+ } while (pageToken && rows.length < maxRows);
89
+ return rows.slice(0, maxRows);
90
+ }
91
+ /**
92
+ * Service-specific mutate (campaigns, campaignBudgets, adGroups, adGroupCriteria, adGroupAds).
93
+ * validateOnly=true is the server-side dry run.
94
+ */
95
+ async mutate(customerId, service, operations, { validateOnly }) {
96
+ return this.request(`customers/${normalizeCustomerId(customerId)}/${service}:mutate`, {
97
+ method: "POST",
98
+ body: { operations, validateOnly, partialFailure: false }
99
+ });
100
+ }
101
+ /** Cross-service atomic mutate (e.g. budget + campaign with temp resource ids). */
102
+ async googleAdsMutate(customerId, mutateOperations, { validateOnly }) {
103
+ return this.request(`customers/${normalizeCustomerId(customerId)}/googleAds:mutate`, {
104
+ method: "POST",
105
+ body: { mutateOperations, validateOnly, partialFailure: false }
106
+ });
107
+ }
108
+ };
109
+ function safeJson(raw) {
110
+ try {
111
+ return JSON.parse(raw);
112
+ } catch {
113
+ return raw;
114
+ }
115
+ }
116
+ function formatGoogleAdsError(status, raw) {
117
+ const parsed = safeJson(raw);
118
+ const error = parsed?.error;
119
+ if (!error) return `Google Ads API error (HTTP ${status})`;
120
+ const lines = [];
121
+ let requestId;
122
+ for (const detail of error.details ?? []) {
123
+ requestId = requestId ?? detail.requestId;
124
+ for (const item of detail.errors ?? []) {
125
+ const path = (item.location?.fieldPathElements ?? []).map((el) => el.index !== void 0 ? `${el.fieldName}[${el.index}]` : el.fieldName).join(".");
126
+ lines.push(`${path ? `at ${path}: ` : ""}${item.message}`);
127
+ }
128
+ }
129
+ const head = `Google Ads API error (HTTP ${status}): ${error.message ?? "request failed"}`;
130
+ const tail = requestId ? ` [request-id: ${requestId}]` : "";
131
+ return lines.length > 0 ? `${head}
132
+ ${lines.join("\n ")}${tail}` : `${head}${tail}`;
133
+ }
134
+
135
+ // src/provider.ts
136
+ import {
137
+ AdportError as AdportError2,
138
+ resolveDateRange
139
+ } from "@adport/core";
140
+ var LEVEL_RESOURCE = {
141
+ account: "customer",
142
+ campaign: "campaign",
143
+ ad_group: "ad_group",
144
+ ad: "ad_group_ad"
145
+ };
146
+ var BASE_METRIC_FIELDS = {
147
+ spend: "metrics.cost_micros",
148
+ impressions: "metrics.impressions",
149
+ clicks: "metrics.clicks",
150
+ conversions: "metrics.conversions",
151
+ conversion_value: "metrics.conversions_value"
152
+ };
153
+ var GoogleAdsProvider = class {
154
+ constructor(client) {
155
+ this.client = client;
156
+ }
157
+ client;
158
+ id = "google";
159
+ capabilities() {
160
+ return { serverDryRun: true };
161
+ }
162
+ async listAccounts() {
163
+ const ids = await this.client.listAccessibleCustomers();
164
+ const accounts = [];
165
+ for (const id of ids) {
166
+ try {
167
+ const rows = await this.client.search(
168
+ id,
169
+ "SELECT customer.id, customer.descriptive_name, customer.currency_code, customer.status, customer.manager FROM customer LIMIT 1",
170
+ { maxRows: 1 }
171
+ );
172
+ const customer = rows[0]?.customer ?? {};
173
+ accounts.push({
174
+ provider: this.id,
175
+ id,
176
+ name: customer.descriptiveName ?? `(account ${id})`,
177
+ currency: customer.currencyCode,
178
+ status: customer.manager ? `${customer.status ?? "UNKNOWN"} (manager)` : customer.status
179
+ });
180
+ } catch {
181
+ accounts.push({ provider: this.id, id, name: "(details unavailable)", status: "UNKNOWN" });
182
+ }
183
+ }
184
+ return accounts;
185
+ }
186
+ async report(query) {
187
+ const range = resolveDateRange(query.dateRange);
188
+ const resource = LEVEL_RESOURCE[query.level];
189
+ const accountIds = query.accountIds ?? (await this.listAccounts()).filter((a) => !a.status?.includes("manager")).map((a) => a.id);
190
+ const baseMetrics = /* @__PURE__ */ new Set();
191
+ for (const metric of query.metrics) {
192
+ const field = BASE_METRIC_FIELDS[metric];
193
+ if (field) baseMetrics.add(field);
194
+ }
195
+ const wants = (m) => query.metrics.includes(m);
196
+ if (wants("ctr")) ["metrics.clicks", "metrics.impressions"].forEach((f) => baseMetrics.add(f));
197
+ if (wants("cpc") || wants("cpa") || wants("cpm") || wants("roas")) baseMetrics.add("metrics.cost_micros");
198
+ if (wants("cpc")) baseMetrics.add("metrics.clicks");
199
+ if (wants("cpm")) baseMetrics.add("metrics.impressions");
200
+ if (wants("cpa")) baseMetrics.add("metrics.conversions");
201
+ if (wants("roas")) baseMetrics.add("metrics.conversions_value");
202
+ const entityFields = {
203
+ account: ["customer.id", "customer.descriptive_name", "customer.status"],
204
+ campaign: ["campaign.id", "campaign.name", "campaign.status"],
205
+ ad_group: ["ad_group.id", "ad_group.name", "ad_group.status", "campaign.name"],
206
+ ad: ["ad_group_ad.ad.id", "ad_group_ad.status", "ad_group.name"]
207
+ }[query.level];
208
+ const gaql = `SELECT ${[...entityFields, ...baseMetrics].join(", ")} FROM ${resource} WHERE segments.date BETWEEN '${range.start}' AND '${range.end}' PARAMETERS omit_unselected_resource_names=true`;
209
+ const rows = [];
210
+ for (const accountId of accountIds) {
211
+ const results = await this.client.search(accountId, gaql, { maxRows: query.limit ?? 1e3 });
212
+ for (const row of results) {
213
+ rows.push(this.toReportRow(row, accountId, query));
214
+ }
215
+ }
216
+ return { rows };
217
+ }
218
+ toReportRow(row, accountId, query) {
219
+ const metricsRaw = row.metrics ?? {};
220
+ const num = (key) => Number(metricsRaw[key] ?? 0);
221
+ const spend = num("costMicros") / 1e6;
222
+ const impressions = num("impressions");
223
+ const clicks = num("clicks");
224
+ const conversions = num("conversions");
225
+ const conversionValue = num("conversionsValue");
226
+ const all = {
227
+ spend: round2(spend),
228
+ impressions,
229
+ clicks,
230
+ conversions,
231
+ conversion_value: round2(conversionValue),
232
+ ctr: impressions > 0 ? round2(clicks / impressions * 100) : 0,
233
+ cpc: clicks > 0 ? round2(spend / clicks) : 0,
234
+ cpm: impressions > 0 ? round2(spend / impressions * 1e3) : 0,
235
+ cpa: conversions > 0 ? round2(spend / conversions) : 0,
236
+ roas: spend > 0 ? round2(conversionValue / spend) : 0
237
+ };
238
+ let entity;
239
+ switch (query.level) {
240
+ case "account": {
241
+ const customer = row.customer ?? {};
242
+ entity = { level: "account", id: String(customer.id ?? accountId), name: customer.descriptiveName ?? accountId, status: customer.status };
243
+ break;
244
+ }
245
+ case "campaign": {
246
+ const campaign = row.campaign ?? {};
247
+ entity = { level: "campaign", id: String(campaign.id ?? ""), name: campaign.name ?? "", status: campaign.status };
248
+ break;
249
+ }
250
+ case "ad_group": {
251
+ const adGroup = row.adGroup ?? {};
252
+ entity = { level: "ad_group", id: String(adGroup.id ?? ""), name: adGroup.name ?? "", status: adGroup.status };
253
+ break;
254
+ }
255
+ case "ad": {
256
+ const adGroupAd = row.adGroupAd ?? {};
257
+ entity = { level: "ad", id: String(adGroupAd.ad?.id ?? ""), name: `ad ${adGroupAd.ad?.id ?? "?"}`, status: adGroupAd.status };
258
+ break;
259
+ }
260
+ }
261
+ return {
262
+ provider: this.id,
263
+ accountId,
264
+ entity,
265
+ metrics: Object.fromEntries(query.metrics.map((m) => [m, all[m]]))
266
+ };
267
+ }
268
+ async gaqlSearch(input) {
269
+ const limit = Math.min(input.limit ?? 200, 1e4);
270
+ let query = `SELECT ${input.fields.join(", ")} FROM ${input.resource}`;
271
+ if (input.conditions?.length) query += ` WHERE ${input.conditions.join(" AND ")}`;
272
+ if (input.order_by?.length) query += ` ORDER BY ${input.order_by.join(", ")}`;
273
+ query += ` LIMIT ${limit} PARAMETERS omit_unselected_resource_names=true`;
274
+ return this.client.search(input.customer_id, query, { maxRows: limit });
275
+ }
276
+ async previewWrite(op, guard) {
277
+ const plan = await this.plan(op, guard);
278
+ await plan.execute(true);
279
+ return {
280
+ summary: plan.summary,
281
+ changes: plan.changes,
282
+ coercions: plan.coercions,
283
+ budgetDeltas: plan.budgetDeltas,
284
+ serverValidated: true
285
+ };
286
+ }
287
+ async applyWrite(op, guard) {
288
+ const plan = await this.plan(op, guard);
289
+ const resourceIds = await plan.execute(false);
290
+ return { applied: true, resourceIds };
291
+ }
292
+ // ---- write planning ------------------------------------------------------
293
+ async plan(op, guard) {
294
+ const cid = normalizeCustomerId(op.accountId);
295
+ const payload = op.payload;
296
+ switch (op.tool) {
297
+ case "google_create_campaign":
298
+ return this.planCreateCampaign(cid, payload, guard);
299
+ case "google_set_campaign_status":
300
+ return this.planSetCampaignStatus(cid, payload);
301
+ case "google_set_budget":
302
+ return this.planSetBudget(cid, payload);
303
+ case "google_create_ad_group":
304
+ return this.planCreateAdGroup(cid, payload);
305
+ case "google_set_ad_group_status":
306
+ return this.planSetAdGroupStatus(cid, payload);
307
+ case "google_add_keywords":
308
+ return this.planAddKeywords(cid, payload);
309
+ case "google_set_keyword_status":
310
+ return this.planSetKeywordStatus(cid, payload);
311
+ case "google_remove_keywords":
312
+ return this.planRemoveKeywords(cid, payload);
313
+ case "google_create_responsive_search_ad":
314
+ return this.planCreateRsa(cid, payload, guard);
315
+ case "google_set_bid_ceiling":
316
+ return this.planSetBidCeiling(cid, payload);
317
+ case "google_set_bidding_strategy":
318
+ return this.planSetBiddingStrategy(cid, payload);
319
+ default:
320
+ throw new AdportError2("PROVIDER_ERROR", `google: unsupported write tool ${op.tool}`);
321
+ }
322
+ }
323
+ async planCreateCampaign(cid, payload, guard) {
324
+ const coercions = [];
325
+ let status = payload.status ?? "ENABLED";
326
+ if (guard.forcePausedCreation && status === "ENABLED") {
327
+ status = "PAUSED";
328
+ coercions.push("status coerced to PAUSED by policy (paused_creation)");
329
+ }
330
+ const channelType = payload.channel_type ?? "SEARCH";
331
+ const budgetTempResource = `customers/${cid}/campaignBudgets/-1`;
332
+ const mutateOperations = [
333
+ {
334
+ campaignBudgetOperation: {
335
+ create: {
336
+ resourceName: budgetTempResource,
337
+ name: `Budget for ${payload.name} (${Date.now()})`,
338
+ amountMicros: String(payload.daily_budget_micros),
339
+ deliveryMethod: "STANDARD",
340
+ explicitlyShared: false
341
+ }
342
+ }
343
+ },
344
+ {
345
+ campaignOperation: {
346
+ create: {
347
+ name: payload.name,
348
+ status,
349
+ advertisingChannelType: channelType,
350
+ manualCpc: {},
351
+ campaignBudget: budgetTempResource
352
+ }
353
+ }
354
+ }
355
+ ];
356
+ return {
357
+ summary: `Create ${channelType} campaign "${payload.name}" (${status}), daily budget ${payload.daily_budget_micros} micros`,
358
+ changes: [
359
+ `+ campaign_budget ${payload.daily_budget_micros} micros/day (not shared)`,
360
+ `+ campaign "${payload.name}" status=${status} channel=${channelType} bidding=manual_cpc`
361
+ ],
362
+ coercions,
363
+ budgetDeltas: [{ target: `new campaign "${payload.name}" daily budget`, toMicros: payload.daily_budget_micros }],
364
+ execute: async (validateOnly) => {
365
+ const res = await this.client.googleAdsMutate(cid, mutateOperations, { validateOnly });
366
+ return (res.mutateOperationResponses ?? []).flatMap((r) => Object.values(r).map((v) => v.resourceName)).filter((r) => Boolean(r));
367
+ }
368
+ };
369
+ }
370
+ async planSetCampaignStatus(cid, payload) {
371
+ const current = await this.lookupCampaign(cid, payload.campaign_id);
372
+ const resourceName = `customers/${cid}/campaigns/${payload.campaign_id}`;
373
+ return {
374
+ summary: `Set campaign "${current.name}" status ${current.status} \u2192 ${payload.status}`,
375
+ changes: [`~ campaign ${payload.campaign_id} status ${current.status} \u2192 ${payload.status}`],
376
+ coercions: [],
377
+ budgetDeltas: [],
378
+ execute: async (validateOnly) => {
379
+ const res = await this.client.mutate(
380
+ cid,
381
+ "campaigns",
382
+ [{ update: { resourceName, status: payload.status }, updateMask: "status" }],
383
+ { validateOnly }
384
+ );
385
+ return (res.results ?? []).map((r) => r.resourceName ?? resourceName);
386
+ }
387
+ };
388
+ }
389
+ async planSetBudget(cid, payload) {
390
+ const current = await this.lookupCampaign(cid, payload.campaign_id);
391
+ const changes = [
392
+ `~ campaign_budget ${current.budgetResource} amount ${current.budgetMicros} \u2192 ${payload.daily_budget_micros}`
393
+ ];
394
+ if (current.budgetShared) {
395
+ changes.push("! this budget is SHARED \u2014 the change affects every campaign using it");
396
+ }
397
+ return {
398
+ summary: `Change "${current.name}" daily budget ${current.budgetMicros} \u2192 ${payload.daily_budget_micros} micros`,
399
+ changes,
400
+ coercions: [],
401
+ budgetDeltas: [
402
+ {
403
+ target: `campaign "${current.name}" daily budget`,
404
+ fromMicros: current.budgetMicros,
405
+ toMicros: payload.daily_budget_micros
406
+ }
407
+ ],
408
+ execute: async (validateOnly) => {
409
+ const res = await this.client.mutate(
410
+ cid,
411
+ "campaignBudgets",
412
+ [
413
+ {
414
+ update: {
415
+ resourceName: current.budgetResource,
416
+ amountMicros: String(payload.daily_budget_micros)
417
+ },
418
+ updateMask: "amount_micros"
419
+ }
420
+ ],
421
+ { validateOnly }
422
+ );
423
+ return (res.results ?? []).map((r) => r.resourceName ?? current.budgetResource);
424
+ }
425
+ };
426
+ }
427
+ async planCreateAdGroup(cid, payload) {
428
+ const campaign = await this.lookupCampaign(cid, payload.campaign_id);
429
+ const create = {
430
+ name: payload.name,
431
+ campaign: `customers/${cid}/campaigns/${payload.campaign_id}`,
432
+ status: "ENABLED",
433
+ type: "SEARCH_STANDARD"
434
+ };
435
+ if (payload.cpc_bid_micros) create.cpcBidMicros = String(payload.cpc_bid_micros);
436
+ return {
437
+ summary: `Create ad group "${payload.name}" in campaign "${campaign.name}"`,
438
+ changes: [`+ ad_group "${payload.name}" in campaign ${payload.campaign_id} (ENABLED \u2014 inherits campaign state)`],
439
+ coercions: [],
440
+ budgetDeltas: [],
441
+ execute: async (validateOnly) => {
442
+ const res = await this.client.mutate(cid, "adGroups", [{ create }], { validateOnly });
443
+ return (res.results ?? []).map((r) => r.resourceName ?? "");
444
+ }
445
+ };
446
+ }
447
+ async planSetAdGroupStatus(cid, payload) {
448
+ const resourceName = `customers/${cid}/adGroups/${payload.ad_group_id}`;
449
+ return {
450
+ summary: `Set ad group ${payload.ad_group_id} status \u2192 ${payload.status}`,
451
+ changes: [`~ ad_group ${payload.ad_group_id} status \u2192 ${payload.status}`],
452
+ coercions: [],
453
+ budgetDeltas: [],
454
+ execute: async (validateOnly) => {
455
+ const res = await this.client.mutate(
456
+ cid,
457
+ "adGroups",
458
+ [{ update: { resourceName, status: payload.status }, updateMask: "status" }],
459
+ { validateOnly }
460
+ );
461
+ return (res.results ?? []).map((r) => r.resourceName ?? resourceName);
462
+ }
463
+ };
464
+ }
465
+ async planAddKeywords(cid, payload) {
466
+ const operations = payload.keywords.map((kw) => ({
467
+ create: {
468
+ adGroup: `customers/${cid}/adGroups/${payload.ad_group_id}`,
469
+ status: "ENABLED",
470
+ negative: payload.negative ?? false,
471
+ keyword: { text: kw.text, matchType: kw.match_type }
472
+ }
473
+ }));
474
+ const kind = payload.negative ? "negative keywords" : "keywords";
475
+ return {
476
+ summary: `Add ${payload.keywords.length} ${kind} to ad group ${payload.ad_group_id}`,
477
+ changes: payload.keywords.map((kw) => `+ ${payload.negative ? "negative " : ""}keyword [${kw.match_type}] "${kw.text}"`),
478
+ coercions: [],
479
+ budgetDeltas: [],
480
+ execute: async (validateOnly) => {
481
+ const res = await this.client.mutate(cid, "adGroupCriteria", operations, { validateOnly });
482
+ return (res.results ?? []).map((r) => r.resourceName ?? "");
483
+ }
484
+ };
485
+ }
486
+ async planSetKeywordStatus(cid, payload) {
487
+ const resourceName = `customers/${cid}/adGroupCriteria/${payload.ad_group_id}~${payload.criterion_id}`;
488
+ return {
489
+ summary: `Set keyword criterion ${payload.criterion_id} status \u2192 ${payload.status}`,
490
+ changes: [`~ ad_group_criterion ${payload.ad_group_id}~${payload.criterion_id} status \u2192 ${payload.status}`],
491
+ coercions: [],
492
+ budgetDeltas: [],
493
+ execute: async (validateOnly) => {
494
+ const res = await this.client.mutate(
495
+ cid,
496
+ "adGroupCriteria",
497
+ [{ update: { resourceName, status: payload.status }, updateMask: "status" }],
498
+ { validateOnly }
499
+ );
500
+ return (res.results ?? []).map((r) => r.resourceName ?? resourceName);
501
+ }
502
+ };
503
+ }
504
+ async planRemoveKeywords(cid, payload) {
505
+ const resourceNames = payload.criterion_ids.map(
506
+ (id) => `customers/${cid}/adGroupCriteria/${payload.ad_group_id}~${id}`
507
+ );
508
+ return {
509
+ summary: `PERMANENTLY remove ${resourceNames.length} keyword criteria from ad group ${payload.ad_group_id}`,
510
+ changes: resourceNames.map((r) => `- ${r}`),
511
+ coercions: [],
512
+ budgetDeltas: [],
513
+ execute: async (validateOnly) => {
514
+ const res = await this.client.mutate(
515
+ cid,
516
+ "adGroupCriteria",
517
+ resourceNames.map((remove) => ({ remove })),
518
+ { validateOnly }
519
+ );
520
+ return (res.results ?? []).map((r) => r.resourceName ?? "");
521
+ }
522
+ };
523
+ }
524
+ async planCreateRsa(cid, payload, guard) {
525
+ validateRsa(payload);
526
+ const coercions = [];
527
+ const status = guard.forcePausedCreation ? "PAUSED" : "ENABLED";
528
+ if (guard.forcePausedCreation) coercions.push("ad created PAUSED by policy (paused_creation)");
529
+ const create = {
530
+ adGroup: `customers/${cid}/adGroups/${payload.ad_group_id}`,
531
+ status,
532
+ ad: {
533
+ finalUrls: payload.final_urls,
534
+ responsiveSearchAd: {
535
+ headlines: payload.headlines.map((text) => ({ text })),
536
+ descriptions: payload.descriptions.map((text) => ({ text })),
537
+ ...payload.path1 ? { path1: payload.path1 } : {},
538
+ ...payload.path2 ? { path2: payload.path2 } : {}
539
+ }
540
+ }
541
+ };
542
+ return {
543
+ summary: `Create responsive search ad (${payload.headlines.length} headlines, ${payload.descriptions.length} descriptions) in ad group ${payload.ad_group_id}`,
544
+ changes: [`+ responsive_search_ad in ad_group ${payload.ad_group_id} status=${status}`],
545
+ coercions,
546
+ budgetDeltas: [],
547
+ execute: async (validateOnly) => {
548
+ const res = await this.client.mutate(cid, "adGroupAds", [{ create }], { validateOnly });
549
+ return (res.results ?? []).map((r) => r.resourceName ?? "");
550
+ }
551
+ };
552
+ }
553
+ /** CPC ceilings exist on TARGET_SPEND (Maximize clicks) and TARGET_IMPRESSION_SHARE. */
554
+ async planSetBidCeiling(cid, payload) {
555
+ const current = await this.lookupCampaignBidding(cid, payload.campaign_id);
556
+ const resourceName = `customers/${cid}/campaigns/${payload.campaign_id}`;
557
+ let update;
558
+ let updateMask;
559
+ if (current.strategyType === "TARGET_SPEND") {
560
+ update = { resourceName, targetSpend: { cpcBidCeilingMicros: String(payload.cpc_bid_ceiling_micros) } };
561
+ updateMask = "target_spend.cpc_bid_ceiling_micros";
562
+ } else if (current.strategyType === "TARGET_IMPRESSION_SHARE") {
563
+ update = {
564
+ resourceName,
565
+ targetImpressionShare: { cpcBidCeilingMicros: String(payload.cpc_bid_ceiling_micros) }
566
+ };
567
+ updateMask = "target_impression_share.cpc_bid_ceiling_micros";
568
+ } else {
569
+ throw new AdportError2(
570
+ "PROVIDER_ERROR",
571
+ `google: campaign "${current.name}" uses ${current.strategyType} bidding, which has no CPC bid ceiling. Use google_set_bidding_strategy to switch strategy (e.g. MAXIMIZE_CLICKS supports a ceiling).`
572
+ );
573
+ }
574
+ return {
575
+ summary: `Set "${current.name}" CPC bid ceiling ${current.cpcBidCeilingMicros ?? "(unset)"} \u2192 ${payload.cpc_bid_ceiling_micros} micros (${current.strategyType})`,
576
+ changes: [
577
+ `~ campaign ${payload.campaign_id} ${updateMask} ${current.cpcBidCeilingMicros ?? "(unset)"} \u2192 ${payload.cpc_bid_ceiling_micros}`
578
+ ],
579
+ coercions: [],
580
+ budgetDeltas: [],
581
+ execute: async (validateOnly) => {
582
+ const res = await this.client.mutate(cid, "campaigns", [{ update, updateMask }], { validateOnly });
583
+ return (res.results ?? []).map((r) => r.resourceName ?? resourceName);
584
+ }
585
+ };
586
+ }
587
+ async planSetBiddingStrategy(cid, payload) {
588
+ if (payload.target_cpa_micros && payload.strategy !== "MAXIMIZE_CONVERSIONS") {
589
+ throw new AdportError2("INVALID_INPUT", "target_cpa_micros only applies to MAXIMIZE_CONVERSIONS");
590
+ }
591
+ if (payload.target_roas && payload.strategy !== "MAXIMIZE_CONVERSION_VALUE") {
592
+ throw new AdportError2("INVALID_INPUT", "target_roas only applies to MAXIMIZE_CONVERSION_VALUE");
593
+ }
594
+ if (payload.cpc_bid_ceiling_micros && payload.strategy !== "MAXIMIZE_CLICKS") {
595
+ throw new AdportError2("INVALID_INPUT", "cpc_bid_ceiling_micros only applies to MAXIMIZE_CLICKS (target spend)");
596
+ }
597
+ const current = await this.lookupCampaignBidding(cid, payload.campaign_id);
598
+ const resourceName = `customers/${cid}/campaigns/${payload.campaign_id}`;
599
+ let strategyField;
600
+ let updateMask;
601
+ const details = [];
602
+ switch (payload.strategy) {
603
+ case "MANUAL_CPC":
604
+ strategyField = { manualCpc: {} };
605
+ updateMask = "manual_cpc";
606
+ break;
607
+ case "MAXIMIZE_CLICKS":
608
+ strategyField = {
609
+ targetSpend: payload.cpc_bid_ceiling_micros ? { cpcBidCeilingMicros: String(payload.cpc_bid_ceiling_micros) } : {}
610
+ };
611
+ updateMask = payload.cpc_bid_ceiling_micros ? "target_spend.cpc_bid_ceiling_micros" : "target_spend";
612
+ if (payload.cpc_bid_ceiling_micros) details.push(`ceiling ${payload.cpc_bid_ceiling_micros} micros`);
613
+ break;
614
+ case "MAXIMIZE_CONVERSIONS":
615
+ strategyField = {
616
+ maximizeConversions: payload.target_cpa_micros ? { targetCpaMicros: String(payload.target_cpa_micros) } : {}
617
+ };
618
+ updateMask = payload.target_cpa_micros ? "maximize_conversions.target_cpa_micros" : "maximize_conversions";
619
+ if (payload.target_cpa_micros) details.push(`target CPA ${payload.target_cpa_micros} micros`);
620
+ break;
621
+ case "MAXIMIZE_CONVERSION_VALUE":
622
+ strategyField = {
623
+ maximizeConversionValue: payload.target_roas ? { targetRoas: payload.target_roas } : {}
624
+ };
625
+ updateMask = payload.target_roas ? "maximize_conversion_value.target_roas" : "maximize_conversion_value";
626
+ if (payload.target_roas) details.push(`target ROAS ${payload.target_roas}`);
627
+ break;
628
+ }
629
+ return {
630
+ summary: `Switch "${current.name}" bidding ${current.strategyType} \u2192 ${payload.strategy}` + (details.length > 0 ? ` (${details.join(", ")})` : ""),
631
+ changes: [`~ campaign ${payload.campaign_id} bidding_strategy ${current.strategyType} \u2192 ${payload.strategy}`],
632
+ coercions: [],
633
+ budgetDeltas: [],
634
+ execute: async (validateOnly) => {
635
+ const res = await this.client.mutate(
636
+ cid,
637
+ "campaigns",
638
+ [{ update: { resourceName, ...strategyField }, updateMask }],
639
+ { validateOnly }
640
+ );
641
+ return (res.results ?? []).map((r) => r.resourceName ?? resourceName);
642
+ }
643
+ };
644
+ }
645
+ async lookupCampaignBidding(cid, campaignId) {
646
+ const rows = await this.client.search(
647
+ cid,
648
+ `SELECT campaign.name, campaign.bidding_strategy_type, campaign.target_spend.cpc_bid_ceiling_micros, campaign.target_impression_share.cpc_bid_ceiling_micros FROM campaign WHERE campaign.id = ${Number(campaignId)} LIMIT 1`,
649
+ { maxRows: 1 }
650
+ );
651
+ const row = rows[0];
652
+ if (!row) {
653
+ throw new AdportError2("PROVIDER_ERROR", `google: campaign ${campaignId} not found in account ${cid}`);
654
+ }
655
+ const campaign = row.campaign;
656
+ const ceiling = campaign.targetSpend?.cpcBidCeilingMicros ?? campaign.targetImpressionShare?.cpcBidCeilingMicros;
657
+ return {
658
+ name: campaign.name ?? campaignId,
659
+ strategyType: campaign.biddingStrategyType ?? "UNKNOWN",
660
+ cpcBidCeilingMicros: ceiling !== void 0 ? Number(ceiling) : void 0
661
+ };
662
+ }
663
+ async lookupCampaign(cid, campaignId) {
664
+ const rows = await this.client.search(
665
+ cid,
666
+ `SELECT campaign.name, campaign.status, campaign.campaign_budget, campaign_budget.resource_name, campaign_budget.amount_micros, campaign_budget.explicitly_shared FROM campaign WHERE campaign.id = ${Number(campaignId)} LIMIT 1`,
667
+ { maxRows: 1 }
668
+ );
669
+ const row = rows[0];
670
+ if (!row) {
671
+ throw new AdportError2("PROVIDER_ERROR", `google: campaign ${campaignId} not found in account ${cid}`);
672
+ }
673
+ const campaign = row.campaign;
674
+ const budget = row.campaignBudget ?? {};
675
+ return {
676
+ name: campaign.name ?? campaignId,
677
+ status: campaign.status ?? "UNKNOWN",
678
+ budgetResource: budget.resourceName ?? "",
679
+ budgetMicros: Number(budget.amountMicros ?? 0),
680
+ budgetShared: budget.explicitlyShared ?? false
681
+ };
682
+ }
683
+ };
684
+ function validateRsa(payload) {
685
+ const problems = [];
686
+ if (payload.headlines.length < 3 || payload.headlines.length > 15) problems.push("3\u201315 headlines required");
687
+ if (payload.descriptions.length < 2 || payload.descriptions.length > 4) problems.push("2\u20134 descriptions required");
688
+ if (payload.final_urls.length < 1) problems.push("at least one final_url required");
689
+ for (const h of payload.headlines) if (h.length > 30) problems.push(`headline over 30 chars: "${h}"`);
690
+ for (const d of payload.descriptions) if (d.length > 90) problems.push(`description over 90 chars: "${d}"`);
691
+ if (problems.length > 0) {
692
+ throw new AdportError2("INVALID_INPUT", `Responsive search ad invalid: ${problems.join("; ")}`);
693
+ }
694
+ }
695
+ function round2(n) {
696
+ return Math.round(n * 100) / 100;
697
+ }
698
+
699
+ // src/tools.ts
700
+ import { defineTool, guardedWriteTool } from "@adport/core";
701
+ import { z } from "zod";
702
+ var statusSchema = z.enum(["ENABLED", "PAUSED"]);
703
+ var matchTypeSchema = z.enum(["EXACT", "PHRASE", "BROAD"]);
704
+ function googleTools(provider) {
705
+ return [
706
+ defineTool({
707
+ name: "google_gaql",
708
+ namespace: "google",
709
+ description: `Run a structured Google Ads (GAQL) query: pick a resource (campaign, ad_group, keyword_view, change_event, ...), fields (e.g. campaign.name, metrics.clicks, segments.date), optional conditions and ordering. Conditions are raw GAQL predicates, e.g. "segments.date DURING LAST_7_DAYS" or "campaign.status = 'ENABLED'".`,
710
+ input: z.object({
711
+ customer_id: z.string().describe("10-digit account id, dashes ok"),
712
+ resource: z.string().min(1),
713
+ fields: z.array(z.string()).min(1),
714
+ conditions: z.array(z.string()).optional(),
715
+ order_by: z.array(z.string()).optional(),
716
+ limit: z.number().int().positive().max(1e4).default(200)
717
+ }),
718
+ annotations: { readOnly: true },
719
+ async handler(input) {
720
+ const rows = await provider.gaqlSearch(input);
721
+ return { rows, row_count: rows.length };
722
+ }
723
+ }),
724
+ guardedWriteTool({
725
+ name: "google_create_campaign",
726
+ namespace: "google",
727
+ description: "Create a Google Ads campaign with its own (non-shared) daily budget, atomically. Manual CPC bidding; refine later.",
728
+ provider: "google",
729
+ kind: "create",
730
+ payload: z.object({
731
+ name: z.string().min(1),
732
+ daily_budget_micros: z.number().int().positive().describe("1 currency unit = 1,000,000 micros"),
733
+ channel_type: z.enum(["SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"]).default("SEARCH"),
734
+ status: statusSchema.optional()
735
+ })
736
+ }),
737
+ guardedWriteTool({
738
+ name: "google_set_campaign_status",
739
+ namespace: "google",
740
+ description: "Enable or pause a campaign.",
741
+ provider: "google",
742
+ kind: "update",
743
+ payload: z.object({ campaign_id: z.string(), status: statusSchema })
744
+ }),
745
+ guardedWriteTool({
746
+ name: "google_set_budget",
747
+ namespace: "google",
748
+ description: "Change a campaign's daily budget. The preview warns when the budget is shared across campaigns.",
749
+ provider: "google",
750
+ kind: "update",
751
+ payload: z.object({ campaign_id: z.string(), daily_budget_micros: z.number().int().positive() })
752
+ }),
753
+ guardedWriteTool({
754
+ name: "google_set_bid_ceiling",
755
+ namespace: "google",
756
+ description: "Set a campaign's max CPC bid ceiling (micros). Works for MAXIMIZE_CLICKS (target spend) and TARGET_IMPRESSION_SHARE strategies; fails with guidance for others.",
757
+ provider: "google",
758
+ kind: "update",
759
+ payload: z.object({
760
+ campaign_id: z.string(),
761
+ cpc_bid_ceiling_micros: z.number().int().positive().describe("1 currency unit = 1,000,000 micros")
762
+ })
763
+ }),
764
+ guardedWriteTool({
765
+ name: "google_set_bidding_strategy",
766
+ namespace: "google",
767
+ description: "Switch a campaign's bidding strategy: MANUAL_CPC, MAXIMIZE_CLICKS (optional cpc_bid_ceiling_micros), MAXIMIZE_CONVERSIONS (optional target_cpa_micros), MAXIMIZE_CONVERSION_VALUE (optional target_roas). Also updates the target of the current strategy when the strategy stays the same.",
768
+ provider: "google",
769
+ kind: "update",
770
+ payload: z.object({
771
+ campaign_id: z.string(),
772
+ strategy: z.enum(["MANUAL_CPC", "MAXIMIZE_CLICKS", "MAXIMIZE_CONVERSIONS", "MAXIMIZE_CONVERSION_VALUE"]),
773
+ target_cpa_micros: z.number().int().positive().optional(),
774
+ target_roas: z.number().positive().optional().describe("e.g. 3.5 = 350% return on ad spend"),
775
+ cpc_bid_ceiling_micros: z.number().int().positive().optional()
776
+ })
777
+ }),
778
+ guardedWriteTool({
779
+ name: "google_create_ad_group",
780
+ namespace: "google",
781
+ description: "Create a SEARCH_STANDARD ad group in a campaign.",
782
+ provider: "google",
783
+ kind: "create",
784
+ payload: z.object({
785
+ campaign_id: z.string(),
786
+ name: z.string().min(1),
787
+ cpc_bid_micros: z.number().int().positive().optional()
788
+ })
789
+ }),
790
+ guardedWriteTool({
791
+ name: "google_set_ad_group_status",
792
+ namespace: "google",
793
+ description: "Enable or pause an ad group.",
794
+ provider: "google",
795
+ kind: "update",
796
+ payload: z.object({ ad_group_id: z.string(), status: statusSchema })
797
+ }),
798
+ guardedWriteTool({
799
+ name: "google_add_keywords",
800
+ namespace: "google",
801
+ description: "Add keywords (or negative keywords) to an ad group.",
802
+ provider: "google",
803
+ kind: "create",
804
+ payload: z.object({
805
+ ad_group_id: z.string(),
806
+ keywords: z.array(z.object({ text: z.string().min(1), match_type: matchTypeSchema })).min(1),
807
+ negative: z.boolean().default(false)
808
+ })
809
+ }),
810
+ guardedWriteTool({
811
+ name: "google_set_keyword_status",
812
+ namespace: "google",
813
+ description: "Enable or pause a keyword criterion.",
814
+ provider: "google",
815
+ kind: "update",
816
+ payload: z.object({ ad_group_id: z.string(), criterion_id: z.string(), status: statusSchema })
817
+ }),
818
+ guardedWriteTool({
819
+ name: "google_remove_keywords",
820
+ namespace: "google",
821
+ description: "PERMANENTLY remove keyword criteria from an ad group. Prefer google_set_keyword_status to pause.",
822
+ provider: "google",
823
+ kind: "remove",
824
+ destructive: true,
825
+ payload: z.object({ ad_group_id: z.string(), criterion_ids: z.array(z.string()).min(1) })
826
+ }),
827
+ guardedWriteTool({
828
+ name: "google_create_responsive_search_ad",
829
+ namespace: "google",
830
+ description: "Create a responsive search ad (3\u201315 headlines \u226430 chars, 2\u20134 descriptions \u226490 chars).",
831
+ provider: "google",
832
+ kind: "create",
833
+ payload: z.object({
834
+ ad_group_id: z.string(),
835
+ headlines: z.array(z.string()).min(3).max(15),
836
+ descriptions: z.array(z.string()).min(2).max(4),
837
+ final_urls: z.array(z.string().url()).min(1),
838
+ path1: z.string().max(15).optional(),
839
+ path2: z.string().max(15).optional()
840
+ })
841
+ })
842
+ ];
843
+ }
844
+
845
+ // src/index.ts
846
+ async function resolveGoogleCredentials(store) {
847
+ const record = await store.get("google");
848
+ if (record) {
849
+ const { developer_token, client_id, client_secret, refresh_token, login_customer_id } = record.data;
850
+ if (developer_token && client_id && client_secret && refresh_token) {
851
+ return {
852
+ developerToken: developer_token,
853
+ clientId: client_id,
854
+ clientSecret: client_secret,
855
+ refreshToken: refresh_token,
856
+ loginCustomerId: login_customer_id || void 0
857
+ };
858
+ }
859
+ }
860
+ const env = process.env;
861
+ if (env.GOOGLE_ADS_DEVELOPER_TOKEN && env.GOOGLE_ADS_CLIENT_ID && env.GOOGLE_ADS_CLIENT_SECRET && env.GOOGLE_ADS_REFRESH_TOKEN) {
862
+ return {
863
+ developerToken: env.GOOGLE_ADS_DEVELOPER_TOKEN,
864
+ clientId: env.GOOGLE_ADS_CLIENT_ID,
865
+ clientSecret: env.GOOGLE_ADS_CLIENT_SECRET,
866
+ refreshToken: env.GOOGLE_ADS_REFRESH_TOKEN,
867
+ loginCustomerId: env.GOOGLE_ADS_LOGIN_CUSTOMER_ID || void 0
868
+ };
869
+ }
870
+ return void 0;
871
+ }
872
+ async function createGoogleModule(store) {
873
+ const credentials = await resolveGoogleCredentials(store);
874
+ if (!credentials) return void 0;
875
+ const provider = new GoogleAdsProvider(new GoogleAdsRestClient(credentials));
876
+ return { provider, tools: googleTools(provider) };
877
+ }
878
+ export {
879
+ DEFAULT_API_VERSION,
880
+ GoogleAdsProvider,
881
+ GoogleAdsRestClient,
882
+ createGoogleModule,
883
+ formatGoogleAdsError,
884
+ googleTools,
885
+ normalizeCustomerId,
886
+ resolveGoogleCredentials
887
+ };
888
+ //# sourceMappingURL=index.js.map