@laburen/openclaw-plugin-whatsapp-api 0.0.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laburen/openclaw-plugin-whatsapp-api",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "WhatsApp API channel plugin for OpenClaw",
6
6
  "files": [
package/src/accounts.ts CHANGED
@@ -9,6 +9,8 @@ const DEFAULT_MAX_RETRIES = 2;
9
9
  const DEFAULT_RETRY_BACKOFF_MS = 500;
10
10
  const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000;
11
11
  const DEFAULT_MAX_BODY_BYTES = 512 * 1024;
12
+ const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
13
+ const DEFAULT_MEDIA_REQUEST_TIMEOUT_MS = 15_000;
12
14
 
13
15
  function getChannelConfig(cfg: Record<string, unknown>): Record<string, unknown> {
14
16
  const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
@@ -52,6 +54,9 @@ export function resolveAccount(
52
54
  inboundSharedSecret:
53
55
  (accountCfg.inboundSharedSecret as string | undefined) ??
54
56
  (channelCfg.inboundSharedSecret as string | undefined),
57
+ inboundAccessToken:
58
+ (accountCfg.inboundAccessToken as string | undefined) ??
59
+ (channelCfg.inboundAccessToken as string | undefined),
55
60
  outboundPhoneNumberId:
56
61
  (accountCfg.outboundPhoneNumberId as string | undefined) ??
57
62
  (channelCfg.outboundPhoneNumberId as string | undefined),
@@ -82,5 +87,16 @@ export function resolveAccount(
82
87
  (accountCfg.maxBodyBytes as number | undefined) ??
83
88
  (channelCfg.maxBodyBytes as number | undefined) ??
84
89
  DEFAULT_MAX_BODY_BYTES,
90
+ mediaMaxBytes:
91
+ (accountCfg.mediaMaxBytes as number | undefined) ??
92
+ (channelCfg.mediaMaxBytes as number | undefined) ??
93
+ DEFAULT_MEDIA_MAX_BYTES,
94
+ mediaRequestTimeoutMs:
95
+ (accountCfg.mediaRequestTimeoutMs as number | undefined) ??
96
+ (channelCfg.mediaRequestTimeoutMs as number | undefined) ??
97
+ DEFAULT_MEDIA_REQUEST_TIMEOUT_MS,
98
+ mediaTempDir:
99
+ (accountCfg.mediaTempDir as string | undefined) ??
100
+ (channelCfg.mediaTempDir as string | undefined),
85
101
  };
86
102
  }
package/src/channel.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { listAccountIds, resolveAccount } from "./accounts.js";
3
- import { sendWhatsAppApiText, normalizeReplyText } from "./outbound.js";
3
+ import { sendWhatsAppApiReplyPayload, sendWhatsAppApiText } from "./outbound.js";
4
4
  import { getPluginRuntime } from "./runtime.js";
5
+ import { downloadInboundMediaAttachment } from "./utils/media/inbound.js";
5
6
  import { handleWhatsAppApiWebhook } from "./webhook.js";
6
7
  import type { WhatsAppApiInboundMessage } from "./types.js";
7
8
 
@@ -44,12 +45,13 @@ function buildInboundBody(rt: any, message: WhatsAppApiInboundMessage): string {
44
45
  async function dispatchInboundToAgent(params: {
45
46
  accountId: string;
46
47
  message: WhatsAppApiInboundMessage;
47
- log?: { info?: (msg: string) => void; error?: (msg: string) => void };
48
+ log?: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void };
48
49
  onOutbound?: () => void;
49
50
  }): Promise<void> {
50
51
  const { accountId, message, log, onOutbound } = params;
51
52
  const rt = getPluginRuntime() as any;
52
53
  const cfg = await rt.config.loadConfig();
54
+ const account = resolveAccount(cfg, accountId);
53
55
  const route = rt.channel.routing.resolveAgentRoute({
54
56
  cfg,
55
57
  channel: CHANNEL_ID,
@@ -61,6 +63,11 @@ async function dispatchInboundToAgent(params: {
61
63
  });
62
64
  const body = buildInboundBody(rt, message);
63
65
  const to = `whatsapp-api:${message.from}`;
66
+ const mediaAttachment = await downloadInboundMediaAttachment({
67
+ account,
68
+ message,
69
+ log: params.log,
70
+ });
64
71
  const ctxPayload = rt.channel.reply.finalizeInboundContext({
65
72
  Body: body,
66
73
  BodyForAgent: message.text,
@@ -78,6 +85,10 @@ async function dispatchInboundToAgent(params: {
78
85
  Surface: CHANNEL_ID,
79
86
  MessageSid: message.messageId,
80
87
  ReplyToId: message.replyToId,
88
+ ReplyToBody: message.replyToBody,
89
+ ReplyToSender: message.replyToSender,
90
+ MediaPath: mediaAttachment.mediaPath,
91
+ MediaType: mediaAttachment.mediaType,
81
92
  OriginatingChannel: CHANNEL_ID,
82
93
  OriginatingTo: to,
83
94
  });
@@ -87,24 +98,20 @@ async function dispatchInboundToAgent(params: {
87
98
  cfg,
88
99
  dispatcherOptions: {
89
100
  deliver: async (payload: unknown) => {
90
- const text = normalizeReplyText(payload);
91
- if (!text) {
92
- log?.info?.(
93
- `[${CHANNEL_ID}] skipping outbound: reply text empty for messageId=${message.messageId}`,
94
- );
95
- return;
96
- }
97
- log?.info?.(
98
- `[${CHANNEL_ID}] sending reply to ${message.from}, length=${text.length}`,
99
- );
100
101
  const account = resolveAccount(cfg, accountId);
101
- const messageId = await sendWhatsAppApiText({
102
+ const sent = await sendWhatsAppApiReplyPayload({
102
103
  to: message.from,
103
- text,
104
+ payload,
104
105
  account,
105
106
  });
107
+ if (sent.length === 0) {
108
+ log?.info?.(
109
+ `[${CHANNEL_ID}] skipping outbound: reply payload empty for messageId=${message.messageId}`,
110
+ );
111
+ return;
112
+ }
106
113
  log?.info?.(
107
- `[${CHANNEL_ID}] reply sent to ${message.from}, messageId=${messageId}`,
114
+ `[${CHANNEL_ID}] sent ${sent.length} outbound message(s) to ${message.from} for inbound=${message.messageId}`,
108
115
  );
109
116
  onOutbound?.();
110
117
  },
@@ -131,7 +138,7 @@ export function createWhatsAppApiChannel(api: OpenClawPluginApi) {
131
138
  },
132
139
  capabilities: {
133
140
  chatTypes: ["direct" as const],
134
- media: false,
141
+ media: true,
135
142
  threads: false,
136
143
  reactions: false,
137
144
  edit: false,
package/src/inbound.ts CHANGED
@@ -1,35 +1,132 @@
1
- import type { WhatsAppApiInboundMessage } from "./types.js";
1
+ import type {
2
+ WhatsAppApiInboundContact,
3
+ WhatsAppApiInboundLocation,
4
+ WhatsAppApiInboundMessage,
5
+ } from "./types.js";
6
+ import { asArray, asRecord, asTrimmedString } from "./utils/common.js";
2
7
 
3
- function asRecord(value: unknown): Record<string, unknown> | null {
4
- if (!value || typeof value !== "object" || Array.isArray(value)) {
5
- return null;
6
- }
7
- return value as Record<string, unknown>;
8
- }
8
+ function readTextPayload(msg: Record<string, unknown>): {
9
+ text: string;
10
+ media?: WhatsAppApiInboundMessage["media"];
11
+ location?: WhatsAppApiInboundLocation;
12
+ contacts?: WhatsAppApiInboundContact[];
13
+ } {
14
+ const readLocationText = (location: WhatsAppApiInboundLocation): string => {
15
+ const coord = `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
16
+ const parts = [location.name, location.address].filter(
17
+ (value): value is string => typeof value === "string" && Boolean(value.trim()),
18
+ );
19
+ const suffix = parts.length > 0 ? `; ${parts.join(" - ")}` : "";
20
+ return `<location: ${coord}${suffix}>`;
21
+ };
9
22
 
10
- function asArray(value: unknown): unknown[] {
11
- return Array.isArray(value) ? value : [];
12
- }
23
+ const readContactLabel = (contact: WhatsAppApiInboundContact): string | undefined => {
24
+ const primaryPhone = contact.phones[0];
25
+ const fields = [contact.name, primaryPhone].filter(
26
+ (value): value is string => typeof value === "string" && Boolean(value.trim()),
27
+ );
28
+ return fields.length > 0 ? fields.join(", ") : undefined;
29
+ };
30
+
31
+ const readContactsText = (contacts: WhatsAppApiInboundContact[]): string => {
32
+ if (contacts.length === 0) {
33
+ return "<contacts>";
34
+ }
35
+ if (contacts.length === 1) {
36
+ const label = readContactLabel(contacts[0]);
37
+ return label ? `<contact: ${label}>` : "<contact>";
38
+ }
39
+ const labels = contacts
40
+ .map((contact) => readContactLabel(contact))
41
+ .filter((value): value is string => Boolean(value));
42
+ if (labels.length === 0) {
43
+ return `<contacts: ${contacts.length} contacts>`;
44
+ }
45
+ const shown = labels.slice(0, 3);
46
+ const remaining = Math.max(contacts.length - shown.length, 0);
47
+ const suffix = remaining > 0 ? ` (+${remaining} more)` : "";
48
+ return `<contacts: ${shown.join(", ")}${suffix}>`;
49
+ };
13
50
 
14
- function readTextPayload(msg: Record<string, unknown>): string {
15
51
  const msgType = typeof msg.type === "string" ? msg.type : "";
16
52
  if (msgType === "text") {
17
53
  const text = asRecord(msg.text);
18
- return typeof text?.body === "string" ? text.body.trim() : "";
54
+ return {
55
+ text: typeof text?.body === "string" ? text.body.trim() : "",
56
+ };
57
+ }
58
+
59
+ if (
60
+ msgType === "image" ||
61
+ msgType === "video" ||
62
+ msgType === "audio" ||
63
+ msgType === "document" ||
64
+ msgType === "sticker"
65
+ ) {
66
+ const rawMedia = asRecord(msg[msgType]);
67
+ const mediaId = asTrimmedString(rawMedia?.id);
68
+ const mimeType = asTrimmedString(rawMedia?.mime_type);
69
+ const fileName = asTrimmedString(rawMedia?.filename);
70
+ const caption = asTrimmedString(rawMedia?.caption);
71
+ const placeholder = `<media:${msgType}>`;
72
+ return {
73
+ text: caption ? `${caption}\n\n${placeholder}` : placeholder,
74
+ media: mediaId
75
+ ? {
76
+ id: mediaId,
77
+ kind: msgType,
78
+ mimeType,
79
+ fileName,
80
+ }
81
+ : undefined,
82
+ };
83
+ }
84
+ if (msgType === "location") {
85
+ const rawLocation = asRecord(msg.location);
86
+ const latitude = Number(rawLocation?.latitude);
87
+ const longitude = Number(rawLocation?.longitude);
88
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
89
+ return { text: "<location>" };
90
+ }
91
+ const location: WhatsAppApiInboundLocation = {
92
+ latitude,
93
+ longitude,
94
+ name: asTrimmedString(rawLocation?.name),
95
+ address: asTrimmedString(rawLocation?.address),
96
+ url: asTrimmedString(rawLocation?.url),
97
+ };
98
+ return {
99
+ text: readLocationText(location),
100
+ location,
101
+ };
102
+ }
103
+ if (msgType === "contacts") {
104
+ const contacts: WhatsAppApiInboundContact[] = [];
105
+ for (const contactRaw of asArray(msg.contacts)) {
106
+ const contact = asRecord(contactRaw);
107
+ if (!contact) {
108
+ continue;
109
+ }
110
+ const nameRecord = asRecord(contact.name);
111
+ const name =
112
+ asTrimmedString(nameRecord?.formatted_name) ??
113
+ asTrimmedString(nameRecord?.first_name) ??
114
+ asTrimmedString(nameRecord?.last_name);
115
+ const phones = asArray(contact.phones)
116
+ .map((phoneRaw) => asTrimmedString(asRecord(phoneRaw)?.phone))
117
+ .filter((value): value is string => Boolean(value));
118
+ contacts.push({ name, phones });
119
+ }
120
+ return {
121
+ text: readContactsText(contacts),
122
+ contacts,
123
+ };
19
124
  }
125
+ if (msgType === "reaction") return { text: "<reaction>" };
126
+ if (msgType === "button") return { text: "<button>" };
127
+ if (msgType === "interactive") return { text: "<interactive>" };
20
128
 
21
- if (msgType === "image") return "<media:image>";
22
- if (msgType === "video") return "<media:video>";
23
- if (msgType === "audio") return "<media:audio>";
24
- if (msgType === "document") return "<media:document>";
25
- if (msgType === "sticker") return "<media:sticker>";
26
- if (msgType === "location") return "<location>";
27
- if (msgType === "contacts") return "<contacts>";
28
- if (msgType === "reaction") return "<reaction>";
29
- if (msgType === "button") return "<button>";
30
- if (msgType === "interactive") return "<interactive>";
31
-
32
- return "";
129
+ return { text: "" };
33
130
  }
34
131
 
35
132
  export function parseWhatsAppCloudInbound(params: {
@@ -96,13 +193,18 @@ export function parseWhatsAppCloudInbound(params: {
96
193
  continue;
97
194
  }
98
195
 
99
- const text = readTextPayload(msg);
100
- if (!text) {
196
+ const parsed = readTextPayload(msg);
197
+ if (!parsed.text) {
101
198
  continue;
102
199
  }
103
200
 
104
201
  const context = asRecord(msg.context);
105
- const replyToId = typeof context?.id === "string" ? context.id.trim() : undefined;
202
+ const replyToId = asTrimmedString(context?.id) ?? asTrimmedString(context?.message_id);
203
+ const replyToSender = asTrimmedString(context?.from);
204
+ const replyToBody =
205
+ asTrimmedString(context?.body) ??
206
+ asTrimmedString(asRecord(context?.text)?.body) ??
207
+ asTrimmedString(asRecord(context?.quoted_message)?.body);
106
208
  const timestampRaw = typeof msg.timestamp === "string" ? Number(msg.timestamp) : NaN;
107
209
  const timestamp =
108
210
  Number.isFinite(timestampRaw) && timestampRaw > 0 ? timestampRaw * 1000 : undefined;
@@ -112,11 +214,16 @@ export function parseWhatsAppCloudInbound(params: {
112
214
  from,
113
215
  to,
114
216
  messageId,
115
- text,
217
+ text: parsed.text,
116
218
  chatType: "direct",
117
219
  timestamp,
118
220
  senderName: contactNames.get(from),
119
221
  replyToId,
222
+ replyToBody,
223
+ replyToSender,
224
+ media: parsed.media,
225
+ location: parsed.location,
226
+ contacts: parsed.contacts,
120
227
  });
121
228
  }
122
229
  }
package/src/outbound.ts CHANGED
@@ -2,6 +2,9 @@ import type {
2
2
  ResolvedWhatsAppApiAccount,
3
3
  WhatsAppApiSendTextParams,
4
4
  } from "./types.js";
5
+ import { readFile } from "node:fs/promises";
6
+ import { mediaKindFromMimeOrName, mimeFromFileName } from "./utils/media/mime.js";
7
+ import { normalizeOutboundPayload } from "./utils/outbound/payload.js";
5
8
 
6
9
  function sleep(ms: number): Promise<void> {
7
10
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -41,42 +44,48 @@ async function withTimeout(input: RequestInfo | URL, init: RequestInit, timeoutM
41
44
  }
42
45
  }
43
46
 
44
- export async function sendWhatsAppApiText(params: WhatsAppApiSendTextParams): Promise<string> {
45
- const account = params.account;
47
+ function readRetryAfterMs(res: Response): number | null {
48
+ const value = res.headers.get("retry-after");
49
+ if (!value) {
50
+ return null;
51
+ }
52
+ const seconds = Number(value);
53
+ if (Number.isFinite(seconds) && seconds > 0) {
54
+ return Math.ceil(seconds * 1000);
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function resolveGraphAuth(account: ResolvedWhatsAppApiAccount): { phoneNumberId: string; accessToken: string } {
46
60
  const phoneNumberId = account.outboundPhoneNumberId?.trim();
47
61
  const accessToken = account.outboundAccessToken?.trim();
48
62
  if (!phoneNumberId || !accessToken) {
49
63
  throw new Error("Missing outboundPhoneNumberId/outboundAccessToken in whatsapp-api account config");
50
64
  }
65
+ return { phoneNumberId, accessToken };
66
+ }
51
67
 
52
- const recipient = normalizeRecipient(params.to);
53
- if (!recipient) {
54
- throw new Error("Recipient id is empty");
55
- }
56
-
57
- const endpoint = `https://graph.facebook.com/${account.outboundApiVersion}/${phoneNumberId}/messages`;
58
- const payload = {
59
- messaging_product: "whatsapp",
60
- to: recipient,
61
- type: "text",
62
- text: { body: params.text },
63
- };
64
-
68
+ async function sendWithRetry(params: {
69
+ endpoint: string;
70
+ account: ResolvedWhatsAppApiAccount;
71
+ accessToken: string;
72
+ payload: Record<string, unknown>;
73
+ }): Promise<string> {
65
74
  let lastErr: unknown = null;
66
- const maxAttempts = Math.max(1, account.maxRetries + 1);
75
+ const maxAttempts = Math.max(1, params.account.maxRetries + 1);
67
76
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
68
77
  try {
69
78
  const res = await withTimeout(
70
- endpoint,
79
+ params.endpoint,
71
80
  {
72
81
  method: "POST",
73
82
  headers: {
74
- authorization: `Bearer ${accessToken}`,
83
+ authorization: `Bearer ${params.accessToken}`,
75
84
  "content-type": "application/json",
76
85
  },
77
- body: JSON.stringify(payload),
86
+ body: JSON.stringify(params.payload),
78
87
  },
79
- account.requestTimeoutMs,
88
+ params.account.requestTimeoutMs,
80
89
  );
81
90
  const bodyText = await res.text();
82
91
  if (!res.ok) {
@@ -88,39 +97,294 @@ export async function sendWhatsAppApiText(params: WhatsAppApiSendTextParams): Pr
88
97
  throw err;
89
98
  }
90
99
  lastErr = err;
91
- } else {
92
- const parsed = JSON.parse(bodyText) as { messages?: Array<{ id?: string }> };
93
- const messageId = parsed.messages?.[0]?.id;
94
- return messageId && messageId.trim() ? messageId.trim() : "unknown";
100
+ const retryAfterMs = readRetryAfterMs(res);
101
+ if (attempt < maxAttempts) {
102
+ await sleep(retryAfterMs ?? params.account.retryBackoffMs * attempt);
103
+ }
104
+ continue;
95
105
  }
106
+ const parsed = JSON.parse(bodyText) as { messages?: Array<{ id?: string }> };
107
+ const messageId = parsed.messages?.[0]?.id;
108
+ return messageId && messageId.trim() ? messageId.trim() : "unknown";
96
109
  } catch (err) {
97
110
  lastErr = err;
98
111
  if (attempt >= maxAttempts) {
99
112
  break;
100
113
  }
101
- await sleep(account.retryBackoffMs * attempt);
114
+ await sleep(params.account.retryBackoffMs * attempt);
102
115
  continue;
103
116
  }
117
+ }
118
+
119
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown outbound error"));
120
+ }
104
121
 
105
- if (attempt >= maxAttempts) {
106
- break;
122
+ async function uploadMediaFromLocalPath(params: {
123
+ endpoint: string;
124
+ account: ResolvedWhatsAppApiAccount;
125
+ accessToken: string;
126
+ localPath: string;
127
+ kind: "image" | "video" | "audio" | "document";
128
+ }): Promise<{ id: string; mimeType?: string }> {
129
+ const bytes = await readFile(params.localPath);
130
+ if (bytes.byteLength > params.account.mediaMaxBytes) {
131
+ throw new Error(
132
+ `Local media exceeds mediaMaxBytes (${bytes.byteLength} > ${params.account.mediaMaxBytes})`,
133
+ );
134
+ }
135
+ const fileName = params.localPath.split("/").pop() ?? "media.bin";
136
+ const mimeType = mimeFromFileName(fileName);
137
+ const uploadEndpoint = params.endpoint.replace(/\/messages$/, "/media");
138
+ const maxAttempts = Math.max(1, params.account.maxRetries + 1);
139
+ let lastErr: unknown = null;
140
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
141
+ try {
142
+ const form = new FormData();
143
+ form.append("messaging_product", "whatsapp");
144
+ form.append("type", mimeType ?? "application/octet-stream");
145
+ form.append(
146
+ "file",
147
+ new Blob([bytes], { type: mimeType ?? "application/octet-stream" }),
148
+ fileName,
149
+ );
150
+ const res = await withTimeout(
151
+ uploadEndpoint,
152
+ {
153
+ method: "POST",
154
+ headers: {
155
+ authorization: `Bearer ${params.accessToken}`,
156
+ },
157
+ body: form,
158
+ },
159
+ params.account.requestTimeoutMs,
160
+ );
161
+ const bodyText = await res.text();
162
+ if (!res.ok) {
163
+ const err = new Error(`Meta media upload failed: HTTP ${res.status} ${bodyText}`);
164
+ if (!isRetryableStatus(res.status)) {
165
+ throw err;
166
+ }
167
+ lastErr = err;
168
+ } else {
169
+ const parsed = JSON.parse(bodyText) as { id?: string };
170
+ if (!parsed.id?.trim()) {
171
+ throw new Error("Meta media upload did not return media id");
172
+ }
173
+ return { id: parsed.id.trim(), mimeType };
174
+ }
175
+ } catch (err) {
176
+ lastErr = err;
177
+ }
178
+ if (attempt < maxAttempts) {
179
+ await sleep(params.account.retryBackoffMs * attempt);
107
180
  }
108
- await sleep(account.retryBackoffMs * attempt);
109
181
  }
182
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media upload error"));
183
+ }
110
184
 
111
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown outbound error"));
185
+ function isLocalMediaPath(value: string): boolean {
186
+ return value.startsWith("/") || value.toLowerCase().startsWith("file://");
187
+ }
188
+
189
+ function toLocalPath(value: string): string {
190
+ if (value.toLowerCase().startsWith("file://")) {
191
+ return decodeURIComponent(value.replace(/^file:\/\//i, ""));
192
+ }
193
+ return value;
194
+ }
195
+
196
+ async function sendWhatsAppApiMedia(params: {
197
+ to: string;
198
+ text?: string;
199
+ mediaUrl: string;
200
+ account: ResolvedWhatsAppApiAccount;
201
+ }): Promise<string> {
202
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
203
+ const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
204
+ const kind = mediaKindFromMimeOrName({ fileNameOrUrl: params.mediaUrl });
205
+
206
+ let mediaPayload: Record<string, unknown>;
207
+ if (isLocalMediaPath(params.mediaUrl)) {
208
+ const localPath = toLocalPath(params.mediaUrl);
209
+ const uploaded = await uploadMediaFromLocalPath({
210
+ endpoint,
211
+ account: params.account,
212
+ accessToken,
213
+ localPath,
214
+ kind,
215
+ });
216
+ mediaPayload =
217
+ kind === "document"
218
+ ? { id: uploaded.id, filename: localPath.split("/").pop() ?? "document" }
219
+ : { id: uploaded.id };
220
+ } else {
221
+ mediaPayload =
222
+ kind === "document"
223
+ ? { link: params.mediaUrl, filename: params.mediaUrl.split("/").pop() ?? "document" }
224
+ : { link: params.mediaUrl };
225
+ }
226
+ if (params.text && (kind === "image" || kind === "video" || kind === "document")) {
227
+ mediaPayload.caption = params.text;
228
+ }
229
+
230
+ return await sendWithRetry({
231
+ endpoint,
232
+ account: params.account,
233
+ accessToken,
234
+ payload: {
235
+ messaging_product: "whatsapp",
236
+ to: normalizeRecipient(params.to),
237
+ type: kind,
238
+ [kind]: mediaPayload,
239
+ },
240
+ });
241
+ }
242
+
243
+ async function sendWhatsAppApiLocation(params: {
244
+ to: string;
245
+ location: {
246
+ latitude: number;
247
+ longitude: number;
248
+ name?: string;
249
+ address?: string;
250
+ };
251
+ account: ResolvedWhatsAppApiAccount;
252
+ }): Promise<string> {
253
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
254
+ const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
255
+ return await sendWithRetry({
256
+ endpoint,
257
+ account: params.account,
258
+ accessToken,
259
+ payload: {
260
+ messaging_product: "whatsapp",
261
+ to: normalizeRecipient(params.to),
262
+ type: "location",
263
+ location: {
264
+ latitude: params.location.latitude,
265
+ longitude: params.location.longitude,
266
+ name: params.location.name,
267
+ address: params.location.address,
268
+ },
269
+ },
270
+ });
271
+ }
272
+
273
+ async function sendWhatsAppApiContacts(params: {
274
+ to: string;
275
+ contacts: Array<{
276
+ name: {
277
+ formatted_name: string;
278
+ first_name?: string;
279
+ last_name?: string;
280
+ };
281
+ phones?: Array<{
282
+ phone: string;
283
+ type?: string;
284
+ wa_id?: string;
285
+ }>;
286
+ }>;
287
+ account: ResolvedWhatsAppApiAccount;
288
+ }): Promise<string> {
289
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
290
+ const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
291
+ return await sendWithRetry({
292
+ endpoint,
293
+ account: params.account,
294
+ accessToken,
295
+ payload: {
296
+ messaging_product: "whatsapp",
297
+ to: normalizeRecipient(params.to),
298
+ type: "contacts",
299
+ contacts: params.contacts,
300
+ },
301
+ });
302
+ }
303
+
304
+ export async function sendWhatsAppApiText(params: WhatsAppApiSendTextParams): Promise<string> {
305
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
306
+
307
+ const recipient = normalizeRecipient(params.to);
308
+ if (!recipient) {
309
+ throw new Error("Recipient id is empty");
310
+ }
311
+
312
+ const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
313
+ const payload = {
314
+ messaging_product: "whatsapp",
315
+ to: recipient,
316
+ type: "text",
317
+ text: { body: params.text },
318
+ };
319
+
320
+ return await sendWithRetry({
321
+ endpoint,
322
+ account: params.account,
323
+ accessToken,
324
+ payload,
325
+ });
112
326
  }
113
327
 
114
328
  export function normalizeReplyText(payload: unknown): string {
115
- if (!payload || typeof payload !== "object") {
116
- return "";
117
- }
118
- const asRecord = payload as Record<string, unknown>;
119
- const candidates = [asRecord.text, asRecord.body];
120
- for (const candidate of candidates) {
121
- if (typeof candidate === "string" && candidate.trim()) {
122
- return candidate.trim();
123
- }
329
+ return normalizeOutboundPayload(payload).text;
330
+ }
331
+
332
+ export async function sendWhatsAppApiReplyPayload(params: {
333
+ to: string;
334
+ payload: unknown;
335
+ account: ResolvedWhatsAppApiAccount;
336
+ }): Promise<string[]> {
337
+ const normalized = normalizeOutboundPayload(params.payload);
338
+ if (
339
+ !normalized.text &&
340
+ normalized.mediaUrls.length === 0 &&
341
+ !normalized.location &&
342
+ normalized.contacts.length === 0
343
+ ) {
344
+ return [];
345
+ }
346
+ if (
347
+ normalized.mediaUrls.length === 0 &&
348
+ !normalized.location &&
349
+ normalized.contacts.length === 0
350
+ ) {
351
+ return [await sendWhatsAppApiText({ to: params.to, text: normalized.text, account: params.account })];
352
+ }
353
+
354
+ const sentIds: string[] = [];
355
+ if (normalized.text && normalized.mediaUrls.length === 0) {
356
+ sentIds.push(await sendWhatsAppApiText({ to: params.to, text: normalized.text, account: params.account }));
357
+ }
358
+
359
+ for (const [index, mediaUrl] of normalized.mediaUrls.entries()) {
360
+ const messageId = await sendWhatsAppApiMedia({
361
+ to: params.to,
362
+ mediaUrl,
363
+ text: index === 0 ? normalized.text : undefined,
364
+ account: params.account,
365
+ });
366
+ sentIds.push(messageId);
367
+ }
368
+
369
+ if (normalized.location) {
370
+ sentIds.push(
371
+ await sendWhatsAppApiLocation({
372
+ to: params.to,
373
+ location: normalized.location,
374
+ account: params.account,
375
+ }),
376
+ );
124
377
  }
125
- return "";
378
+
379
+ if (normalized.contacts.length > 0) {
380
+ sentIds.push(
381
+ await sendWhatsAppApiContacts({
382
+ to: params.to,
383
+ contacts: normalized.contacts,
384
+ account: params.account,
385
+ }),
386
+ );
387
+ }
388
+
389
+ return sentIds;
126
390
  }
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@ export type ResolvedWhatsAppApiAccount = {
3
3
  enabled: boolean;
4
4
  webhookPath: string;
5
5
  inboundSharedSecret?: string;
6
+ inboundAccessToken?: string;
6
7
  outboundPhoneNumberId?: string;
7
8
  outboundAccessToken?: string;
8
9
  outboundApiVersion: string;
@@ -11,6 +12,29 @@ export type ResolvedWhatsAppApiAccount = {
11
12
  retryBackoffMs: number;
12
13
  dedupeTtlMs: number;
13
14
  maxBodyBytes: number;
15
+ mediaMaxBytes: number;
16
+ mediaRequestTimeoutMs: number;
17
+ mediaTempDir?: string;
18
+ };
19
+
20
+ export type WhatsAppApiInboundMedia = {
21
+ id: string;
22
+ kind: "image" | "video" | "audio" | "document" | "sticker";
23
+ mimeType?: string;
24
+ fileName?: string;
25
+ };
26
+
27
+ export type WhatsAppApiInboundLocation = {
28
+ latitude: number;
29
+ longitude: number;
30
+ name?: string;
31
+ address?: string;
32
+ url?: string;
33
+ };
34
+
35
+ export type WhatsAppApiInboundContact = {
36
+ name?: string;
37
+ phones: string[];
14
38
  };
15
39
 
16
40
  export type WhatsAppApiInboundMessage = {
@@ -23,6 +47,11 @@ export type WhatsAppApiInboundMessage = {
23
47
  timestamp?: number;
24
48
  senderName?: string;
25
49
  replyToId?: string;
50
+ replyToBody?: string;
51
+ replyToSender?: string;
52
+ media?: WhatsAppApiInboundMedia;
53
+ location?: WhatsAppApiInboundLocation;
54
+ contacts?: WhatsAppApiInboundContact[];
26
55
  };
27
56
 
28
57
  export type WhatsAppApiSendTextParams = {
@@ -0,0 +1,19 @@
1
+ export function asRecord(value: unknown): Record<string, unknown> | null {
2
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3
+ return null;
4
+ }
5
+ return value as Record<string, unknown>;
6
+ }
7
+
8
+ export function asArray(value: unknown): unknown[] {
9
+ return Array.isArray(value) ? value : [];
10
+ }
11
+
12
+ export function asTrimmedString(value: unknown): string | undefined {
13
+ if (typeof value !== "string") {
14
+ return undefined;
15
+ }
16
+ const trimmed = value.trim();
17
+ return trimmed || undefined;
18
+ }
19
+
@@ -0,0 +1,164 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import type { ResolvedWhatsAppApiAccount, WhatsAppApiInboundMessage } from "../../types.js";
5
+ import { asRecord } from "../common.js";
6
+ import { extensionFromMime } from "./mime.js";
7
+
8
+ type LogLike = {
9
+ warn?: (message: string) => void;
10
+ };
11
+
12
+ function sleep(ms: number): Promise<void> {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function isRetryableStatus(status: number): boolean {
17
+ return status === 429 || status >= 500;
18
+ }
19
+
20
+ async function withTimeout(input: RequestInfo | URL, init: RequestInit, timeoutMs: number) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
23
+ try {
24
+ return await fetch(input, {
25
+ ...init,
26
+ signal: controller.signal,
27
+ });
28
+ } finally {
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+
33
+ async function fetchWithRetry(params: {
34
+ account: ResolvedWhatsAppApiAccount;
35
+ url: string;
36
+ init: RequestInit;
37
+ }): Promise<Response> {
38
+ const maxAttempts = Math.max(1, params.account.maxRetries + 1);
39
+ let lastErr: unknown = null;
40
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
41
+ try {
42
+ const res = await withTimeout(
43
+ params.url,
44
+ params.init,
45
+ Math.max(500, params.account.mediaRequestTimeoutMs),
46
+ );
47
+ if (res.ok) {
48
+ return res;
49
+ }
50
+ const body = await res.text();
51
+ const err = new Error(`HTTP ${res.status} ${body}`);
52
+ if (!isRetryableStatus(res.status)) {
53
+ throw err;
54
+ }
55
+ lastErr = err;
56
+ } catch (err) {
57
+ lastErr = err;
58
+ }
59
+ if (attempt < maxAttempts) {
60
+ await sleep(params.account.retryBackoffMs * attempt);
61
+ }
62
+ }
63
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media error"));
64
+ }
65
+
66
+ function resolveAccessToken(account: ResolvedWhatsAppApiAccount): string {
67
+ const token = account.inboundAccessToken?.trim() ?? account.outboundAccessToken?.trim();
68
+ if (!token) {
69
+ throw new Error("Missing inboundAccessToken/outboundAccessToken for media download");
70
+ }
71
+ return token;
72
+ }
73
+
74
+ function resolveMediaStorageDir(account: ResolvedWhatsAppApiAccount): string {
75
+ const root = account.mediaTempDir?.trim() || path.join(tmpdir(), "openclaw-whatsapp-api-media");
76
+ return path.join(root, account.accountId);
77
+ }
78
+
79
+ function sanitizeFileName(base: string): string {
80
+ return base.replace(/[^a-zA-Z0-9._-]/g, "_");
81
+ }
82
+
83
+ export async function downloadInboundMediaAttachment(params: {
84
+ account: ResolvedWhatsAppApiAccount;
85
+ message: WhatsAppApiInboundMessage;
86
+ log?: LogLike;
87
+ }): Promise<{ mediaPath?: string; mediaType?: string }> {
88
+ try {
89
+ const media = params.message.media;
90
+ if (!media?.id) {
91
+ return {};
92
+ }
93
+
94
+ const token = resolveAccessToken(params.account);
95
+ const mediaInfoEndpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${media.id}`;
96
+ let mediaInfoRes: Response;
97
+ try {
98
+ mediaInfoRes = await fetchWithRetry({
99
+ account: params.account,
100
+ url: mediaInfoEndpoint,
101
+ init: {
102
+ method: "GET",
103
+ headers: {
104
+ authorization: `Bearer ${token}`,
105
+ },
106
+ },
107
+ });
108
+ } catch (err) {
109
+ params.log?.warn?.(`[whatsapp-api] media metadata fetch failed for mediaId=${media.id}: ${String(err)}`);
110
+ return {};
111
+ }
112
+
113
+ const metaRaw = (await mediaInfoRes.json()) as unknown;
114
+ const meta = asRecord(metaRaw);
115
+ const mediaUrl = typeof meta?.url === "string" ? meta.url.trim() : "";
116
+ if (!mediaUrl) {
117
+ params.log?.warn?.(`[whatsapp-api] media metadata missing URL for mediaId=${media.id}`);
118
+ return {};
119
+ }
120
+ const mediaType =
121
+ (typeof meta?.mime_type === "string" && meta.mime_type.trim()) || media.mimeType || undefined;
122
+
123
+ let mediaRes: Response;
124
+ try {
125
+ mediaRes = await fetchWithRetry({
126
+ account: params.account,
127
+ url: mediaUrl,
128
+ init: {
129
+ method: "GET",
130
+ headers: {
131
+ authorization: `Bearer ${token}`,
132
+ },
133
+ },
134
+ });
135
+ } catch (err) {
136
+ params.log?.warn?.(`[whatsapp-api] media download failed for mediaId=${media.id}: ${String(err)}`);
137
+ return {};
138
+ }
139
+
140
+ const bytes = new Uint8Array(await mediaRes.arrayBuffer());
141
+ if (bytes.byteLength > params.account.mediaMaxBytes) {
142
+ params.log?.warn?.(
143
+ `[whatsapp-api] media too large for mediaId=${media.id}: ${bytes.byteLength} > ${params.account.mediaMaxBytes}`,
144
+ );
145
+ return {};
146
+ }
147
+
148
+ const dir = resolveMediaStorageDir(params.account);
149
+ await mkdir(dir, { recursive: true });
150
+ const extension = extensionFromMime(mediaType);
151
+ const baseName = sanitizeFileName(media.fileName || `${media.kind}-${media.id}${extension}`);
152
+ const filePath = path.join(dir, `${Date.now()}-${baseName}`);
153
+ await writeFile(filePath, bytes);
154
+ return {
155
+ mediaPath: filePath,
156
+ mediaType,
157
+ };
158
+ } catch (err) {
159
+ params.log?.warn?.(
160
+ `[whatsapp-api] media attachment resolution failed for messageId=${params.message.messageId}: ${String(err)}`,
161
+ );
162
+ return {};
163
+ }
164
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+
3
+ const MIME_BY_EXT: Record<string, string> = {
4
+ ".jpg": "image/jpeg",
5
+ ".jpeg": "image/jpeg",
6
+ ".png": "image/png",
7
+ ".webp": "image/webp",
8
+ ".gif": "image/gif",
9
+ ".mp4": "video/mp4",
10
+ ".mov": "video/quicktime",
11
+ ".webm": "video/webm",
12
+ ".mp3": "audio/mpeg",
13
+ ".wav": "audio/wav",
14
+ ".ogg": "audio/ogg",
15
+ ".opus": "audio/ogg",
16
+ ".m4a": "audio/mp4",
17
+ ".pdf": "application/pdf",
18
+ ".txt": "text/plain",
19
+ ".csv": "text/csv",
20
+ ".json": "application/json",
21
+ ".zip": "application/zip",
22
+ };
23
+
24
+ function normalizeMime(value?: string): string | undefined {
25
+ const trimmed = value?.trim();
26
+ if (!trimmed) {
27
+ return undefined;
28
+ }
29
+ return trimmed.split(";")[0]?.trim().toLowerCase();
30
+ }
31
+
32
+ export function mediaKindFromMimeOrName(params: {
33
+ mimeType?: string;
34
+ fileNameOrUrl?: string;
35
+ }): "image" | "video" | "audio" | "document" {
36
+ const mime = normalizeMime(params.mimeType);
37
+ if (mime?.startsWith("image/")) {
38
+ return "image";
39
+ }
40
+ if (mime?.startsWith("video/")) {
41
+ return "video";
42
+ }
43
+ if (mime?.startsWith("audio/")) {
44
+ return "audio";
45
+ }
46
+
47
+ const ext = path.extname(params.fileNameOrUrl ?? "").toLowerCase();
48
+ const extMime = MIME_BY_EXT[ext];
49
+ if (extMime?.startsWith("image/")) {
50
+ return "image";
51
+ }
52
+ if (extMime?.startsWith("video/")) {
53
+ return "video";
54
+ }
55
+ if (extMime?.startsWith("audio/")) {
56
+ return "audio";
57
+ }
58
+ return "document";
59
+ }
60
+
61
+ export function mimeFromFileName(fileNameOrPath: string): string | undefined {
62
+ const ext = path.extname(fileNameOrPath).toLowerCase();
63
+ return MIME_BY_EXT[ext];
64
+ }
65
+
66
+ export function extensionFromMime(mimeType?: string): string {
67
+ const mime = normalizeMime(mimeType);
68
+ if (!mime) {
69
+ return ".bin";
70
+ }
71
+ const found = Object.entries(MIME_BY_EXT).find(([, value]) => value === mime)?.[0];
72
+ return found ?? ".bin";
73
+ }
74
+
@@ -0,0 +1,173 @@
1
+ export type NormalizedOutboundPayload = {
2
+ text: string;
3
+ mediaUrls: string[];
4
+ location?: {
5
+ latitude: number;
6
+ longitude: number;
7
+ name?: string;
8
+ address?: string;
9
+ };
10
+ contacts: Array<{
11
+ name: {
12
+ formatted_name: string;
13
+ first_name?: string;
14
+ last_name?: string;
15
+ };
16
+ phones?: Array<{
17
+ phone: string;
18
+ type?: string;
19
+ wa_id?: string;
20
+ }>;
21
+ }>;
22
+ };
23
+
24
+ function toTrimmedString(value: unknown): string {
25
+ if (typeof value !== "string") {
26
+ return "";
27
+ }
28
+ return value.trim();
29
+ }
30
+
31
+ function asRecord(value: unknown): Record<string, unknown> | null {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
33
+ return null;
34
+ }
35
+ return value as Record<string, unknown>;
36
+ }
37
+
38
+ function parseNumber(value: unknown): number | undefined {
39
+ const numeric = Number(value);
40
+ return Number.isFinite(numeric) ? numeric : undefined;
41
+ }
42
+
43
+ function getWhatsAppPayload(record: Record<string, unknown>): Record<string, unknown> {
44
+ const whatsapp = asRecord(record.whatsapp) ?? {};
45
+ const channelData = asRecord(record.channelData) ?? {};
46
+ const channelDataWhatsapp = asRecord(channelData.whatsapp) ?? {};
47
+ return {
48
+ ...channelDataWhatsapp,
49
+ ...whatsapp,
50
+ };
51
+ }
52
+
53
+ function normalizeOutboundLocation(record: Record<string, unknown>): NormalizedOutboundPayload["location"] {
54
+ const whatsapp = getWhatsAppPayload(record);
55
+ const raw = asRecord(record.location) ?? asRecord(whatsapp.location);
56
+ if (!raw) {
57
+ return undefined;
58
+ }
59
+ const latitude = parseNumber(raw.latitude ?? raw.lat);
60
+ const longitude = parseNumber(raw.longitude ?? raw.lng ?? raw.lon);
61
+ if (latitude === undefined || longitude === undefined) {
62
+ return undefined;
63
+ }
64
+ return {
65
+ latitude,
66
+ longitude,
67
+ name: toTrimmedString(raw.name || raw.title) || undefined,
68
+ address: toTrimmedString(raw.address) || undefined,
69
+ };
70
+ }
71
+
72
+ function normalizeSingleContact(value: unknown): NormalizedOutboundPayload["contacts"][number] | undefined {
73
+ const raw = asRecord(value);
74
+ if (!raw) {
75
+ return undefined;
76
+ }
77
+ const nameRecord = asRecord(raw.name);
78
+ const firstName = toTrimmedString(nameRecord?.first_name ?? raw.first_name ?? raw.firstName);
79
+ const lastName = toTrimmedString(nameRecord?.last_name ?? raw.last_name ?? raw.lastName);
80
+ const formattedName =
81
+ toTrimmedString(nameRecord?.formatted_name) ||
82
+ toTrimmedString(raw.formatted_name || raw.formattedName || raw.display_name || raw.displayName) ||
83
+ [firstName, lastName].filter(Boolean).join(" ") ||
84
+ toTrimmedString(raw.phone || raw.msisdn);
85
+ if (!formattedName) {
86
+ return undefined;
87
+ }
88
+ const phones = new Set<string>();
89
+ const directPhone = toTrimmedString(raw.phone || raw.msisdn);
90
+ if (directPhone) {
91
+ phones.add(directPhone);
92
+ }
93
+ const rawPhones = Array.isArray(raw.phones) ? raw.phones : [];
94
+ for (const rawPhone of rawPhones) {
95
+ if (typeof rawPhone === "string") {
96
+ const cleaned = toTrimmedString(rawPhone);
97
+ if (cleaned) {
98
+ phones.add(cleaned);
99
+ }
100
+ continue;
101
+ }
102
+ const phoneRecord = asRecord(rawPhone);
103
+ const cleaned = toTrimmedString(phoneRecord?.phone);
104
+ if (cleaned) {
105
+ phones.add(cleaned);
106
+ }
107
+ }
108
+ return {
109
+ name: {
110
+ formatted_name: formattedName,
111
+ first_name: firstName || undefined,
112
+ last_name: lastName || undefined,
113
+ },
114
+ phones:
115
+ phones.size > 0
116
+ ? [...phones].map((phone) => ({
117
+ phone,
118
+ }))
119
+ : undefined,
120
+ };
121
+ }
122
+
123
+ function normalizeOutboundContacts(record: Record<string, unknown>): NormalizedOutboundPayload["contacts"] {
124
+ const whatsapp = getWhatsAppPayload(record);
125
+ const rawContacts =
126
+ (Array.isArray(record.contacts) && record.contacts) ||
127
+ (Array.isArray(whatsapp.contacts) && whatsapp.contacts) ||
128
+ [];
129
+ const contacts: NormalizedOutboundPayload["contacts"] = [];
130
+ for (const rawContact of rawContacts) {
131
+ const normalized = normalizeSingleContact(rawContact);
132
+ if (normalized) {
133
+ contacts.push(normalized);
134
+ }
135
+ }
136
+ const singleContact =
137
+ normalizeSingleContact(record.contact) ??
138
+ normalizeSingleContact(whatsapp.contact);
139
+ if (singleContact) {
140
+ contacts.push(singleContact);
141
+ }
142
+ return contacts;
143
+ }
144
+
145
+ export function normalizeOutboundPayload(payload: unknown): NormalizedOutboundPayload {
146
+ if (!payload || typeof payload !== "object") {
147
+ return { text: "", mediaUrls: [], contacts: [] };
148
+ }
149
+ const record = payload as Record<string, unknown>;
150
+ const text = toTrimmedString(record.text) || toTrimmedString(record.body);
151
+
152
+ const mediaUrls = new Set<string>();
153
+ const mediaUrl = toTrimmedString(record.mediaUrl);
154
+ if (mediaUrl) {
155
+ mediaUrls.add(mediaUrl);
156
+ }
157
+ if (Array.isArray(record.mediaUrls)) {
158
+ for (const value of record.mediaUrls) {
159
+ const url = toTrimmedString(value);
160
+ if (url) {
161
+ mediaUrls.add(url);
162
+ }
163
+ }
164
+ }
165
+
166
+ return {
167
+ text,
168
+ mediaUrls: [...mediaUrls],
169
+ location: normalizeOutboundLocation(record),
170
+ contacts: normalizeOutboundContacts(record),
171
+ };
172
+ }
173
+