@ararahq/mcp 5.0.0 → 6.0.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.
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { MAX_PAGE_SIZE } from "../config.js";
4
+ import { apiRequest } from "../lib/api.js";
5
+ import { AraraError } from "../lib/errors.js";
6
+ import { resolveRecipient } from "../lib/recipients.js";
7
+ import { campaignDetailSchema, campaignEstimateSchema, campaignListSchema, campaignSchema, pagedTemplatesSchema, } from "../lib/schemas.js";
8
+ import { templateButtonLabels } from "../lib/templates.js";
9
+ import { renderTemplateBody } from "../ui/model/format.js";
10
+ import { uiResourceUri } from "../ui/resources.js";
11
+ import { execute } from "../mcp/result.js";
12
+ import { readOnly, register, write } from "./register.js";
13
+ import { e164Schema, recipientInputSchema, variablesSchema } from "./send.js";
14
+ const MAX_BROADCAST_RECIPIENTS = 1000;
15
+ const MAX_UNRESOLVED_LISTED = 10;
16
+ const DEFAULT_CAMPAIGN_PAGE_SIZE = 20;
17
+ const AB_DEFAULT_SAMPLE_PCT = 20;
18
+ const AB_DEFAULT_SPLIT_PCT = 50;
19
+ const AB_DEFAULT_DECISION_MINUTES = 240;
20
+ const AB_DEFAULT_METRIC = "CLICKED";
21
+ const idSchema = z.string().uuid();
22
+ const recipientEntrySchema = z.union([
23
+ recipientInputSchema,
24
+ z.object({ to: recipientInputSchema, variables: variablesSchema }),
25
+ ]);
26
+ const recipientsSchema = z.array(recipientEntrySchema).min(1).max(MAX_BROADCAST_RECIPIENTS);
27
+ const abTestSchema = z.object({
28
+ variantBTemplateName: z.string().trim().min(1),
29
+ metric: z.enum(["DELIVERED", "READ", "CLICKED", "CONVERTED"]).default(AB_DEFAULT_METRIC),
30
+ samplePct: z.number().int().min(1).max(99).default(AB_DEFAULT_SAMPLE_PCT),
31
+ decisionWindowMinutes: z.number().int().min(1).default(AB_DEFAULT_DECISION_MINUTES),
32
+ autopilot: z.boolean().default(true),
33
+ });
34
+ export const resolveCampaignContacts = async (entries, sharedVariables) => {
35
+ const settled = await Promise.all(entries.map(async (entry) => {
36
+ const to = typeof entry === "string" ? entry : entry.to;
37
+ const variables = typeof entry === "string" ? sharedVariables : entry.variables;
38
+ try {
39
+ const recipient = await resolveRecipient(to);
40
+ return { to: recipient.phone, variables };
41
+ }
42
+ catch {
43
+ return { unresolved: to };
44
+ }
45
+ }));
46
+ const contacts = settled.filter((item) => "to" in item);
47
+ const unresolved = settled
48
+ .filter((item) => "unresolved" in item)
49
+ .map((item) => item.unresolved);
50
+ return { contacts, unresolved };
51
+ };
52
+ const defaultCampaignName = () => `Broadcast ${new Date().toISOString().slice(0, 16).replace("T", " ")}`;
53
+ const buildAbTest = (raw) => {
54
+ if (raw === undefined)
55
+ return undefined;
56
+ const parsed = abTestSchema.parse(raw);
57
+ return { ...parsed, splitPct: AB_DEFAULT_SPLIT_PCT };
58
+ };
59
+ const planBroadcast = async (input) => {
60
+ const entries = recipientsSchema.parse(input.to);
61
+ const shared = variablesSchema.parse(input.variables);
62
+ const { contacts, unresolved } = await resolveCampaignContacts(entries, shared);
63
+ if (contacts.length === 0) {
64
+ throw new AraraError("NO_VALID_RECIPIENTS", `No recipient could be resolved: ${unresolved.join(", ")}.`, 400, false);
65
+ }
66
+ return {
67
+ name: typeof input.name === "string" ? input.name : defaultCampaignName(),
68
+ templateName: z.string().trim().min(1).parse(input.templateName),
69
+ contacts,
70
+ unresolved,
71
+ sender: typeof input.from === "string" ? input.from : undefined,
72
+ scheduledAt: typeof input.scheduledAt === "string" ? input.scheduledAt : undefined,
73
+ abTest: buildAbTest(input.abTest),
74
+ };
75
+ };
76
+ /** Template body and buttons rendered with the first contact's variables. Never blocks a send. */
77
+ const loadTemplatePreview = async (plan) => {
78
+ const fallback = { renderedBody: "", buttons: [], category: "" };
79
+ const params = new URLSearchParams({ name: plan.templateName, size: "1" });
80
+ const templates = await apiRequest(`/v1/templates?${params.toString()}`, {
81
+ schema: pagedTemplatesSchema,
82
+ }).catch(() => []);
83
+ const [template] = templates;
84
+ if (template === undefined)
85
+ return fallback;
86
+ const body = typeof template.bodyPreview === "string" ? template.bodyPreview : "";
87
+ const variables = plan.contacts[0]?.variables ?? [];
88
+ return {
89
+ renderedBody: renderTemplateBody(body, variables),
90
+ buttons: templateButtonLabels(template.structureJson),
91
+ category: template.category,
92
+ };
93
+ };
94
+ const previewRequest = (input) => Object.fromEntries(Object.entries(input).filter(([key, value]) => key !== "dryRun" && value !== undefined));
95
+ const previewBroadcast = async (input) => {
96
+ const plan = await planBroadcast(input);
97
+ const [template, estimate] = await Promise.all([
98
+ loadTemplatePreview(plan),
99
+ apiRequest("/v1/campaigns/estimate", {
100
+ method: "POST",
101
+ body: { templateName: plan.templateName, phones: plan.contacts.map((contact) => contact.to) },
102
+ schema: campaignEstimateSchema,
103
+ retry: false,
104
+ }),
105
+ ]);
106
+ const data = {
107
+ preview: true,
108
+ templateName: plan.templateName,
109
+ renderedBody: template.renderedBody,
110
+ buttons: template.buttons,
111
+ category: template.category || estimate.templateCategory,
112
+ recipients: plan.contacts.length,
113
+ unresolved: plan.unresolved,
114
+ totalCost: estimate.totalCost,
115
+ unitPrice: estimate.unitPrice,
116
+ abTest: plan.abTest,
117
+ scheduledAt: plan.scheduledAt,
118
+ request: previewRequest(input),
119
+ };
120
+ return {
121
+ data,
122
+ message: `Preview only, nothing sent. '${plan.templateName}' would reach ${plan.contacts.length} recipient(s) for ${estimate.totalCost.toFixed(2)}. Call broadcast again with dryRun=false to send.`,
123
+ };
124
+ };
125
+ const sendBroadcast = async (input) => {
126
+ const plan = await planBroadcast(input);
127
+ const idempotencyKey = typeof input.idempotencyKey === "string" ? input.idempotencyKey : randomUUID();
128
+ const [data, template] = await Promise.all([
129
+ apiRequest("/v1/campaigns", {
130
+ method: "POST",
131
+ body: {
132
+ name: plan.name,
133
+ templateName: plan.templateName,
134
+ contacts: plan.contacts,
135
+ ...(plan.sender === undefined ? {} : { sender: plan.sender }),
136
+ ...(plan.scheduledAt === undefined ? {} : { scheduledAt: plan.scheduledAt }),
137
+ ...(plan.abTest === undefined ? {} : { abTest: plan.abTest }),
138
+ },
139
+ schema: campaignSchema,
140
+ idempotencyKey,
141
+ }),
142
+ loadTemplatePreview(plan),
143
+ ]);
144
+ const unresolvedNote = plan.unresolved.length === 0
145
+ ? ""
146
+ : ` ${plan.unresolved.length} recipient(s) not resolved: ${plan.unresolved.slice(0, MAX_UNRESOLVED_LISTED).join(", ")}.`;
147
+ return {
148
+ data: {
149
+ ...data,
150
+ unresolved: plan.unresolved,
151
+ idempotencyKey,
152
+ renderedBody: template.renderedBody,
153
+ buttons: template.buttons,
154
+ },
155
+ message: `Campaign '${data.name}' accepted for ${data.totalMessages} recipient(s), total cost ${data.totalCost.toFixed(2)}.${unresolvedNote} Follow it with campaign_report.`,
156
+ };
157
+ };
158
+ const registerBroadcast = (server) => {
159
+ register(server, "broadcast", "Send an approved template to many people at once (up to 1000) as a campaign. By default it is a dry run: it resolves the audience, renders the message and estimates the cost without sending, so the user can approve. Call again with dryRun=false to send. Each entry in 'to' is a phone or contact name (uses the shared 'variables') or an object {to, variables} for per-person values. Optional 'abTest' runs an A/B test; optional 'scheduledAt' (ISO-8601) schedules instead of sending now. Unresolved recipients are listed, not silently dropped.", {
160
+ templateName: z.string().trim().min(1),
161
+ to: recipientsSchema,
162
+ variables: variablesSchema.default([]),
163
+ name: z.string().trim().min(1).max(255).optional(),
164
+ from: e164Schema.optional(),
165
+ scheduledAt: z.string().datetime().optional(),
166
+ abTest: abTestSchema.optional(),
167
+ dryRun: z.boolean().default(true),
168
+ idempotencyKey: z.string().uuid().optional(),
169
+ }, write, async (input) => execute(async () => input.dryRun === false ? sendBroadcast(input) : previewBroadcast(input)), { ui: uiResourceUri("broadcast") });
170
+ };
171
+ const listCampaigns = async (input) => {
172
+ const page = z.number().int().nonnegative().default(0).parse(input.page);
173
+ const size = z
174
+ .number()
175
+ .int()
176
+ .min(1)
177
+ .max(MAX_PAGE_SIZE)
178
+ .default(DEFAULT_CAMPAIGN_PAGE_SIZE)
179
+ .parse(input.size);
180
+ const params = new URLSearchParams({ page: String(page), size: String(size) });
181
+ if (typeof input.status === "string" && input.status.length > 0) {
182
+ params.set("status", input.status);
183
+ }
184
+ const data = await apiRequest(`/v1/campaigns?${params.toString()}`, {
185
+ schema: campaignListSchema,
186
+ });
187
+ return {
188
+ data: { ...data, pagination: { ...data.pagination, page, size } },
189
+ message: `${data.data.length} campaign(s) loaded. Pass campaignId for the full report.`,
190
+ };
191
+ };
192
+ const getCampaign = async (campaignId) => {
193
+ const data = await apiRequest(`/v1/campaigns/${campaignId}`, { schema: campaignDetailSchema });
194
+ const pct = (count) => data.totalMessages > 0 ? ` (${Math.round((count / data.totalMessages) * 100)}%)` : "";
195
+ const message = [
196
+ `Campaign '${data.name}' [${data.status}] with template ${data.templateName}.`,
197
+ `Sent ${data.sentCount}${pct(data.sentCount)}, delivered ${data.deliveredCount}${pct(data.deliveredCount)}, read ${data.readCount}${pct(data.readCount)}, clicked ${data.clickedCount}${pct(data.clickedCount)}, replied ${data.replyCount}${pct(data.replyCount)}.`,
198
+ `Converted ${data.convertedCount} worth ${data.convertedValue.toFixed(2)}. Cost ${data.totalCost.toFixed(2)}.`,
199
+ ].join(" ");
200
+ return { data, message };
201
+ };
202
+ const registerCampaignReport = (server) => {
203
+ register(server, "campaign_report", "See what came back. Without 'campaignId' it lists recent campaigns (newest first, optional 'status' filter such as COMPLETED, AB_TESTING, SCHEDULED, CANCELED). With 'campaignId' it returns the full report: sent, delivered, read, clicked, replied, converted with value, blocked with reasons, refunds and cost.", {
204
+ campaignId: idSchema.optional(),
205
+ status: z.string().trim().max(40).optional(),
206
+ page: z.number().int().nonnegative().default(0),
207
+ size: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_CAMPAIGN_PAGE_SIZE),
208
+ }, readOnly, async (input) => execute(async () => typeof input.campaignId === "string"
209
+ ? getCampaign(idSchema.parse(input.campaignId))
210
+ : listCampaigns(input)), { ui: uiResourceUri("campaign") });
211
+ };
212
+ export const registerCampaignTools = (server) => {
213
+ registerBroadcast(server);
214
+ registerCampaignReport(server);
215
+ };
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import { apiRequest } from "../lib/api.js";
3
+ import { recipientLabel, resolveRecipient } from "../lib/recipients.js";
4
+ import { contactsBatchSchema, conversationSchema, jsonValueSchema, mutationSchema, } from "../lib/schemas.js";
5
+ import { execute } from "../mcp/result.js";
6
+ import { uiResourceUri } from "../ui/resources.js";
7
+ import { destructive, idempotentWrite, readOnly, register } from "./register.js";
8
+ import { e164Schema, recipientInputSchema } from "./send.js";
9
+ const MAX_CONTACTS_PER_BATCH = 1000;
10
+ const MAX_CONTACT_NAME = 255;
11
+ const MAX_EMAIL = 255;
12
+ const MAX_ERRORS_LISTED = 10;
13
+ const MAX_OPT_OUT_REASON = 80;
14
+ const DEFAULT_CONVERSATION_LIMIT = 30;
15
+ const MAX_CONVERSATION_LIMIT = 100;
16
+ const INBOUND_DIRECTION = "INBOUND";
17
+ const contactSchema = z.object({
18
+ name: z.string().trim().min(1).max(MAX_CONTACT_NAME),
19
+ phone: e164Schema,
20
+ email: z.string().email().max(MAX_EMAIL).optional(),
21
+ attributes: z.record(jsonValueSchema).optional(),
22
+ });
23
+ const contactsSchema = z.array(contactSchema).min(1).max(MAX_CONTACTS_PER_BATCH);
24
+ const registerSaveContacts = (server) => {
25
+ register(server, "save_contacts", "Create or update up to 1000 contacts in one call so you can message people by name. Phone must be E.164 (+5511999998888). Returns created, updated and skipped counts plus per-row errors.", { contacts: contactsSchema }, idempotentWrite, async (input) => execute(async () => {
26
+ const contacts = contactsSchema.parse(input.contacts);
27
+ const data = await apiRequest("/v1/contacts/batch", {
28
+ method: "POST",
29
+ body: contacts,
30
+ schema: contactsBatchSchema,
31
+ retry: false,
32
+ });
33
+ const errors = data.errors.length === 0
34
+ ? ""
35
+ : ` ${data.errors.length} error(s): ${data.errors
36
+ .slice(0, MAX_ERRORS_LISTED)
37
+ .map((item) => `#${item.index} ${item.phone ?? "?"} ${item.reason}`)
38
+ .join("; ")}.`;
39
+ return {
40
+ data,
41
+ message: `Contacts saved: ${data.created} created, ${data.updated} updated, ${data.skipped} skipped.${errors}`,
42
+ };
43
+ }));
44
+ };
45
+ const registerOptOut = (server) => {
46
+ register(server, "opt_out", "Record that a person no longer wants messages from this organization. Idempotent. Use whenever someone replies STOP/PARAR or asks to leave; the backend then blocks every send to them.", { phone: e164Schema, reason: z.string().trim().min(1).max(MAX_OPT_OUT_REASON).optional() }, destructive, async (input) => execute(async () => ({
47
+ data: await apiRequest("/v1/opt-outs", {
48
+ method: "POST",
49
+ body: {
50
+ phone: input.phone,
51
+ ...(typeof input.reason === "string" ? { reason: input.reason } : {}),
52
+ },
53
+ schema: mutationSchema,
54
+ retry: false,
55
+ }),
56
+ message: `Opt-out recorded for ${String(input.phone)}.`,
57
+ })));
58
+ };
59
+ const registerReadConversation = (server) => {
60
+ register(server, "read_conversation", "Read what a person actually wrote: the raw message timeline with one contact, newest first, each line marked as customer or you. Use it to judge a reply before answering with send_whatsapp.", {
61
+ to: recipientInputSchema,
62
+ limit: z
63
+ .number()
64
+ .int()
65
+ .min(1)
66
+ .max(MAX_CONVERSATION_LIMIT)
67
+ .default(DEFAULT_CONVERSATION_LIMIT),
68
+ }, readOnly, async (input) => execute(async () => {
69
+ const recipient = await resolveRecipient(recipientInputSchema.parse(input.to));
70
+ const limit = z.number().int().min(1).max(MAX_CONVERSATION_LIMIT).parse(input.limit);
71
+ const params = new URLSearchParams({ limit: String(limit) });
72
+ const data = await apiRequest(`/v1/contacts/${recipient.phone}/messages?${params.toString()}`, { schema: conversationSchema });
73
+ const inbound = data.messages.filter((item) => item.direction.toUpperCase() === INBOUND_DIRECTION).length;
74
+ return {
75
+ data: { ...data, recipient },
76
+ message: `${data.total} message(s) with ${recipientLabel(recipient)}, ${inbound} from the customer in this page.`,
77
+ };
78
+ }), { ui: uiResourceUri("conversation") });
79
+ };
80
+ export const registerContactTools = (server) => {
81
+ registerSaveContacts(server);
82
+ registerOptOut(server);
83
+ registerReadConversation(server);
84
+ };
@@ -1,284 +1,41 @@
1
- import { randomUUID } from "node:crypto";
2
- import { z } from "zod";
3
- import { MAX_PAGE_SIZE } from "../config.js";
4
1
  import { apiRequest } from "../lib/api.js";
5
- import { AraraError } from "../lib/errors.js";
6
- import { automationSchema, automationsSchema, campaignSchema, coverageSchema, identitySchema, jsonValueSchema, messageSchema, mutationSchema, pagedInboxSchema, pagedMessagesSchema, planSchema, routineSchema, templateSchema, templatesSchema, todaySchema, } from "../lib/schemas.js";
7
- import { execute, toolOutputSchema } from "../mcp/result.js";
8
- const readOnly = {
9
- readOnlyHint: true,
10
- destructiveHint: false,
11
- idempotentHint: true,
12
- openWorldHint: true,
13
- };
14
- const write = {
15
- readOnlyHint: false,
16
- destructiveHint: false,
17
- idempotentHint: false,
18
- openWorldHint: true,
19
- };
20
- const idempotentWrite = { ...write, idempotentHint: true };
21
- const destructive = {
22
- readOnlyHint: false,
23
- destructiveHint: true,
24
- idempotentHint: true,
25
- openWorldHint: true,
26
- };
27
- const idSchema = z.string().uuid();
28
- const e164Schema = z.string().regex(/^\+[1-9]\d{6,14}$/, "Use E.164, for example +5511999999999.");
29
- const routineKeySchema = z.enum(["support", "billing", "scheduling"]);
30
- const routinesResponseSchema = z.object({ data: z.array(routineSchema) });
31
- const register = (server, name, description, inputSchema, annotations, handler) => {
32
- server.registerTool(name, { description, inputSchema, outputSchema: toolOutputSchema, annotations }, handler);
33
- };
2
+ import { balanceSchema, identitySchema, planSchema } from "../lib/schemas.js";
3
+ import { execute } from "../mcp/result.js";
4
+ import { registerCampaignTools } from "./campaigns.js";
5
+ import { registerContactTools } from "./contacts.js";
6
+ import { readOnly, register } from "./register.js";
7
+ import { registerSendTools } from "./send.js";
8
+ import { registerTemplateTools } from "./templates.js";
9
+ export { destructive, idempotentWrite, readOnly, register, write } from "./register.js";
34
10
  export const TOOL_NAMES = [
35
11
  "whoami",
36
- "get_today",
37
- "find_conversations",
38
- "get_conversation",
39
- "reply_to_conversation",
40
- "claim_conversation",
41
- "close_conversation",
42
- "list_automations",
43
- "get_automation",
44
- "prepare_campaign",
45
- "publish_campaign",
46
12
  "send_whatsapp",
47
- "check_message",
48
- "save_contacts",
13
+ "broadcast",
14
+ "campaign_report",
15
+ "check_status",
49
16
  "create_template",
50
- "get_template_status",
17
+ "save_contacts",
51
18
  "opt_out",
19
+ "read_conversation",
52
20
  ];
53
- export const registerAllTools = (server) => {
54
- register(server, "whoami", "Return the authenticated AraraHQ identity and organization plan.", {}, readOnly, async () => execute(async () => {
55
- const [identity, plan] = await Promise.all([
21
+ const registerWhoami = (server) => {
22
+ register(server, "whoami", "Confirm who is authenticated, which organization will send, the current plan and the wallet balance. Call it before the first broadcast of a session.", {}, readOnly, async () => execute(async () => {
23
+ const [identity, plan, balance] = await Promise.all([
56
24
  apiRequest("/auth/me", { schema: identitySchema }),
57
25
  apiRequest("/v1/organizations/me/plan", { schema: planSchema }),
26
+ apiRequest("/dashboard/wallet/balance", { schema: balanceSchema }),
58
27
  ]);
28
+ const planName = typeof plan.current === "string" ? plan.current : "unknown";
59
29
  return {
60
- data: { identity, plan },
61
- message: `Authenticated as ${identity.name} (${identity.email}).`,
62
- };
63
- }));
64
- register(server, "get_today", "Return today's Atendimento queue across support, billing and scheduling.", {}, readOnly, async () => execute(async () => ({
65
- data: await apiRequest("/v1/operation/today", { schema: todaySchema }),
66
- message: "Today's operation loaded.",
67
- })));
68
- register(server, "find_conversations", "Find Atendimento conversations with stable pagination. Use this before selecting a conversation.", {
69
- filter: z.string().max(100).optional(),
70
- page: z.number().int().nonnegative().default(0),
71
- size: z.number().int().min(1).max(MAX_PAGE_SIZE).default(50),
72
- }, readOnly, async (input) => execute(async () => {
73
- const params = new URLSearchParams({ page: String(input.page), size: String(input.size) });
74
- if (typeof input.filter === "string" && input.filter.length > 0)
75
- params.set("filter", input.filter);
76
- const data = await apiRequest(`/v1/operation/inbox?${params.toString()}`, {
77
- schema: pagedInboxSchema,
78
- });
79
- return { data, message: `${data.data.length} conversation(s) loaded.` };
80
- }));
81
- register(server, "get_conversation", "Load a paginated message timeline. Conversation metadata comes from find_conversations.", {
82
- conversationId: idSchema,
83
- page: z.number().int().nonnegative().default(0),
84
- size: z.number().int().min(1).max(MAX_PAGE_SIZE).default(50),
85
- }, readOnly, async (input) => execute(async () => {
86
- const conversationId = idSchema.parse(input.conversationId);
87
- const page = z.number().int().nonnegative().parse(input.page);
88
- const size = z.number().int().min(1).max(MAX_PAGE_SIZE).parse(input.size);
89
- const data = await apiRequest(`/v1/conversations/${conversationId}/messages?page=${page}&size=${size}`, { schema: pagedMessagesSchema });
90
- return {
91
- data: { conversationId, ...data },
92
- message: `${data.data.length} message(s) loaded.`,
93
- };
94
- }));
95
- register(server, "reply_to_conversation", "Reply inside an Atendimento conversation. Accepted means queued, not delivered.", {
96
- conversationId: idSchema,
97
- body: z.string().trim().min(1).max(4096),
98
- }, write, async (input) => execute(async () => ({
99
- data: await apiRequest("/v1/conversations/reply", {
100
- method: "POST",
101
- body: input,
102
- schema: mutationSchema,
103
- retry: false,
104
- }),
105
- message: "Reply accepted and queued for delivery.",
106
- })));
107
- register(server, "claim_conversation", "Assign an unowned conversation to the authenticated operator.", { conversationId: idSchema }, idempotentWrite, async (input) => execute(async () => {
108
- const conversationId = idSchema.parse(input.conversationId);
109
- return {
110
- data: await apiRequest(`/v1/operation/inbox/${conversationId}/claim`, {
111
- method: "POST",
112
- body: {},
113
- schema: mutationSchema,
114
- retry: false,
115
- }),
116
- message: "Conversation claimed.",
117
- };
118
- }));
119
- register(server, "close_conversation", "Close a conversation after its outcome and next step are resolved.", { conversationId: idSchema }, destructive, async (input) => execute(async () => {
120
- const conversationId = idSchema.parse(input.conversationId);
121
- return {
122
- data: await apiRequest(`/v1/conversations/${conversationId}/status`, {
123
- method: "PATCH",
124
- body: { status: "CLOSED" },
125
- schema: mutationSchema,
126
- }),
127
- message: "Conversation closed.",
128
- };
129
- }));
130
- register(server, "list_automations", "List configured automations.", {}, readOnly, async () => execute(async () => {
131
- const data = await apiRequest("/v1/automations", { schema: automationsSchema });
132
- return { data, message: `${data.length} automation(s) loaded.` };
133
- }));
134
- register(server, "get_automation", "Inspect an automation's trigger, steps, cost and status.", { automationId: idSchema }, readOnly, async (input) => execute(async () => {
135
- const automationId = idSchema.parse(input.automationId);
136
- const data = await apiRequest(`/v1/automations/${automationId}`, {
137
- schema: automationSchema,
138
- });
139
- return { data, message: `Automation ${data.name} loaded.` };
140
- }));
141
- register(server, "prepare_campaign", "Load campaign preflight: guidance, published Atendimento routines, approved templates and coverage.", {}, readOnly, async () => execute(async () => {
142
- const [copilot, routines, templates, coverage] = await Promise.all([
143
- apiRequest("/v1/operation/copilot/campaign", { schema: z.record(jsonValueSchema) }),
144
- apiRequest("/v1/operation/routines", { schema: routinesResponseSchema }),
145
- apiRequest("/v1/templates", { schema: templatesSchema }),
146
- apiRequest("/v1/operation/coverage", { schema: coverageSchema }),
147
- ]);
148
- return {
149
- data: {
150
- copilot,
151
- coverage,
152
- publishedRoutines: routines.data.filter((routine) => routine.published),
153
- approvedTemplates: templates.filter((template) => template.providerStatus === "APPROVED" && template.availableForSending),
154
- },
155
- message: "Campaign preflight loaded. Review it before publishing.",
156
- };
157
- }));
158
- const campaignContactSchema = z.object({
159
- to: e164Schema,
160
- variables: z.array(z.string().max(1024)).max(20).default([]),
161
- });
162
- register(server, "publish_campaign", "Publish a template campaign after validating its template and Atendimento destination routine.", {
163
- name: z.string().trim().min(1).max(255),
164
- templateName: z.string().trim().min(1),
165
- routineKey: routineKeySchema,
166
- contacts: z.array(campaignContactSchema).min(1).max(1000),
167
- sender: e164Schema.optional(),
168
- scheduledAt: z.string().datetime().optional(),
169
- idempotencyKey: z.string().uuid().optional(),
170
- }, write, async (input) => execute(async () => {
171
- const [routines, templates] = await Promise.all([
172
- apiRequest("/v1/operation/routines", { schema: routinesResponseSchema }),
173
- apiRequest("/v1/templates", { schema: templatesSchema }),
174
- ]);
175
- const routineKey = routineKeySchema.parse(input.routineKey);
176
- if (!routines.data.some((routine) => routine.key === routineKey && routine.published)) {
177
- throw new AraraError("ROUTINE_NOT_PUBLISHED", `Atendimento routine '${routineKey}' is not published.`, 409, false);
178
- }
179
- if (!templates.some((template) => template.name === input.templateName &&
180
- template.providerStatus === "APPROVED" &&
181
- template.availableForSending)) {
182
- throw new AraraError("TEMPLATE_NOT_AVAILABLE", `Template '${String(input.templateName)}' is not approved and available.`, 409, false);
183
- }
184
- const idempotencyKey = typeof input.idempotencyKey === "string" ? input.idempotencyKey : randomUUID();
185
- const data = await apiRequest("/v1/campaigns", {
186
- method: "POST",
187
- body: {
188
- name: input.name,
189
- templateName: input.templateName,
190
- routineKey,
191
- contacts: input.contacts,
192
- ...(input.sender === undefined ? {} : { sender: input.sender }),
193
- ...(input.scheduledAt === undefined ? {} : { scheduledAt: input.scheduledAt }),
194
- },
195
- schema: campaignSchema,
196
- idempotencyKey,
197
- });
198
- return {
199
- data: { ...data, idempotencyKey },
200
- message: `Campaign '${data.name}' accepted with ${data.totalMessages} message(s).`,
30
+ data: { identity, plan, balance },
31
+ message: `Authenticated as ${identity.name} (${identity.email}) on plan ${planName}.`,
201
32
  };
202
33
  }));
203
- register(server, "send_whatsapp", "Send one WhatsApp text message to an E.164 number. Accepted means queued, not delivered.", {
204
- to: e164Schema,
205
- message: z.string().trim().min(1).max(4096),
206
- from: e164Schema.optional(),
207
- idempotencyKey: z.string().uuid().optional(),
208
- }, write, async (input) => execute(async () => {
209
- const idempotencyKey = typeof input.idempotencyKey === "string" ? input.idempotencyKey : randomUUID();
210
- const data = await apiRequest("/v1/messages", {
211
- method: "POST",
212
- body: {
213
- receiver: input.to,
214
- body: input.message,
215
- type: "text",
216
- ...(input.from === undefined ? {} : { sender: input.from }),
217
- },
218
- schema: messageSchema,
219
- idempotencyKey,
220
- });
221
- return {
222
- data: { ...data, idempotencyKey },
223
- message: `Message ${data.id ?? "accepted"} queued. Use check_message to verify delivery.`,
224
- };
225
- }));
226
- register(server, "check_message", "Check the provider delivery state for a message.", { messageId: idSchema }, readOnly, async (input) => execute(async () => {
227
- const messageId = idSchema.parse(input.messageId);
228
- return {
229
- data: await apiRequest(`/v1/messages/${messageId}`, { schema: mutationSchema }),
230
- message: "Message status loaded.",
231
- };
232
- }));
233
- const contactSchema = z.object({
234
- name: z.string().trim().min(1).max(255),
235
- phone: e164Schema,
236
- email: z.string().email().max(255).optional(),
237
- attributes: z.record(jsonValueSchema).optional(),
238
- });
239
- register(server, "save_contacts", "Create or update contacts in one batch.", { contacts: z.array(contactSchema).min(1).max(1000) }, idempotentWrite, async (input) => execute(async () => ({
240
- data: await apiRequest("/v1/contacts/batch", {
241
- method: "POST",
242
- body: input.contacts,
243
- schema: mutationSchema,
244
- retry: false,
245
- }),
246
- message: "Contact batch processed.",
247
- })));
248
- register(server, "create_template", "Submit a WhatsApp template for Meta approval.", {
249
- name: z
250
- .string()
251
- .regex(/^[a-z0-9_]+$/)
252
- .max(512),
253
- category: z.enum(["MARKETING", "UTILITY", "AUTHENTICATION"]),
254
- body: z.string().trim().min(1).max(4096),
255
- language: z.string().default("pt_BR"),
256
- footer: z.string().max(60).optional(),
257
- }, write, async (input) => execute(async () => ({
258
- data: await apiRequest("/v1/templates", {
259
- method: "POST",
260
- body: input,
261
- schema: templateSchema,
262
- retry: false,
263
- }),
264
- message: "Template submitted for approval.",
265
- })));
266
- register(server, "get_template_status", "Check the Meta approval status for a template.", { templateId: idSchema }, readOnly, async (input) => execute(async () => {
267
- const templateId = idSchema.parse(input.templateId);
268
- return {
269
- data: await apiRequest(`/v1/templates/${templateId}/status`, {
270
- schema: mutationSchema,
271
- }),
272
- message: "Template status loaded.",
273
- };
274
- }));
275
- register(server, "opt_out", "Record a WhatsApp opt-out and suppress future sends.", { phone: e164Schema, reason: z.string().trim().min(1).max(80).optional() }, destructive, async (input) => execute(async () => ({
276
- data: await apiRequest("/v1/opt-outs", {
277
- method: "POST",
278
- body: input,
279
- schema: mutationSchema,
280
- retry: false,
281
- }),
282
- message: "Opt-out recorded.",
283
- })));
34
+ };
35
+ export const registerAllTools = (server) => {
36
+ registerWhoami(server);
37
+ registerSendTools(server);
38
+ registerCampaignTools(server);
39
+ registerTemplateTools(server);
40
+ registerContactTools(server);
284
41
  };