@agentforge-io/connectors-meta 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/connector.d.ts +80 -0
- package/dist/connector.js +263 -0
- package/dist/http.d.ts +89 -0
- package/dist/http.js +157 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +71 -0
- package/dist/page.d.ts +164 -0
- package/dist/page.js +139 -0
- package/dist/tools/_shared.d.ts +25 -0
- package/dist/tools/_shared.js +31 -0
- package/dist/tools/facebook-pages.d.ts +41 -0
- package/dist/tools/facebook-pages.js +362 -0
- package/dist/tools/instagram.d.ts +42 -0
- package/dist/tools/instagram.js +389 -0
- package/dist/tools/messenger.d.ts +43 -0
- package/dist/tools/messenger.js +320 -0
- package/dist/tools/whatsapp.d.ts +39 -0
- package/dist/tools/whatsapp.js +242 -0
- package/package.json +24 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Messenger (Page DMs) tools.
|
|
4
|
+
*
|
|
5
|
+
* Four tools covering the MVP scope for a Page-bound respondent:
|
|
6
|
+
*
|
|
7
|
+
* 1. messenger_list_threads — Page inbox, newest first
|
|
8
|
+
* 2. messenger_list_messages — messages in a thread
|
|
9
|
+
* 3. messenger_send_message — send a message (24h window + tags)
|
|
10
|
+
* 4. messenger_mark_seen — sender_action: mark thread as seen
|
|
11
|
+
*
|
|
12
|
+
* Token model:
|
|
13
|
+
* - `ctx.getAccessToken()` returns the PAGE access token persisted at
|
|
14
|
+
* connect-time. Same property as Instagram — the page token
|
|
15
|
+
* authorizes both surfaces.
|
|
16
|
+
* - `ctx.metadata.pageId` is the Page id (NOT the IG user id). Used
|
|
17
|
+
* in every endpoint path here.
|
|
18
|
+
*
|
|
19
|
+
* Why so close to Instagram's shape (but a separate file):
|
|
20
|
+
* - Conversations API is shared (same fields), so the response
|
|
21
|
+
* parsing is similar. But the routing key is different (page id vs
|
|
22
|
+
* IG user id), the platform discriminator is different, and the
|
|
23
|
+
* send-message body has a Messenger-specific `messaging_type` +
|
|
24
|
+
* `tag` set that doesn't exist on Instagram. Trying to share a
|
|
25
|
+
* single send-tool implementation between them would mean a giant
|
|
26
|
+
* conditional on platform; cleaner to fork.
|
|
27
|
+
*
|
|
28
|
+
* 24h messaging window (the Messenger-specific quirk):
|
|
29
|
+
* - Inside 24h of the user's last message → `messaging_type=RESPONSE`
|
|
30
|
+
* (free-form, no tag).
|
|
31
|
+
* - Outside 24h → `messaging_type=MESSAGE_TAG` + an approved `tag`
|
|
32
|
+
* (HUMAN_AGENT, ACCOUNT_UPDATE, CONFIRMED_EVENT_UPDATE,
|
|
33
|
+
* POST_PURCHASE_UPDATE). Sending without a tag returns error
|
|
34
|
+
* code 10 ("App does not have permission for this messaging").
|
|
35
|
+
* - We expose `messagingType` + `tag` in the schema; defaults are
|
|
36
|
+
* RESPONSE/null. The agent can pick MESSAGE_TAG when it knows it's
|
|
37
|
+
* outside the window — the API is authoritative on the actual
|
|
38
|
+
* decision.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.messengerMarkSeenTool = exports.messengerSendMessageTool = exports.messengerListMessagesTool = exports.messengerListThreadsTool = void 0;
|
|
42
|
+
const http_1 = require("../http");
|
|
43
|
+
const _shared_1 = require("./_shared");
|
|
44
|
+
exports.messengerListThreadsTool = {
|
|
45
|
+
definition: {
|
|
46
|
+
name: 'messenger_list_threads',
|
|
47
|
+
description: "List the Facebook Page's Messenger inbox threads, newest-updated " +
|
|
48
|
+
'first. Returns thread id, updated time, senders (PSID + name), ' +
|
|
49
|
+
'unread count, and the latest message preview. Use `cursor` from ' +
|
|
50
|
+
'a previous response to paginate.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
limit: {
|
|
55
|
+
type: 'number',
|
|
56
|
+
description: 'Max threads (1-50, default 20).',
|
|
57
|
+
minimum: 1,
|
|
58
|
+
maximum: 50,
|
|
59
|
+
},
|
|
60
|
+
cursor: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Pagination cursor from a previous call.',
|
|
63
|
+
},
|
|
64
|
+
unreadOnly: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
description: 'When true, filter the results to threads with unread > 0. ' +
|
|
67
|
+
'Default false.',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
build: (ctx) => async (input) => {
|
|
73
|
+
const pageId = (0, _shared_1.resolvePageId)(ctx);
|
|
74
|
+
const accessToken = await ctx.getAccessToken();
|
|
75
|
+
const limit = Math.min(Math.max(Number(input.limit ?? 20), 1), 50);
|
|
76
|
+
const result = await (0, http_1.metaGet)({
|
|
77
|
+
accessToken,
|
|
78
|
+
path: `/${encodeURIComponent(pageId)}/conversations`,
|
|
79
|
+
query: {
|
|
80
|
+
platform: 'messenger',
|
|
81
|
+
fields: 'id,updated_time,unread_count,senders{id,name,email},messages.limit(1){id,message,from{id,name},created_time}',
|
|
82
|
+
limit,
|
|
83
|
+
after: input.cursor,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const threads = result.data.map((t) => ({
|
|
87
|
+
id: t.id,
|
|
88
|
+
updatedAt: t.updated_time,
|
|
89
|
+
unreadCount: t.unread_count ?? 0,
|
|
90
|
+
senders: t.senders?.data?.map((s) => ({
|
|
91
|
+
id: s.id,
|
|
92
|
+
name: s.name,
|
|
93
|
+
email: s.email,
|
|
94
|
+
})) ?? [],
|
|
95
|
+
latestMessage: t.messages?.data?.[0]
|
|
96
|
+
? {
|
|
97
|
+
id: t.messages.data[0].id,
|
|
98
|
+
text: t.messages.data[0].message,
|
|
99
|
+
fromId: t.messages.data[0].from?.id,
|
|
100
|
+
fromName: t.messages.data[0].from?.name,
|
|
101
|
+
createdAt: t.messages.data[0].created_time,
|
|
102
|
+
}
|
|
103
|
+
: null,
|
|
104
|
+
}));
|
|
105
|
+
// Client-side filter when the agent asked for unread-only. We can't
|
|
106
|
+
// push this to the API (Messenger conversations endpoint doesn't
|
|
107
|
+
// expose an `unread_only` query param) so we trim after the fetch.
|
|
108
|
+
const filtered = input.unreadOnly
|
|
109
|
+
? threads.filter((t) => t.unreadCount > 0)
|
|
110
|
+
: threads;
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
threads: filtered,
|
|
113
|
+
pageInfo: {
|
|
114
|
+
nextCursor: result.paging?.cursors?.after,
|
|
115
|
+
hasNextPage: Boolean(result.paging?.next),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
exports.messengerListMessagesTool = {
|
|
121
|
+
definition: {
|
|
122
|
+
name: 'messenger_list_messages',
|
|
123
|
+
description: 'List the messages in a Messenger thread, newest first. Returns ' +
|
|
124
|
+
'message id, text, sender id/name/email, created time, ' +
|
|
125
|
+
'attachment URLs, and event tags (e.g. inbound vs system).',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
threadId: {
|
|
130
|
+
type: 'string',
|
|
131
|
+
description: 'Conversation id from `messenger_list_threads`.',
|
|
132
|
+
},
|
|
133
|
+
limit: {
|
|
134
|
+
type: 'number',
|
|
135
|
+
description: 'Max messages (1-100, default 25).',
|
|
136
|
+
minimum: 1,
|
|
137
|
+
maximum: 100,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ['threadId'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
build: (ctx) => async (input) => {
|
|
144
|
+
if (!input.threadId || typeof input.threadId !== 'string') {
|
|
145
|
+
throw new Error('threadId is required (from messenger_list_threads).');
|
|
146
|
+
}
|
|
147
|
+
const accessToken = await ctx.getAccessToken();
|
|
148
|
+
const limit = Math.min(Math.max(Number(input.limit ?? 25), 1), 100);
|
|
149
|
+
const result = await (0, http_1.metaGet)({
|
|
150
|
+
accessToken,
|
|
151
|
+
path: `/${encodeURIComponent(input.threadId)}`,
|
|
152
|
+
query: {
|
|
153
|
+
fields: `messages.limit(${limit}){id,message,from{id,name,email},to{id,name},created_time,attachments{id,mime_type,name,file_url,image_data,video_data},tags}`,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return JSON.stringify({
|
|
157
|
+
messages: result.messages?.data?.map((m) => ({
|
|
158
|
+
id: m.id,
|
|
159
|
+
text: m.message,
|
|
160
|
+
fromId: m.from?.id,
|
|
161
|
+
fromName: m.from?.name,
|
|
162
|
+
fromEmail: m.from?.email,
|
|
163
|
+
toIds: m.to?.data?.map((t) => t.id),
|
|
164
|
+
createdAt: m.created_time,
|
|
165
|
+
attachments: m.attachments?.data?.map((a) => ({
|
|
166
|
+
id: a.id,
|
|
167
|
+
mimeType: a.mime_type,
|
|
168
|
+
name: a.name,
|
|
169
|
+
url: a.file_url ?? a.image_data?.url ?? a.video_data?.url,
|
|
170
|
+
})) ?? [],
|
|
171
|
+
tags: m.tags?.data?.map((t) => t.name) ?? [],
|
|
172
|
+
})) ?? [],
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
/** Approved Message Tags. Sending outside the 24h window requires
|
|
177
|
+
* ONE of these in the body. Keep this list narrow to the tags Meta
|
|
178
|
+
* actually approves in 2025 — `NON_PROMOTIONAL_SUBSCRIPTION` was
|
|
179
|
+
* retired; using it returns code 10. */
|
|
180
|
+
const MESSAGE_TAGS = [
|
|
181
|
+
'CONFIRMED_EVENT_UPDATE',
|
|
182
|
+
'POST_PURCHASE_UPDATE',
|
|
183
|
+
'ACCOUNT_UPDATE',
|
|
184
|
+
'HUMAN_AGENT',
|
|
185
|
+
];
|
|
186
|
+
exports.messengerSendMessageTool = {
|
|
187
|
+
definition: {
|
|
188
|
+
name: 'messenger_send_message',
|
|
189
|
+
description: 'Send a message to a user on Messenger via the Page. Inside 24h ' +
|
|
190
|
+
'of the user\'s last message, use `messagingType="RESPONSE"` ' +
|
|
191
|
+
"(free-form). Outside 24h, you must pass `messagingType=" +
|
|
192
|
+
'"MESSAGE_TAG"` + a `tag` value (HUMAN_AGENT is the catch-all ' +
|
|
193
|
+
'for live-agent replies — Meta approves it for most pages). ' +
|
|
194
|
+
'Sending outside 24h without a tag returns error code 10.',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
recipientId: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
description: 'Page-scoped Sender ID (PSID) of the recipient. Get it from ' +
|
|
201
|
+
'`messenger_list_threads` senders[].id. Required when ' +
|
|
202
|
+
'`threadId` is not set.',
|
|
203
|
+
},
|
|
204
|
+
threadId: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Conversation id (from `messenger_list_threads`). When set, ' +
|
|
207
|
+
'goes to the same thread without needing the PSID.',
|
|
208
|
+
},
|
|
209
|
+
text: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Message body, ≤2000 chars.',
|
|
212
|
+
maxLength: 2000,
|
|
213
|
+
},
|
|
214
|
+
messagingType: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
enum: ['RESPONSE', 'UPDATE', 'MESSAGE_TAG'],
|
|
217
|
+
description: 'Defaults to `RESPONSE` (inside-24h reply). Use ' +
|
|
218
|
+
'`MESSAGE_TAG` + `tag` outside the window. `UPDATE` is for ' +
|
|
219
|
+
'subscription-style notifications and rarely needed.',
|
|
220
|
+
},
|
|
221
|
+
tag: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
enum: [...MESSAGE_TAGS],
|
|
224
|
+
description: 'Required when `messagingType=MESSAGE_TAG`. Pick the value ' +
|
|
225
|
+
'that matches the message purpose: HUMAN_AGENT for live-' +
|
|
226
|
+
'support replies, ACCOUNT_UPDATE for changes to the user\'s ' +
|
|
227
|
+
'account, CONFIRMED_EVENT_UPDATE for booking/event ' +
|
|
228
|
+
'reminders, POST_PURCHASE_UPDATE for order status.',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
required: ['text'],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
build: (ctx) => async (input) => {
|
|
235
|
+
const pageId = (0, _shared_1.resolvePageId)(ctx);
|
|
236
|
+
const accessToken = await ctx.getAccessToken();
|
|
237
|
+
const text = String(input.text ?? '').trim();
|
|
238
|
+
if (!text)
|
|
239
|
+
throw new Error('text is required and must not be empty.');
|
|
240
|
+
const recipientId = input.recipientId;
|
|
241
|
+
const threadId = input.threadId;
|
|
242
|
+
if (!recipientId && !threadId) {
|
|
243
|
+
throw new Error('Either `recipientId` or `threadId` must be provided.');
|
|
244
|
+
}
|
|
245
|
+
const recipient = threadId
|
|
246
|
+
? { thread_key: threadId }
|
|
247
|
+
: { id: recipientId };
|
|
248
|
+
const messagingType = input.messagingType ?? 'RESPONSE';
|
|
249
|
+
// Fail-fast on the easy-to-miss client-side requirement, before
|
|
250
|
+
// the round-trip. Meta's error message for missing tag is
|
|
251
|
+
// confusing ("App does not have permission") so we explain the
|
|
252
|
+
// real reason here.
|
|
253
|
+
if (messagingType === 'MESSAGE_TAG' && !input.tag) {
|
|
254
|
+
throw new Error('`tag` is required when messagingType=MESSAGE_TAG. Pick one of: ' +
|
|
255
|
+
MESSAGE_TAGS.join(', '));
|
|
256
|
+
}
|
|
257
|
+
if (input.tag && !MESSAGE_TAGS.includes(input.tag)) {
|
|
258
|
+
throw new Error(`Unknown tag "${input.tag}". Supported: ${MESSAGE_TAGS.join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
const result = await (0, http_1.metaPost)({
|
|
261
|
+
accessToken,
|
|
262
|
+
path: `/${encodeURIComponent(pageId)}/messages`,
|
|
263
|
+
body: {
|
|
264
|
+
recipient,
|
|
265
|
+
message: { text },
|
|
266
|
+
messaging_type: messagingType,
|
|
267
|
+
...(input.tag ? { tag: input.tag } : {}),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
messageId: result.message_id,
|
|
272
|
+
recipientId: result.recipient_id,
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
// ─── messenger_mark_seen ────────────────────────────────────────────────
|
|
277
|
+
exports.messengerMarkSeenTool = {
|
|
278
|
+
definition: {
|
|
279
|
+
name: 'messenger_mark_seen',
|
|
280
|
+
description: 'Mark a Messenger thread as seen by the Page. Surfaces the blue ' +
|
|
281
|
+
'"Seen" indicator to the recipient — useful right after the ' +
|
|
282
|
+
'agent reads a thread but before it composes a reply, so the ' +
|
|
283
|
+
"user knows their message landed. Doesn't send any content.",
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {
|
|
287
|
+
recipientId: {
|
|
288
|
+
type: 'string',
|
|
289
|
+
description: 'PSID of the recipient. Required when `threadId` is not set.',
|
|
290
|
+
},
|
|
291
|
+
threadId: {
|
|
292
|
+
type: 'string',
|
|
293
|
+
description: 'Conversation id (from `messenger_list_threads`). Either ' +
|
|
294
|
+
'this or `recipientId` is required.',
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
build: (ctx) => async (input) => {
|
|
300
|
+
const pageId = (0, _shared_1.resolvePageId)(ctx);
|
|
301
|
+
const accessToken = await ctx.getAccessToken();
|
|
302
|
+
const recipientId = input.recipientId;
|
|
303
|
+
const threadId = input.threadId;
|
|
304
|
+
if (!recipientId && !threadId) {
|
|
305
|
+
throw new Error('Either `recipientId` or `threadId` must be provided.');
|
|
306
|
+
}
|
|
307
|
+
const recipient = threadId
|
|
308
|
+
? { thread_key: threadId }
|
|
309
|
+
: { id: recipientId };
|
|
310
|
+
await (0, http_1.metaPost)({
|
|
311
|
+
accessToken,
|
|
312
|
+
path: `/${encodeURIComponent(pageId)}/messages`,
|
|
313
|
+
body: {
|
|
314
|
+
recipient,
|
|
315
|
+
sender_action: 'mark_seen',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
return JSON.stringify({ ok: true });
|
|
319
|
+
},
|
|
320
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp Cloud outbound tools.
|
|
3
|
+
*
|
|
4
|
+
* Two tools — the connector is intentionally minimal because INBOUND
|
|
5
|
+
* lives in the platform's `WhatsAppModule` already (gateway webhook +
|
|
6
|
+
* inbox routing). This connector only adds the ability for an agent to
|
|
7
|
+
* INITIATE or REPLY outbound from a Cloud API phone number.
|
|
8
|
+
*
|
|
9
|
+
* 1. wa_send_text — free-form text inside the 24h window
|
|
10
|
+
* 2. wa_send_template — pre-approved template (any time)
|
|
11
|
+
*
|
|
12
|
+
* Token model:
|
|
13
|
+
* - `ctx.getAccessToken()` returns the WhatsApp-scoped token persisted
|
|
14
|
+
* at connect-time. Same Bearer flow as the rest of the Graph API.
|
|
15
|
+
* - `ctx.metadata.waPhoneNumberId` is the PHONE NUMBER ID (NOT the
|
|
16
|
+
* WABA id, NOT the human-readable phone number). Set by the
|
|
17
|
+
* connect modal when the operator picks a phone from the WABA's
|
|
18
|
+
* `phone_numbers[]` list.
|
|
19
|
+
*
|
|
20
|
+
* The 24h customer-service window:
|
|
21
|
+
* - The Cloud API only accepts free-form text within 24h of the
|
|
22
|
+
* customer's last inbound message. Outside that window, you MUST
|
|
23
|
+
* send a TEMPLATE (pre-approved by Meta) — anything else returns
|
|
24
|
+
* error code 131047 ("Re-engagement message").
|
|
25
|
+
* - We expose this as two separate tools (`wa_send_text` vs
|
|
26
|
+
* `wa_send_template`) instead of one polymorphic tool so the
|
|
27
|
+
* agent's intent reads clearly in the trace + the schemas stay
|
|
28
|
+
* focused. The runtime cost (one more tool slot) is dwarfed by
|
|
29
|
+
* the clarity benefit.
|
|
30
|
+
*
|
|
31
|
+
* Number format:
|
|
32
|
+
* - `to` is the recipient's phone in E.164 WITHOUT the `+` sign.
|
|
33
|
+
* `573001234567` is correct; `+57 300 123 4567` returns code 131009.
|
|
34
|
+
* We strip `+`, spaces, and dashes client-side and reject anything
|
|
35
|
+
* that doesn't match the E.164-without-plus pattern.
|
|
36
|
+
*/
|
|
37
|
+
import type { ConnectorToolFactory } from '@agentforge-io/core';
|
|
38
|
+
export declare const waSendTextTool: ConnectorToolFactory;
|
|
39
|
+
export declare const waSendTemplateTool: ConnectorToolFactory;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WhatsApp Cloud outbound tools.
|
|
4
|
+
*
|
|
5
|
+
* Two tools — the connector is intentionally minimal because INBOUND
|
|
6
|
+
* lives in the platform's `WhatsAppModule` already (gateway webhook +
|
|
7
|
+
* inbox routing). This connector only adds the ability for an agent to
|
|
8
|
+
* INITIATE or REPLY outbound from a Cloud API phone number.
|
|
9
|
+
*
|
|
10
|
+
* 1. wa_send_text — free-form text inside the 24h window
|
|
11
|
+
* 2. wa_send_template — pre-approved template (any time)
|
|
12
|
+
*
|
|
13
|
+
* Token model:
|
|
14
|
+
* - `ctx.getAccessToken()` returns the WhatsApp-scoped token persisted
|
|
15
|
+
* at connect-time. Same Bearer flow as the rest of the Graph API.
|
|
16
|
+
* - `ctx.metadata.waPhoneNumberId` is the PHONE NUMBER ID (NOT the
|
|
17
|
+
* WABA id, NOT the human-readable phone number). Set by the
|
|
18
|
+
* connect modal when the operator picks a phone from the WABA's
|
|
19
|
+
* `phone_numbers[]` list.
|
|
20
|
+
*
|
|
21
|
+
* The 24h customer-service window:
|
|
22
|
+
* - The Cloud API only accepts free-form text within 24h of the
|
|
23
|
+
* customer's last inbound message. Outside that window, you MUST
|
|
24
|
+
* send a TEMPLATE (pre-approved by Meta) — anything else returns
|
|
25
|
+
* error code 131047 ("Re-engagement message").
|
|
26
|
+
* - We expose this as two separate tools (`wa_send_text` vs
|
|
27
|
+
* `wa_send_template`) instead of one polymorphic tool so the
|
|
28
|
+
* agent's intent reads clearly in the trace + the schemas stay
|
|
29
|
+
* focused. The runtime cost (one more tool slot) is dwarfed by
|
|
30
|
+
* the clarity benefit.
|
|
31
|
+
*
|
|
32
|
+
* Number format:
|
|
33
|
+
* - `to` is the recipient's phone in E.164 WITHOUT the `+` sign.
|
|
34
|
+
* `573001234567` is correct; `+57 300 123 4567` returns code 131009.
|
|
35
|
+
* We strip `+`, spaces, and dashes client-side and reject anything
|
|
36
|
+
* that doesn't match the E.164-without-plus pattern.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.waSendTemplateTool = exports.waSendTextTool = void 0;
|
|
40
|
+
const http_1 = require("../http");
|
|
41
|
+
const _shared_1 = require("./_shared");
|
|
42
|
+
/**
|
|
43
|
+
* E.164 without the `+` — digits only, 8–15 length per the ITU spec.
|
|
44
|
+
* Cloud API rejects anything shorter or longer with code 131009.
|
|
45
|
+
*/
|
|
46
|
+
const WA_NUMBER_PATTERN = /^[1-9]\d{7,14}$/;
|
|
47
|
+
function normalizeWaNumber(input) {
|
|
48
|
+
const raw = String(input ?? '');
|
|
49
|
+
const stripped = raw.replace(/[\s\-+()]/g, '');
|
|
50
|
+
if (!WA_NUMBER_PATTERN.test(stripped)) {
|
|
51
|
+
throw new Error(`"${raw}" is not a valid recipient phone number. Expected E.164 ` +
|
|
52
|
+
'digits without the leading "+", e.g. "573001234567" (8-15 ' +
|
|
53
|
+
'digits, first digit must be 1-9).');
|
|
54
|
+
}
|
|
55
|
+
return stripped;
|
|
56
|
+
}
|
|
57
|
+
exports.waSendTextTool = {
|
|
58
|
+
definition: {
|
|
59
|
+
name: 'wa_send_text',
|
|
60
|
+
description: 'Send a free-form text message via the connected WhatsApp Cloud ' +
|
|
61
|
+
'phone number. Only works when the recipient messaged the ' +
|
|
62
|
+
'business within the last 24 hours (the customer-service ' +
|
|
63
|
+
'window). Outside the window, Meta rejects with code 131047 — ' +
|
|
64
|
+
'use `wa_send_template` instead. Returns the WhatsApp message ' +
|
|
65
|
+
'id.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
to: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: "Recipient phone in E.164 digits without the '+' sign " +
|
|
72
|
+
'(e.g. "573001234567"). Spaces, dashes, and a leading + are ' +
|
|
73
|
+
'stripped client-side before sending.',
|
|
74
|
+
},
|
|
75
|
+
text: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Message body, ≤4096 chars (Meta hard limit).',
|
|
78
|
+
maxLength: 4096,
|
|
79
|
+
},
|
|
80
|
+
previewUrl: {
|
|
81
|
+
type: 'boolean',
|
|
82
|
+
description: 'When true and the body contains a URL, WhatsApp renders a ' +
|
|
83
|
+
'link preview card. Default false.',
|
|
84
|
+
},
|
|
85
|
+
replyToMessageId: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
description: 'Optional inbound message id to quote-reply. Surfaces as a ' +
|
|
88
|
+
'quoted bubble in the WhatsApp UI. Get this from the ' +
|
|
89
|
+
"inbound webhook's `messages[].id`.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ['to', 'text'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
build: (ctx) => async (input) => {
|
|
96
|
+
const phoneId = (0, _shared_1.resolveWaPhoneId)(ctx);
|
|
97
|
+
const accessToken = await ctx.getAccessToken();
|
|
98
|
+
const to = normalizeWaNumber(input.to);
|
|
99
|
+
const text = String(input.text ?? '').trim();
|
|
100
|
+
if (!text)
|
|
101
|
+
throw new Error('text is required and must not be empty.');
|
|
102
|
+
const result = await (0, http_1.metaPost)({
|
|
103
|
+
accessToken,
|
|
104
|
+
path: `/${encodeURIComponent(phoneId)}/messages`,
|
|
105
|
+
body: {
|
|
106
|
+
messaging_product: 'whatsapp',
|
|
107
|
+
recipient_type: 'individual',
|
|
108
|
+
to,
|
|
109
|
+
type: 'text',
|
|
110
|
+
text: {
|
|
111
|
+
body: text,
|
|
112
|
+
preview_url: Boolean(input.previewUrl),
|
|
113
|
+
},
|
|
114
|
+
...(input.replyToMessageId
|
|
115
|
+
? {
|
|
116
|
+
context: { message_id: String(input.replyToMessageId) },
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return JSON.stringify({
|
|
122
|
+
messageId: result.messages?.[0]?.id,
|
|
123
|
+
to: result.contacts?.[0]?.wa_id,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
exports.waSendTemplateTool = {
|
|
128
|
+
definition: {
|
|
129
|
+
name: 'wa_send_template',
|
|
130
|
+
description: 'Send a pre-approved WhatsApp template message. Use this OUTSIDE ' +
|
|
131
|
+
"the 24h customer-service window (or to start a new conversation " +
|
|
132
|
+
'with a customer who hasn\'t messaged the business yet). The ' +
|
|
133
|
+
'template must be APPROVED in WhatsApp Business Manager — ' +
|
|
134
|
+
'submitting un-approved names returns code 132001. Pass the ' +
|
|
135
|
+
'template name, language code, and the variables that fill the ' +
|
|
136
|
+
'template\'s `{{1}}`, `{{2}}` placeholders in order.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
to: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: "Recipient phone in E.164 digits without '+' (e.g. '573001234567').",
|
|
143
|
+
},
|
|
144
|
+
templateName: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description: "Approved template name, e.g. 'order_confirmation'. Case " +
|
|
147
|
+
'sensitive — must match exactly what was approved.',
|
|
148
|
+
},
|
|
149
|
+
languageCode: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
description: 'Template language tag, e.g. "es", "en_US", "pt_BR". Must ' +
|
|
152
|
+
'match the language the template was approved in.',
|
|
153
|
+
},
|
|
154
|
+
bodyVariables: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
description: 'Positional text values that fill the template body\'s ' +
|
|
157
|
+
'`{{1}}`, `{{2}}`, ... placeholders. Order matters. Omit ' +
|
|
158
|
+
'when the template has no body variables.',
|
|
159
|
+
items: { type: 'string' },
|
|
160
|
+
},
|
|
161
|
+
headerImageUrl: {
|
|
162
|
+
type: 'string',
|
|
163
|
+
description: 'When the template has an IMAGE header, pass the public ' +
|
|
164
|
+
'URL of the image to render. Omit for text-only templates.',
|
|
165
|
+
},
|
|
166
|
+
components: {
|
|
167
|
+
type: 'array',
|
|
168
|
+
description: 'Advanced: pass the raw `components` array Meta expects ' +
|
|
169
|
+
"when the template uses dynamic buttons, currency, " +
|
|
170
|
+
'date_time, or other rich types. When set, this overrides ' +
|
|
171
|
+
'`bodyVariables` + `headerImageUrl`. Shape is the same as ' +
|
|
172
|
+
"the Cloud API's `template.components`.",
|
|
173
|
+
items: { type: 'object' },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
required: ['to', 'templateName', 'languageCode'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
build: (ctx) => async (input) => {
|
|
180
|
+
const phoneId = (0, _shared_1.resolveWaPhoneId)(ctx);
|
|
181
|
+
const accessToken = await ctx.getAccessToken();
|
|
182
|
+
const to = normalizeWaNumber(input.to);
|
|
183
|
+
const templateName = String(input.templateName ?? '').trim();
|
|
184
|
+
const languageCode = String(input.languageCode ?? '').trim();
|
|
185
|
+
if (!templateName)
|
|
186
|
+
throw new Error('templateName is required.');
|
|
187
|
+
if (!languageCode)
|
|
188
|
+
throw new Error('languageCode is required.');
|
|
189
|
+
// Two ways to build the `components` array:
|
|
190
|
+
// 1. Raw passthrough — when the caller already knows the Meta
|
|
191
|
+
// shape (buttons, currency, date_time), they pass `components`
|
|
192
|
+
// directly and we forward it verbatim.
|
|
193
|
+
// 2. Convenience — when the caller passes `bodyVariables` and/or
|
|
194
|
+
// `headerImageUrl`, we build the components array for them so
|
|
195
|
+
// the common case (text body with positional substitutions +
|
|
196
|
+
// optional image header) doesn't require knowing the schema.
|
|
197
|
+
let components;
|
|
198
|
+
if (Array.isArray(input.components) && input.components.length > 0) {
|
|
199
|
+
components = input.components;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const built = [];
|
|
203
|
+
if (typeof input.headerImageUrl === 'string' && input.headerImageUrl) {
|
|
204
|
+
built.push({
|
|
205
|
+
type: 'header',
|
|
206
|
+
parameters: [
|
|
207
|
+
{ type: 'image', image: { link: input.headerImageUrl } },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(input.bodyVariables) && input.bodyVariables.length > 0) {
|
|
212
|
+
built.push({
|
|
213
|
+
type: 'body',
|
|
214
|
+
parameters: input.bodyVariables.map((v) => ({
|
|
215
|
+
type: 'text',
|
|
216
|
+
text: String(v ?? ''),
|
|
217
|
+
})),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
components = built.length > 0 ? built : undefined;
|
|
221
|
+
}
|
|
222
|
+
const result = await (0, http_1.metaPost)({
|
|
223
|
+
accessToken,
|
|
224
|
+
path: `/${encodeURIComponent(phoneId)}/messages`,
|
|
225
|
+
body: {
|
|
226
|
+
messaging_product: 'whatsapp',
|
|
227
|
+
recipient_type: 'individual',
|
|
228
|
+
to,
|
|
229
|
+
type: 'template',
|
|
230
|
+
template: {
|
|
231
|
+
name: templateName,
|
|
232
|
+
language: { code: languageCode },
|
|
233
|
+
...(components ? { components } : {}),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return JSON.stringify({
|
|
238
|
+
messageId: result.messages?.[0]?.id,
|
|
239
|
+
to: result.contacts?.[0]?.wa_id,
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentforge-io/connectors-meta",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Meta Graph API connectors for AgentForge — Instagram DMs/comments, Messenger Page DMs, Facebook Page posts/comments, WhatsApp Cloud outbound. One OAuth client → four independent connectors so the operator only grants the scopes the surface needs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.build.json",
|
|
13
|
+
"build:watch": "tsc -p tsconfig.build.json --watch",
|
|
14
|
+
"clean": "rm -rf dist *.tgz"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@agentforge-io/core": "^3.3.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@agentforge-io/core": "*",
|
|
21
|
+
"@types/node": "^20.0.0",
|
|
22
|
+
"typescript": "^5.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|