@affset/mcp 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/.env.example +18 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/client.js +131 -0
- package/dist/config.js +100 -0
- package/dist/index.js +21 -0
- package/dist/lib/format.js +148 -0
- package/dist/lib/integrationUrls.js +128 -0
- package/dist/lib/linkArgs.js +53 -0
- package/dist/lib/patch.js +23 -0
- package/dist/lib/payoutRules.js +46 -0
- package/dist/lib/targeting.js +246 -0
- package/dist/lib/time.js +234 -0
- package/dist/lib/toolResult.js +35 -0
- package/dist/lib/urls.js +15 -0
- package/dist/lib/zones.js +83 -0
- package/dist/server.js +316 -0
- package/dist/tools/createCampaign.js +211 -0
- package/dist/tools/createZone.js +117 -0
- package/dist/tools/cutZones.js +224 -0
- package/dist/tools/deletePayoutRule.js +70 -0
- package/dist/tools/getStats.js +72 -0
- package/dist/tools/getTrackingLink.js +119 -0
- package/dist/tools/getZoneUrl.js +94 -0
- package/dist/tools/listCampaigns.js +81 -0
- package/dist/tools/listConversions.js +237 -0
- package/dist/tools/listPayoutRules.js +72 -0
- package/dist/tools/listSubLabels.js +31 -0
- package/dist/tools/listTargetingRules.js +69 -0
- package/dist/tools/listTargetingTypes.js +39 -0
- package/dist/tools/listTeam.js +57 -0
- package/dist/tools/listZones.js +70 -0
- package/dist/tools/removeTargetingRule.js +109 -0
- package/dist/tools/setCampaignStatus.js +32 -0
- package/dist/tools/setPayoutGoal.js +69 -0
- package/dist/tools/setPayoutRule.js +119 -0
- package/dist/tools/setSubLabels.js +102 -0
- package/dist/tools/setTargetingRule.js +125 -0
- package/dist/tools/updateCampaign.js +218 -0
- package/dist/tools/updateZone.js +118 -0
- package/dist/tools/whoami.js +42 -0
- package/dist/types.js +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mdCell } from "../lib/format.js";
|
|
2
|
+
import { errorResult, textResult } from "../lib/toolResult.js";
|
|
3
|
+
import { SUB_KEYS } from "../types.js";
|
|
4
|
+
export const LIST_SUB_LABELS_DESCRIPTION = "List the tenant's display names for sub1–sub5 (traffic-source breakdown slots). " +
|
|
5
|
+
"Unlabeled slots show as the raw key. Used by get_stats column titles and " +
|
|
6
|
+
"tracking-link / zone-URL query params.";
|
|
7
|
+
export const listSubLabelsInputSchema = {};
|
|
8
|
+
export async function listSubLabels(client) {
|
|
9
|
+
try {
|
|
10
|
+
const settings = await client.get("/api/tenant");
|
|
11
|
+
const labels = settings.sub_labels ?? {};
|
|
12
|
+
const labeled = SUB_KEYS.filter((k) => (labels[k] ?? "").trim().length > 0);
|
|
13
|
+
const lines = [
|
|
14
|
+
`**Sub labels** — ${labeled.length} of ${SUB_KEYS.length} named`,
|
|
15
|
+
"",
|
|
16
|
+
"| Key | Label |",
|
|
17
|
+
"|---|---|",
|
|
18
|
+
];
|
|
19
|
+
for (const key of SUB_KEYS) {
|
|
20
|
+
const label = labels[key]?.trim();
|
|
21
|
+
lines.push(`| \`${key}\` | ${label ? mdCell(label) : "_— (raw key)_"} |`);
|
|
22
|
+
}
|
|
23
|
+
lines.push("");
|
|
24
|
+
lines.push("_Set / clear with `set_sub_labels` (null or empty clears a slot)._");
|
|
25
|
+
return textResult(lines.join("\n"));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return errorResult(err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=listSubLabels.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { mdCell } from "../lib/format.js";
|
|
4
|
+
import { fetchTargetingRules, fetchTargetingTypes, UNENFORCED_TYPES } from "../lib/targeting.js";
|
|
5
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
6
|
+
export const LIST_TARGETING_RULES_DESCRIPTION = "List a campaign's targeting rules (type, method whitelist/blacklist, rule values). " +
|
|
7
|
+
"Rules gate /serve rotation; the direct tracking link does not enforce them. " +
|
|
8
|
+
"Call `list_targeting_types` for type ids/names.";
|
|
9
|
+
export const listTargetingRulesInputSchema = {
|
|
10
|
+
campaign_id: z
|
|
11
|
+
.union([z.string().min(1), z.number().int()])
|
|
12
|
+
.describe("Campaign whose targeting rules to list."),
|
|
13
|
+
};
|
|
14
|
+
export async function listTargetingRules(client, args) {
|
|
15
|
+
try {
|
|
16
|
+
const campaignId = String(args.campaign_id).trim();
|
|
17
|
+
if (!campaignId)
|
|
18
|
+
return textError("campaign_id is required.");
|
|
19
|
+
const [rules, types] = await Promise.all([
|
|
20
|
+
fetchTargetingRules(client, campaignId),
|
|
21
|
+
// Names are a nicety; a rule list is still useful with bare type ids.
|
|
22
|
+
fetchTargetingTypes(client).catch(() => []),
|
|
23
|
+
]);
|
|
24
|
+
const typeById = new Map(types.map((t) => [t.id, t]));
|
|
25
|
+
const dead = rules.filter((r) => {
|
|
26
|
+
const name = typeById.get(r.targeting_rule_type_id)?.name.toLowerCase();
|
|
27
|
+
return name !== undefined && UNENFORCED_TYPES[name] !== undefined;
|
|
28
|
+
});
|
|
29
|
+
return textResult([
|
|
30
|
+
`**Targeting rules** — campaign \`${campaignId}\` — ${rules.length} rule(s)`,
|
|
31
|
+
"",
|
|
32
|
+
renderRules(rules, typeById),
|
|
33
|
+
...(dead.length ? ["", ...deadRuleWarnings(dead, typeById)] : []),
|
|
34
|
+
"",
|
|
35
|
+
"_Enforced on `/serve` rotation only — not on direct tracking links._",
|
|
36
|
+
"_Manage with `set_targeting_rule` / `remove_targeting_rule`._",
|
|
37
|
+
].join("\n"));
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
41
|
+
return textError(`Campaign \`${String(args.campaign_id).trim()}\` not found in this namespace.`);
|
|
42
|
+
}
|
|
43
|
+
return errorResult(err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function renderRules(rules, typeById) {
|
|
47
|
+
if (rules.length === 0) {
|
|
48
|
+
return "_No targeting rules — campaign is unrestricted on /serve._";
|
|
49
|
+
}
|
|
50
|
+
const lines = ["| Id | Type | Method | Rule |", "|---|---|---|---|"];
|
|
51
|
+
for (const r of rules) {
|
|
52
|
+
const t = typeById.get(r.targeting_rule_type_id);
|
|
53
|
+
const typeLabel = t
|
|
54
|
+
? `${mdCell(t.name)} (${r.targeting_rule_type_id})`
|
|
55
|
+
: String(r.targeting_rule_type_id);
|
|
56
|
+
const inert = t && UNENFORCED_TYPES[t.name.toLowerCase()] ? " ⚠️" : "";
|
|
57
|
+
lines.push(`| ${r.id ?? "—"} | ${typeLabel}${inert} | ${mdCell(r.targeting_method)} | ${mdCell(r.rule)} |`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Rules of a seeded-but-unevaluated type read as working targeting. Saying so on
|
|
63
|
+
* every listing is the only place an operator would find out.
|
|
64
|
+
*/
|
|
65
|
+
function deadRuleWarnings(dead, typeById) {
|
|
66
|
+
const names = [...new Set(dead.map((r) => typeById.get(r.targeting_rule_type_id).name))];
|
|
67
|
+
return names.map((name) => `⚠️ \`${name}\` — ${UNENFORCED_TYPES[name.toLowerCase()]}. This rule has no effect.`);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=listTargetingRules.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mdCell } from "../lib/format.js";
|
|
2
|
+
import { fetchTargetingTypes, UNENFORCED_TYPES } from "../lib/targeting.js";
|
|
3
|
+
import { errorResult, textResult } from "../lib/toolResult.js";
|
|
4
|
+
export const LIST_TARGETING_TYPES_DESCRIPTION = "List targeting rule types available in this tenant (id, name, description), " +
|
|
5
|
+
"flagging the seeded types the /serve path does not actually evaluate. " +
|
|
6
|
+
"Use the id (or name) when setting campaign targeting rules. Enforced types: " +
|
|
7
|
+
"geo, device_type, zone_id, os, browser, unique_users.";
|
|
8
|
+
export const listTargetingTypesInputSchema = {};
|
|
9
|
+
export async function listTargetingTypes(client) {
|
|
10
|
+
try {
|
|
11
|
+
const types = await fetchTargetingTypes(client);
|
|
12
|
+
if (types.length === 0) {
|
|
13
|
+
return textResult("_No targeting rule types found._");
|
|
14
|
+
}
|
|
15
|
+
const lines = [
|
|
16
|
+
`**Targeting rule types** — ${types.length}`,
|
|
17
|
+
"",
|
|
18
|
+
"| Id | Name | Enforced | Description |",
|
|
19
|
+
"|--:|---|---|---|",
|
|
20
|
+
];
|
|
21
|
+
for (const t of types) {
|
|
22
|
+
const unenforced = UNENFORCED_TYPES[t.name.toLowerCase()];
|
|
23
|
+
lines.push(`| ${t.id} | ${mdCell(t.name)} | ${unenforced ? "**no**" : "yes"} | ` +
|
|
24
|
+
`${mdCell(unenforced ?? t.description ?? "—")} |`);
|
|
25
|
+
}
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push("_Use these ids with `set_targeting_rule` / `list_targeting_rules`. " +
|
|
28
|
+
"`rule` is usually a comma-separated list (e.g. geo: `BR,MX`; " +
|
|
29
|
+
"unique_users: `visits/hours`)._");
|
|
30
|
+
lines.push("_Values are matched exactly and case-sensitively at serve time (geo from " +
|
|
31
|
+
"`CF-IPCountry`, os/browser from the user agent); `set_targeting_rule` " +
|
|
32
|
+
"normalises what it can and rejects what could never match._");
|
|
33
|
+
return textResult(lines.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return errorResult(err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=listTargetingTypes.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mdCell } from "../lib/format.js";
|
|
3
|
+
import { errorResult } from "../lib/toolResult.js";
|
|
4
|
+
export const LIST_TEAM_DESCRIPTION = "List team members (user API keys) in the current namespace: email, role, " +
|
|
5
|
+
"manager, created/expiry. Never returns API tokens. Requires owner/manager " +
|
|
6
|
+
"(or a scoped manager role).";
|
|
7
|
+
export const listTeamInputSchema = {
|
|
8
|
+
role: z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Optional role filter, e.g. owner, manager, publisher, advertiser, " +
|
|
13
|
+
"publisher_manager, advertiser_manager."),
|
|
14
|
+
include_expired: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.default(false)
|
|
17
|
+
.describe("Include members whose expires_at is in the past. Default false."),
|
|
18
|
+
};
|
|
19
|
+
export async function listTeam(client, args) {
|
|
20
|
+
try {
|
|
21
|
+
// Team members are user-typed API keys — there is no /api/team endpoint.
|
|
22
|
+
const members = await client.get("/api/api-keys", { type: "user" });
|
|
23
|
+
const list = Array.isArray(members) ? members : [];
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
let filtered = list;
|
|
26
|
+
if (args.role) {
|
|
27
|
+
const role = args.role.trim().toLowerCase();
|
|
28
|
+
filtered = filtered.filter((m) => (m.role ?? "").toLowerCase() === role);
|
|
29
|
+
}
|
|
30
|
+
if (!args.include_expired) {
|
|
31
|
+
filtered = filtered.filter((m) => m.expires_at == null || m.expires_at > now);
|
|
32
|
+
}
|
|
33
|
+
const head = `**Team** — ${filtered.length} member(s)` +
|
|
34
|
+
(filtered.length !== list.length ? ` (of ${list.length} total keys)` : "") +
|
|
35
|
+
".";
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: `${head}\n\n${renderTable(filtered, now)}` }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return errorResult(err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function renderTable(members, now) {
|
|
45
|
+
if (members.length === 0)
|
|
46
|
+
return "_No team members matched._";
|
|
47
|
+
const lines = ["| Email | Role | Manager | Created | Expires |", "|---|---|---|---|---|"];
|
|
48
|
+
for (const m of members) {
|
|
49
|
+
const expired = m.expires_at != null && m.expires_at <= now;
|
|
50
|
+
lines.push(`| ${mdCell(m.email ?? "(no email)")} | ${mdCell(m.role)} | ${mdCell(m.manager_email ?? "—")} | ${fmtDay(m.created_at)} | ${m.expires_at == null ? "—" : `${fmtDay(m.expires_at)}${expired ? " (expired)" : ""}`} |`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function fmtDay(ms) {
|
|
55
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=listTeam.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mdCell } from "../lib/format.js";
|
|
3
|
+
import { errorResult } from "../lib/toolResult.js";
|
|
4
|
+
import { ZONE_STATUSES } from "../types.js";
|
|
5
|
+
export const LIST_ZONES_DESCRIPTION = "List traffic-source zones in the current namespace. Filter by status and optionally " +
|
|
6
|
+
"by name (client-side contains match). Returns id, name, status, postback_url, " +
|
|
7
|
+
"site_url, publisher. Paginated (default 20, max 100).";
|
|
8
|
+
export const listZonesInputSchema = {
|
|
9
|
+
status: z.enum(ZONE_STATUSES).optional().describe("Filter by zone status."),
|
|
10
|
+
name_contains: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Case-insensitive substring match on zone name (client-side)."),
|
|
15
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Page size (1–100). Default 20."),
|
|
16
|
+
offset: z.number().int().min(0).default(0).describe("Pagination offset. Default 0."),
|
|
17
|
+
sort: z
|
|
18
|
+
.enum(["name", "created_at", "site_url"])
|
|
19
|
+
.default("created_at")
|
|
20
|
+
.describe("Sort field. Default created_at."),
|
|
21
|
+
order: z.enum(["asc", "desc"]).default("desc").describe("Sort order. Default desc."),
|
|
22
|
+
};
|
|
23
|
+
export async function listZones(client, args) {
|
|
24
|
+
try {
|
|
25
|
+
const data = await client.get("/api/zones", {
|
|
26
|
+
status: args.status,
|
|
27
|
+
limit: args.limit,
|
|
28
|
+
offset: args.offset,
|
|
29
|
+
sort: args.sort,
|
|
30
|
+
order: args.order,
|
|
31
|
+
});
|
|
32
|
+
let zones = data.zones ?? [];
|
|
33
|
+
const needle = args.name_contains?.trim().toLowerCase();
|
|
34
|
+
if (needle) {
|
|
35
|
+
zones = zones.filter((z) => z.name.toLowerCase().includes(needle));
|
|
36
|
+
}
|
|
37
|
+
const pagination = data.pagination;
|
|
38
|
+
const total = pagination?.total ?? zones.length;
|
|
39
|
+
const shown = zones.length;
|
|
40
|
+
const filterNote = needle
|
|
41
|
+
? ` (name contains "${args.name_contains}" → ${shown} on this page)`
|
|
42
|
+
: "";
|
|
43
|
+
const head = `**Zones** — showing ${shown} of total ${total}${filterNote}` +
|
|
44
|
+
(pagination?.has_more ? `. More available (offset ${args.offset + args.limit}).` : ".");
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: `${head}\n\n${renderTable(zones)}` }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return errorResult(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function renderTable(zones) {
|
|
54
|
+
if (zones.length === 0)
|
|
55
|
+
return "_No zones matched._";
|
|
56
|
+
const lines = [
|
|
57
|
+
"| ID | Name | Status | Postback | Site | Publisher |",
|
|
58
|
+
"|---|---|---|---|---|---|",
|
|
59
|
+
];
|
|
60
|
+
for (const z of zones) {
|
|
61
|
+
lines.push(`| \`${z.id}\` | ${mdCell(z.name)} | ${z.status} | ${z.postback_url ? mdCell(shortUrl(z.postback_url)) : "⚠️ none"} | ${mdCell(shortUrl(z.site_url))} | ${mdCell(z.user_email ?? "—")} |`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
function shortUrl(url) {
|
|
66
|
+
if (!url)
|
|
67
|
+
return "—";
|
|
68
|
+
return url.length > 40 ? `${url.slice(0, 37)}…` : url;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=listZones.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { mdCell } from "../lib/format.js";
|
|
4
|
+
import { fetchTargetingRules, fetchTargetingTypes, resolveTargetingType, syncTargetingRules, toSyncPayload, } from "../lib/targeting.js";
|
|
5
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
6
|
+
export const REMOVE_TARGETING_RULE_DESCRIPTION = "Remove one targeting rule from a campaign (safe merge — other rules are kept). " +
|
|
7
|
+
"Identify by rule_id, or by type + method. DRY-RUN by default; pass confirm=true to apply.";
|
|
8
|
+
export const removeTargetingRuleInputSchema = {
|
|
9
|
+
campaign_id: z.union([z.string().min(1), z.number().int()]).describe("Campaign to edit."),
|
|
10
|
+
rule_id: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.positive()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Existing rule id (from list_targeting_rules). Prefer this when known."),
|
|
16
|
+
type: z
|
|
17
|
+
.union([z.string().min(1), z.number().int()])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Targeting type id or name. Required with method when rule_id is omitted."),
|
|
20
|
+
method: z
|
|
21
|
+
.enum(["whitelist", "blacklist"])
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("whitelist or blacklist. Required with type when rule_id is omitted."),
|
|
24
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
25
|
+
};
|
|
26
|
+
export async function removeTargetingRule(client, args) {
|
|
27
|
+
try {
|
|
28
|
+
const campaignId = String(args.campaign_id).trim();
|
|
29
|
+
if (!campaignId)
|
|
30
|
+
return textError("campaign_id is required.");
|
|
31
|
+
if (args.rule_id == null && (args.type == null || args.method == null)) {
|
|
32
|
+
return textError("Provide rule_id, or both type and method, to identify the rule to remove.");
|
|
33
|
+
}
|
|
34
|
+
const [types, current] = await Promise.all([
|
|
35
|
+
fetchTargetingTypes(client),
|
|
36
|
+
fetchTargetingRules(client, campaignId),
|
|
37
|
+
]);
|
|
38
|
+
const target = findTarget(types, current, args);
|
|
39
|
+
if ("error" in target)
|
|
40
|
+
return textError(target.error);
|
|
41
|
+
const rule = target.rule;
|
|
42
|
+
const typeName = types.find((t) => t.id === rule.targeting_rule_type_id)?.name ??
|
|
43
|
+
String(rule.targeting_rule_type_id);
|
|
44
|
+
if (!args.confirm) {
|
|
45
|
+
return textResult([
|
|
46
|
+
`**Dry run** — would remove targeting rule on campaign \`${campaignId}\`.`,
|
|
47
|
+
"",
|
|
48
|
+
"| Field | Value |",
|
|
49
|
+
"|---|---|",
|
|
50
|
+
`| Id | \`${rule.id ?? "—"}\` |`,
|
|
51
|
+
`| Type | ${mdCell(typeName)} (${rule.targeting_rule_type_id}) |`,
|
|
52
|
+
`| Method | ${mdCell(rule.targeting_method)} |`,
|
|
53
|
+
`| Rule | ${mdCell(rule.rule)} |`,
|
|
54
|
+
"",
|
|
55
|
+
removalNote(current, rule, typeName),
|
|
56
|
+
"",
|
|
57
|
+
"Other rules stay untouched. Call again with `confirm: true` to apply.",
|
|
58
|
+
].join("\n"));
|
|
59
|
+
}
|
|
60
|
+
// Identity, not id: a rule row the API returned without an id must not take
|
|
61
|
+
// every other id-less row with it.
|
|
62
|
+
await syncTargetingRules(client, campaignId, toSyncPayload(current.filter((r) => r !== rule)));
|
|
63
|
+
return textResult(`✅ Removed ${mdCell(typeName)} ${rule.targeting_method} rule ` +
|
|
64
|
+
`\`${mdCell(rule.rule)}\` (id \`${rule.id ?? "—"}\`) from campaign \`${campaignId}\`.`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
68
|
+
return textError(`Campaign \`${String(args.campaign_id).trim()}\` not found in this namespace.`);
|
|
69
|
+
}
|
|
70
|
+
return errorResult(err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** What the campaign is left targeting once this rule is gone. */
|
|
74
|
+
function removalNote(rules, rule, typeName) {
|
|
75
|
+
const remaining = rules.filter((r) => r !== rule && r.targeting_rule_type_id === rule.targeting_rule_type_id);
|
|
76
|
+
if (remaining.length > 0) {
|
|
77
|
+
return `${typeName} stays gated by ${remaining.length} other rule(s) of the same type.`;
|
|
78
|
+
}
|
|
79
|
+
return rules.length === 1
|
|
80
|
+
? "⚠️ This is the campaign's last rule — it becomes unrestricted on `/serve`."
|
|
81
|
+
: `${typeName} becomes unrestricted; other rule types still apply.`;
|
|
82
|
+
}
|
|
83
|
+
function findTarget(types, rules, args) {
|
|
84
|
+
if (args.rule_id != null) {
|
|
85
|
+
const match = rules.find((r) => r.id === args.rule_id);
|
|
86
|
+
if (!match) {
|
|
87
|
+
return { error: `No targeting rule with id \`${args.rule_id}\` on this campaign.` };
|
|
88
|
+
}
|
|
89
|
+
return { rule: match };
|
|
90
|
+
}
|
|
91
|
+
const resolved = resolveTargetingType(types, args.type);
|
|
92
|
+
if ("error" in resolved)
|
|
93
|
+
return resolved;
|
|
94
|
+
const matches = rules.filter((r) => r.targeting_rule_type_id === resolved.type.id && r.targeting_method === args.method);
|
|
95
|
+
if (matches.length === 0) {
|
|
96
|
+
return {
|
|
97
|
+
error: `No ${args.method} rule of type ${resolved.type.name} on this campaign.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (matches.length > 1) {
|
|
101
|
+
const ids = matches.map((r) => `\`${r.id}\``).join(", ");
|
|
102
|
+
return {
|
|
103
|
+
error: `${matches.length} ${resolved.type.name} ${args.method} rules on this campaign ` +
|
|
104
|
+
`(${ids}) — pass rule_id to say which one to remove.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { rule: matches[0] };
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=removeTargetingRule.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { updateCampaign } from "./updateCampaign.js";
|
|
3
|
+
/** Media-buyer verbs → API status. */
|
|
4
|
+
export const CAMPAIGN_ACTIONS = ["run", "pause"];
|
|
5
|
+
const ACTION_TO_STATUS = {
|
|
6
|
+
run: "active",
|
|
7
|
+
pause: "paused",
|
|
8
|
+
};
|
|
9
|
+
export const SET_CAMPAIGN_STATUS_DESCRIPTION = "Run (activate) or pause a campaign. 'run' enters /serve zone rotation and counts " +
|
|
10
|
+
"against the plan's active-campaign limit (402 if exceeded). 'pause' removes the " +
|
|
11
|
+
"campaign from active serving, so both /serve selection and direct tracking links stop. DRY-RUN by " +
|
|
12
|
+
"default; pass confirm=true to apply. For name/offer/budget edits use update_campaign.";
|
|
13
|
+
export const setCampaignStatusInputSchema = {
|
|
14
|
+
campaign_id: z
|
|
15
|
+
.union([z.string().min(1), z.number().int()])
|
|
16
|
+
.describe("Campaign id to run or pause."),
|
|
17
|
+
action: z
|
|
18
|
+
.enum(CAMPAIGN_ACTIONS)
|
|
19
|
+
.describe("run = active serving. pause = both /serve and direct tracking links stop."),
|
|
20
|
+
confirm: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.default(false)
|
|
23
|
+
.describe("false = dry-run preview (default). true = apply the status change."),
|
|
24
|
+
};
|
|
25
|
+
export async function setCampaignStatus(client, args) {
|
|
26
|
+
return updateCampaign(client, {
|
|
27
|
+
campaign_id: args.campaign_id,
|
|
28
|
+
status: ACTION_TO_STATUS[args.action],
|
|
29
|
+
confirm: args.confirm,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=setCampaignStatus.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { mdCell } from "../lib/format.js";
|
|
4
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
5
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
6
|
+
export const SET_PAYOUT_GOAL_DESCRIPTION = "Set or clear a campaign's payout_goal_type (goal-based conversions). When set, " +
|
|
7
|
+
"spend and payout apply only when the conversion pixel's `type=` exactly matches; " +
|
|
8
|
+
"other types are still recorded with $0. Pass null (or empty string) to clear. " +
|
|
9
|
+
"DRY-RUN by default; pass confirm=true to apply.";
|
|
10
|
+
export const setPayoutGoalInputSchema = {
|
|
11
|
+
campaign_id: z
|
|
12
|
+
.union([z.string().min(1), z.number().int()])
|
|
13
|
+
.describe("Campaign to set the payout goal type on."),
|
|
14
|
+
goal_type: z
|
|
15
|
+
.union([z.string(), z.null()])
|
|
16
|
+
.describe('Goal type string (e.g. "deposit", "lead", "purchase"), or null/"" to clear.'),
|
|
17
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
18
|
+
};
|
|
19
|
+
export async function setPayoutGoal(client, args) {
|
|
20
|
+
try {
|
|
21
|
+
const campaignId = String(args.campaign_id).trim();
|
|
22
|
+
if (!campaignId)
|
|
23
|
+
return textError("campaign_id is required.");
|
|
24
|
+
const next = args.goal_type == null ? null : args.goal_type.trim() || null;
|
|
25
|
+
let existing;
|
|
26
|
+
try {
|
|
27
|
+
existing = await client.get(`/api/campaigns/${encodeURIComponent(campaignId)}`);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
31
|
+
return textError(`Campaign \`${campaignId}\` not found in this namespace.`);
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
const from = existing.payout_goal_type?.trim() || null;
|
|
36
|
+
if (from === next) {
|
|
37
|
+
return textResult(`Nothing to change on campaign \`${existing.id}\` (${mdCell(existing.name)}) — ` +
|
|
38
|
+
`payout_goal_type is already ${displayValue(from)}.`);
|
|
39
|
+
}
|
|
40
|
+
const diff = renderDiff([
|
|
41
|
+
{
|
|
42
|
+
field: "payout_goal_type",
|
|
43
|
+
from: displayValue(from),
|
|
44
|
+
to: displayValue(next),
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
if (!args.confirm) {
|
|
48
|
+
return textResult([
|
|
49
|
+
`**Dry run** — would update payout_goal_type on campaign \`${existing.id}\` (${mdCell(existing.name)}).`,
|
|
50
|
+
"",
|
|
51
|
+
diff,
|
|
52
|
+
"",
|
|
53
|
+
next
|
|
54
|
+
? `⚠️ Only conversions with pixel \`type=${next}\` will get spend/payout; others stay $0.`
|
|
55
|
+
: "Clearing the goal type means every conversion type gets the resolved payout.",
|
|
56
|
+
"",
|
|
57
|
+
"Call again with `confirm: true` to apply.",
|
|
58
|
+
].join("\n"));
|
|
59
|
+
}
|
|
60
|
+
await client.put(`/api/campaigns/${encodeURIComponent(campaignId)}`, {
|
|
61
|
+
payout_goal_type: next,
|
|
62
|
+
});
|
|
63
|
+
return textResult([`✅ Campaign \`${existing.id}\` payout_goal_type updated.`, "", diff].join("\n"));
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return errorResult(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=setPayoutGoal.js.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { moneyPrecise } from "../lib/format.js";
|
|
4
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
5
|
+
import { createPayoutRule, fetchPayoutRules, findPayoutRule, replacePayout, } from "../lib/payoutRules.js";
|
|
6
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
7
|
+
/** Matches lite-adserver PAYOUT_MIN / PAYOUT_MAX. */
|
|
8
|
+
const PAYOUT_MIN = 0.00001;
|
|
9
|
+
const PAYOUT_MAX = 9999.99999;
|
|
10
|
+
export const SET_PAYOUT_RULE_DESCRIPTION = "Set a campaign payout rule (global or per-zone). Upserts: if a rule already " +
|
|
11
|
+
"exists for that scope it is replaced (delete + create, with the old payout " +
|
|
12
|
+
"restored if the create fails). Omit zone_id for the global rule; pass a zone " +
|
|
13
|
+
"UUID for a zone override. DRY-RUN by default; pass confirm=true to apply.";
|
|
14
|
+
export const setPayoutRuleInputSchema = {
|
|
15
|
+
campaign_id: z
|
|
16
|
+
.union([z.string().min(1), z.number().int()])
|
|
17
|
+
.describe("Campaign to set the payout rule on."),
|
|
18
|
+
payout: z
|
|
19
|
+
.number()
|
|
20
|
+
.min(PAYOUT_MIN)
|
|
21
|
+
.max(PAYOUT_MAX)
|
|
22
|
+
.describe(`Payout per conversion in USD (${PAYOUT_MIN}–${PAYOUT_MAX}).`),
|
|
23
|
+
zone_id: z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.min(1)
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Zone UUID for a zone-specific override. Omit for the global rule."),
|
|
29
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
30
|
+
};
|
|
31
|
+
export async function setPayoutRule(client, args) {
|
|
32
|
+
try {
|
|
33
|
+
const campaignId = String(args.campaign_id).trim();
|
|
34
|
+
if (!campaignId)
|
|
35
|
+
return textError("campaign_id is required.");
|
|
36
|
+
const zoneId = args.zone_id?.trim() || null;
|
|
37
|
+
const scope = zoneId == null ? "global" : `zone \`${zoneId}\``;
|
|
38
|
+
// The API stores payouts at 5 decimals; preview what will actually be stored.
|
|
39
|
+
const payout = Math.round(args.payout * 100000) / 100000;
|
|
40
|
+
const existing = findPayoutRule(await fetchPayoutRules(client, campaignId), zoneId);
|
|
41
|
+
const changes = buildChanges(existing, payout);
|
|
42
|
+
if (existing && changes.length === 0) {
|
|
43
|
+
return textResult(`Nothing to change — ${scope} payout on campaign \`${campaignId}\` ` +
|
|
44
|
+
`is already ${moneyPrecise(payout)}.`);
|
|
45
|
+
}
|
|
46
|
+
if (!args.confirm) {
|
|
47
|
+
const head = existing
|
|
48
|
+
? `**Dry run** — would replace ${scope} payout on campaign \`${campaignId}\`.`
|
|
49
|
+
: `**Dry run** — would create ${scope} payout on campaign \`${campaignId}\`.`;
|
|
50
|
+
return textResult([
|
|
51
|
+
head,
|
|
52
|
+
"",
|
|
53
|
+
renderDiff(changes),
|
|
54
|
+
...(payout !== args.payout
|
|
55
|
+
? ["", `_Rounded ${args.payout} → ${payout} (the API stores 5 decimals)._`]
|
|
56
|
+
: []),
|
|
57
|
+
"",
|
|
58
|
+
"Call again with `confirm: true` to apply.",
|
|
59
|
+
].join("\n"));
|
|
60
|
+
}
|
|
61
|
+
if (!existing) {
|
|
62
|
+
const created = await createPayoutRule(client, campaignId, zoneId, payout);
|
|
63
|
+
return textResult([
|
|
64
|
+
`✅ Created ${scope} payout on campaign \`${campaignId}\` (rule id \`${created.id}\`).`,
|
|
65
|
+
"",
|
|
66
|
+
renderDiff(changes),
|
|
67
|
+
].join("\n"));
|
|
68
|
+
}
|
|
69
|
+
const result = await replacePayout(client, campaignId, zoneId, existing, payout);
|
|
70
|
+
if ("lost" in result) {
|
|
71
|
+
return textError([
|
|
72
|
+
`❌ Campaign \`${campaignId}\` now has **no ${scope} payout rule** — every ` +
|
|
73
|
+
"conversion on that scope resolves to $0 until one is set.",
|
|
74
|
+
"",
|
|
75
|
+
`The old rule (${moneyPrecise(result.lost.payout)}) was deleted, the new payout ` +
|
|
76
|
+
"failed to save, and restoring the old one failed too.",
|
|
77
|
+
"",
|
|
78
|
+
`- new payout: ${describeCause(result.cause)}`,
|
|
79
|
+
`- restore: ${describeCause(result.rollbackCause)}`,
|
|
80
|
+
"",
|
|
81
|
+
"Re-run this tool with `confirm: true` to set the payout again.",
|
|
82
|
+
].join("\n"));
|
|
83
|
+
}
|
|
84
|
+
if ("rolledBack" in result) {
|
|
85
|
+
return textError([
|
|
86
|
+
`❌ Could not set ${scope} payout on campaign \`${campaignId}\`: ` +
|
|
87
|
+
describeCause(result.cause),
|
|
88
|
+
"",
|
|
89
|
+
`The previous payout (${moneyPrecise(result.rolledBack.payout)}) was restored — ` +
|
|
90
|
+
"nothing is serving at $0.",
|
|
91
|
+
].join("\n"));
|
|
92
|
+
}
|
|
93
|
+
return textResult([
|
|
94
|
+
`✅ Replaced ${scope} payout on campaign \`${campaignId}\` (rule id \`${result.rule.id}\`).`,
|
|
95
|
+
"",
|
|
96
|
+
renderDiff(changes),
|
|
97
|
+
].join("\n"));
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
101
|
+
return textError(`Campaign \`${String(args.campaign_id).trim()}\` not found (or zone does not exist).`);
|
|
102
|
+
}
|
|
103
|
+
return errorResult(err);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function describeCause(err) {
|
|
107
|
+
if (err instanceof AffsetApiError)
|
|
108
|
+
return `affset API error (${err.status}): ${err.message}`;
|
|
109
|
+
return err instanceof Error ? err.message : String(err);
|
|
110
|
+
}
|
|
111
|
+
function buildChanges(existing, payout) {
|
|
112
|
+
if (!existing) {
|
|
113
|
+
return [{ field: "payout", from: displayValue(null), to: moneyPrecise(payout) }];
|
|
114
|
+
}
|
|
115
|
+
if (existing.payout === payout)
|
|
116
|
+
return [];
|
|
117
|
+
return [{ field: "payout", from: moneyPrecise(existing.payout), to: moneyPrecise(payout) }];
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=setPayoutRule.js.map
|