@daolmedo/openclaw-twilio-whatsapp 1.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.
- package/dist/index.d.ts +10 -0
- package/dist/index.js +333 -0
- package/dist/src/accounts.d.ts +11 -0
- package/dist/src/accounts.js +38 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/http-routes.d.ts +2 -0
- package/dist/src/http-routes.js +117 -0
- package/dist/src/outbound-adapter.d.ts +2 -0
- package/dist/src/outbound-adapter.js +31 -0
- package/dist/src/runtime-store.d.ts +9 -0
- package/dist/src/runtime-store.js +10 -0
- package/dist/src/send.d.ts +8 -0
- package/dist/src/send.js +13 -0
- package/dist/src/webhook.d.ts +20 -0
- package/dist/src/webhook.js +28 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +36 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const _default: {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
configSchema: import("openclaw/channels/plugins/types.plugin.js").ChannelConfigSchema;
|
|
6
|
+
register: (api: import("openclaw/plugins/types.js").OpenClawPluginApi) => void;
|
|
7
|
+
channelPlugin: import("openclaw/plugin-sdk/channel-core.js").ChannelPlugin<import("./src/accounts.js").ResolvedTwilioAccount>;
|
|
8
|
+
setChannelRuntime?: (runtime: import("openclaw/plugin-sdk/channel-core.js").PluginRuntime) => void;
|
|
9
|
+
};
|
|
10
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core.js";
|
|
3
|
+
|
|
4
|
+
// src/accounts.ts
|
|
5
|
+
var getChannelConfig = (cfg) => cfg.channels && cfg.channels["twilio-whatsapp"] ? cfg.channels["twilio-whatsapp"] : void 0;
|
|
6
|
+
function listTwilioAccountIds(cfg) {
|
|
7
|
+
const ch = getChannelConfig(cfg);
|
|
8
|
+
return Object.keys(ch?.accounts ?? {});
|
|
9
|
+
}
|
|
10
|
+
function resolveTwilioAccount(cfg, accountId) {
|
|
11
|
+
const ch = getChannelConfig(cfg);
|
|
12
|
+
const id = accountId ?? "default";
|
|
13
|
+
const entry = ch?.accounts?.[id];
|
|
14
|
+
if (!entry) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`[twilio-whatsapp] No account configured for accountId="${id}". Add channels.twilio-whatsapp.accounts.${id} to openclaw.json.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
accountId: id,
|
|
21
|
+
accountSid: entry.accountSid,
|
|
22
|
+
authToken: entry.authToken,
|
|
23
|
+
phoneNumber: entry.phoneNumber
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function findTwilioAccountByPhoneNumber(cfg, phoneNumber) {
|
|
27
|
+
const ch = getChannelConfig(cfg);
|
|
28
|
+
if (!ch?.accounts) return void 0;
|
|
29
|
+
for (const [id, entry] of Object.entries(ch.accounts)) {
|
|
30
|
+
const normalized = normalizePhoneNumber(entry.phoneNumber);
|
|
31
|
+
if (normalized === normalizePhoneNumber(phoneNumber)) {
|
|
32
|
+
return { accountId: id, ...entry };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
function normalizePhoneNumber(phone) {
|
|
38
|
+
return phone.replace(/^whatsapp:/, "").replace(/\s+/g, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/send.ts
|
|
42
|
+
import twilio from "twilio";
|
|
43
|
+
async function sendTwilioWhatsappMessage(opts) {
|
|
44
|
+
const client = twilio(opts.accountSid, opts.authToken);
|
|
45
|
+
const from = opts.from.startsWith("whatsapp:") ? opts.from : `whatsapp:${opts.from}`;
|
|
46
|
+
const to = opts.to.startsWith("whatsapp:") ? opts.to : `whatsapp:${opts.to}`;
|
|
47
|
+
const msg = await client.messages.create({
|
|
48
|
+
from,
|
|
49
|
+
to,
|
|
50
|
+
...opts.body ? { body: opts.body } : {},
|
|
51
|
+
...opts.mediaUrl ? { mediaUrl: [opts.mediaUrl] } : {}
|
|
52
|
+
});
|
|
53
|
+
return msg.sid;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/outbound-adapter.ts
|
|
57
|
+
async function deliver(ctx) {
|
|
58
|
+
const account = resolveTwilioAccount(ctx.cfg, ctx.accountId);
|
|
59
|
+
const sid = await sendTwilioWhatsappMessage({
|
|
60
|
+
accountSid: account.accountSid,
|
|
61
|
+
authToken: account.authToken,
|
|
62
|
+
from: account.phoneNumber,
|
|
63
|
+
to: ctx.to,
|
|
64
|
+
body: ctx.text || void 0,
|
|
65
|
+
mediaUrl: ctx.mediaUrl
|
|
66
|
+
});
|
|
67
|
+
return sid;
|
|
68
|
+
}
|
|
69
|
+
var twilioWhatsappOutbound = {
|
|
70
|
+
deliveryMode: "direct",
|
|
71
|
+
async sendText(ctx) {
|
|
72
|
+
const messageId = await deliver(ctx);
|
|
73
|
+
return {
|
|
74
|
+
channel: "twilio-whatsapp",
|
|
75
|
+
messageId
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
async sendMedia(ctx) {
|
|
79
|
+
const messageId = await deliver(ctx);
|
|
80
|
+
return {
|
|
81
|
+
channel: "twilio-whatsapp",
|
|
82
|
+
messageId
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// src/runtime-store.ts
|
|
88
|
+
var phoneToRuntime = /* @__PURE__ */ new Map();
|
|
89
|
+
function setRuntimeForPhoneNumber(phoneNumber, runtime) {
|
|
90
|
+
phoneToRuntime.set(phoneNumber, runtime);
|
|
91
|
+
}
|
|
92
|
+
function clearRuntimeForPhoneNumber(phoneNumber) {
|
|
93
|
+
phoneToRuntime.delete(phoneNumber);
|
|
94
|
+
}
|
|
95
|
+
function getRuntimeForPhoneNumber(phoneNumber) {
|
|
96
|
+
return phoneToRuntime.get(phoneNumber);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/channel.ts
|
|
100
|
+
var twilioWhatsappPlugin = {
|
|
101
|
+
id: "twilio-whatsapp",
|
|
102
|
+
meta: {
|
|
103
|
+
id: "twilio-whatsapp",
|
|
104
|
+
label: "Twilio WhatsApp",
|
|
105
|
+
selectionLabel: "WhatsApp (Twilio)",
|
|
106
|
+
docsPath: "/channels/twilio-whatsapp",
|
|
107
|
+
blurb: "WhatsApp messaging via Twilio Business API",
|
|
108
|
+
markdownCapable: false,
|
|
109
|
+
showInSetup: true,
|
|
110
|
+
showConfigured: true
|
|
111
|
+
},
|
|
112
|
+
capabilities: {
|
|
113
|
+
chatTypes: ["direct"],
|
|
114
|
+
media: true
|
|
115
|
+
},
|
|
116
|
+
config: {
|
|
117
|
+
listAccountIds: listTwilioAccountIds,
|
|
118
|
+
resolveAccount(cfg, accountId) {
|
|
119
|
+
return resolveTwilioAccount(cfg, accountId);
|
|
120
|
+
},
|
|
121
|
+
isConfigured(account) {
|
|
122
|
+
return Boolean(account.accountSid && account.authToken && account.phoneNumber);
|
|
123
|
+
},
|
|
124
|
+
describeAccount(account) {
|
|
125
|
+
return {
|
|
126
|
+
accountId: account.accountId,
|
|
127
|
+
configured: Boolean(account.accountSid && account.authToken && account.phoneNumber),
|
|
128
|
+
name: `WhatsApp ${account.phoneNumber}`
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
hasConfiguredState({ cfg }) {
|
|
132
|
+
return listTwilioAccountIds(cfg).length > 0;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
outbound: twilioWhatsappOutbound,
|
|
136
|
+
gateway: {
|
|
137
|
+
async startAccount(ctx) {
|
|
138
|
+
const account = ctx.account;
|
|
139
|
+
const phoneNumber = normalizePhoneNumber(account.phoneNumber);
|
|
140
|
+
if (!ctx.channelRuntime) {
|
|
141
|
+
console.error(
|
|
142
|
+
`[twilio-whatsapp] channelRuntime not provided for account ${ctx.accountId}. Inbound dispatch will not work.`
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
setRuntimeForPhoneNumber(phoneNumber, {
|
|
147
|
+
cfg: ctx.cfg,
|
|
148
|
+
accountId: ctx.accountId,
|
|
149
|
+
channelRuntime: ctx.channelRuntime
|
|
150
|
+
});
|
|
151
|
+
ctx.setStatus({
|
|
152
|
+
accountId: ctx.accountId,
|
|
153
|
+
running: true,
|
|
154
|
+
connected: true,
|
|
155
|
+
configured: true,
|
|
156
|
+
name: `WhatsApp ${account.phoneNumber}`,
|
|
157
|
+
webhookPath: "/twilio/whatsapp/events"
|
|
158
|
+
});
|
|
159
|
+
console.log(
|
|
160
|
+
`[twilio-whatsapp] Account ${ctx.accountId} started, listening on /twilio/whatsapp/events for ${account.phoneNumber}`
|
|
161
|
+
);
|
|
162
|
+
await new Promise((_, reject) => {
|
|
163
|
+
ctx.abortSignal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
164
|
+
}).catch(() => {
|
|
165
|
+
clearRuntimeForPhoneNumber(phoneNumber);
|
|
166
|
+
ctx.setStatus({
|
|
167
|
+
accountId: ctx.accountId,
|
|
168
|
+
running: false,
|
|
169
|
+
connected: false
|
|
170
|
+
});
|
|
171
|
+
console.log(`[twilio-whatsapp] Account ${ctx.accountId} stopped`);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/http-routes.ts
|
|
178
|
+
import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm.js";
|
|
179
|
+
|
|
180
|
+
// src/webhook.ts
|
|
181
|
+
import twilio2 from "twilio";
|
|
182
|
+
function verifyTwilioSignature(params) {
|
|
183
|
+
try {
|
|
184
|
+
const parsed = Object.fromEntries(new URLSearchParams(params.rawBody));
|
|
185
|
+
return twilio2.validateRequest(params.authToken, params.signature, params.url, parsed);
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function parseTwilioWebhook(rawBody) {
|
|
191
|
+
const p = Object.fromEntries(new URLSearchParams(rawBody));
|
|
192
|
+
const numMedia = parseInt(p["NumMedia"] ?? "0", 10);
|
|
193
|
+
const media = Array.from({ length: numMedia }, (_, i) => ({
|
|
194
|
+
url: p[`MediaUrl${i}`] ?? "",
|
|
195
|
+
contentType: p[`MediaContentType${i}`] ?? "application/octet-stream"
|
|
196
|
+
}));
|
|
197
|
+
return {
|
|
198
|
+
from: (p["From"] ?? "").replace("whatsapp:", ""),
|
|
199
|
+
to: (p["To"] ?? "").replace("whatsapp:", ""),
|
|
200
|
+
body: p["Body"] ?? "",
|
|
201
|
+
waId: p["WaId"] ?? "",
|
|
202
|
+
profileName: p["ProfileName"] ?? "",
|
|
203
|
+
messageSid: p["MessageSid"] ?? "",
|
|
204
|
+
media,
|
|
205
|
+
repliedToSid: p["OriginalRepliedMessageSid"]
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/http-routes.ts
|
|
210
|
+
async function readBody(req) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const chunks = [];
|
|
213
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
214
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
215
|
+
req.on("error", reject);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function twimlOk(res) {
|
|
219
|
+
res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
|
|
220
|
+
res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
|
|
221
|
+
}
|
|
222
|
+
function twimlError(res, status, message) {
|
|
223
|
+
res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
|
|
224
|
+
res.end(
|
|
225
|
+
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
function registerTwilioWhatsappHttpRoutes(api) {
|
|
229
|
+
api.registerHttpRoute({
|
|
230
|
+
path: "/twilio/whatsapp/events",
|
|
231
|
+
auth: "plugin",
|
|
232
|
+
handler: async (req, res) => {
|
|
233
|
+
const rawBody = await readBody(req);
|
|
234
|
+
const headers = req.headers;
|
|
235
|
+
const signature = String(headers["x-twilio-signature"] ?? "");
|
|
236
|
+
const webhookUrl = String(headers["x-twilio-webhook-url"] ?? "");
|
|
237
|
+
const fields = parseTwilioWebhook(rawBody);
|
|
238
|
+
const toPhone = normalizePhoneNumber(fields.to);
|
|
239
|
+
const stored = getRuntimeForPhoneNumber(toPhone);
|
|
240
|
+
if (!stored) {
|
|
241
|
+
console.error(`[twilio-whatsapp] No runtime found for To=${fields.to}`);
|
|
242
|
+
twimlError(res, 404, "No agent bound to this number");
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
const account = findTwilioAccountByPhoneNumber(stored.cfg, toPhone);
|
|
246
|
+
if (!account) {
|
|
247
|
+
console.error(`[twilio-whatsapp] Account not found for phone=${toPhone}`);
|
|
248
|
+
twimlError(res, 404, "Account not found");
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (webhookUrl && signature) {
|
|
252
|
+
const valid = verifyTwilioSignature({
|
|
253
|
+
authToken: account.authToken,
|
|
254
|
+
signature,
|
|
255
|
+
url: webhookUrl,
|
|
256
|
+
rawBody
|
|
257
|
+
});
|
|
258
|
+
if (!valid) {
|
|
259
|
+
console.error("[twilio-whatsapp] Invalid Twilio signature");
|
|
260
|
+
twimlError(res, 403, "Forbidden");
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
console.warn("[twilio-whatsapp] Skipping sig verification \u2014 headers missing");
|
|
265
|
+
}
|
|
266
|
+
const fromPhone = normalizePhoneNumber(fields.from);
|
|
267
|
+
const senderAddress = `whatsapp:${fromPhone}`;
|
|
268
|
+
const recipientAddress = `whatsapp:${toPhone}`;
|
|
269
|
+
const conversationLabel = fields.profileName || fromPhone;
|
|
270
|
+
const messageText = fields.body.trim();
|
|
271
|
+
const hasMedia = fields.media.length > 0;
|
|
272
|
+
if (!messageText && !hasMedia) {
|
|
273
|
+
twimlOk(res);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
const bodyForAgent = hasMedia ? [messageText, ...fields.media.map((m) => `[Media: ${m.url}]`)].filter(Boolean).join("\n") : messageText;
|
|
277
|
+
twimlOk(res);
|
|
278
|
+
dispatchInboundDirectDmWithRuntime({
|
|
279
|
+
cfg: stored.cfg,
|
|
280
|
+
runtime: { channel: stored.channelRuntime },
|
|
281
|
+
channel: "twilio-whatsapp",
|
|
282
|
+
channelLabel: "WhatsApp",
|
|
283
|
+
accountId: account.accountId,
|
|
284
|
+
peer: { kind: "direct", id: fromPhone },
|
|
285
|
+
senderId: fromPhone,
|
|
286
|
+
senderAddress,
|
|
287
|
+
recipientAddress,
|
|
288
|
+
conversationLabel,
|
|
289
|
+
rawBody: messageText,
|
|
290
|
+
bodyForAgent,
|
|
291
|
+
messageId: fields.messageSid,
|
|
292
|
+
timestamp: Date.now(),
|
|
293
|
+
commandAuthorized: true,
|
|
294
|
+
deliver: async (payload) => {
|
|
295
|
+
const text = payload.text ?? "";
|
|
296
|
+
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
|
297
|
+
if (!text && !mediaUrl) return;
|
|
298
|
+
await sendTwilioWhatsappMessage({
|
|
299
|
+
accountSid: account.accountSid,
|
|
300
|
+
authToken: account.authToken,
|
|
301
|
+
from: toPhone,
|
|
302
|
+
to: fromPhone,
|
|
303
|
+
body: text || void 0,
|
|
304
|
+
mediaUrl
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
onRecordError: (err) => {
|
|
308
|
+
console.error("[twilio-whatsapp] Session record error:", err);
|
|
309
|
+
},
|
|
310
|
+
onDispatchError: (err, info) => {
|
|
311
|
+
console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
|
|
312
|
+
}
|
|
313
|
+
}).catch((err) => {
|
|
314
|
+
console.error("[twilio-whatsapp] Unhandled dispatch error:", err);
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// index.ts
|
|
322
|
+
var index_default = defineChannelPluginEntry({
|
|
323
|
+
id: "twilio-whatsapp",
|
|
324
|
+
name: "Twilio WhatsApp",
|
|
325
|
+
description: "WhatsApp channel via Twilio Business API (ISV)",
|
|
326
|
+
plugin: twilioWhatsappPlugin,
|
|
327
|
+
registerFull(api) {
|
|
328
|
+
registerTwilioWhatsappHttpRoutes(api);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
export {
|
|
332
|
+
index_default as default
|
|
333
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/channels/plugins/types.js";
|
|
2
|
+
export type ResolvedTwilioAccount = {
|
|
3
|
+
accountId: string;
|
|
4
|
+
accountSid: string;
|
|
5
|
+
authToken: string;
|
|
6
|
+
phoneNumber: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function listTwilioAccountIds(cfg: OpenClawConfig): string[];
|
|
9
|
+
export declare function resolveTwilioAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedTwilioAccount;
|
|
10
|
+
export declare function findTwilioAccountByPhoneNumber(cfg: OpenClawConfig, phoneNumber: string): ResolvedTwilioAccount | undefined;
|
|
11
|
+
export declare function normalizePhoneNumber(phone: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const getChannelConfig = (cfg) => cfg.channels &&
|
|
2
|
+
cfg.channels["twilio-whatsapp"]
|
|
3
|
+
? cfg.channels["twilio-whatsapp"]
|
|
4
|
+
: undefined;
|
|
5
|
+
export function listTwilioAccountIds(cfg) {
|
|
6
|
+
const ch = getChannelConfig(cfg);
|
|
7
|
+
return Object.keys(ch?.accounts ?? {});
|
|
8
|
+
}
|
|
9
|
+
export function resolveTwilioAccount(cfg, accountId) {
|
|
10
|
+
const ch = getChannelConfig(cfg);
|
|
11
|
+
const id = accountId ?? "default";
|
|
12
|
+
const entry = ch?.accounts?.[id];
|
|
13
|
+
if (!entry) {
|
|
14
|
+
throw new Error(`[twilio-whatsapp] No account configured for accountId="${id}". ` +
|
|
15
|
+
`Add channels.twilio-whatsapp.accounts.${id} to openclaw.json.`);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
accountId: id,
|
|
19
|
+
accountSid: entry.accountSid,
|
|
20
|
+
authToken: entry.authToken,
|
|
21
|
+
phoneNumber: entry.phoneNumber,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function findTwilioAccountByPhoneNumber(cfg, phoneNumber) {
|
|
25
|
+
const ch = getChannelConfig(cfg);
|
|
26
|
+
if (!ch?.accounts)
|
|
27
|
+
return undefined;
|
|
28
|
+
for (const [id, entry] of Object.entries(ch.accounts)) {
|
|
29
|
+
const normalized = normalizePhoneNumber(entry.phoneNumber);
|
|
30
|
+
if (normalized === normalizePhoneNumber(phoneNumber)) {
|
|
31
|
+
return { accountId: id, ...entry };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
export function normalizePhoneNumber(phone) {
|
|
37
|
+
return phone.replace(/^whatsapp:/, "").replace(/\s+/g, "");
|
|
38
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { listTwilioAccountIds, normalizePhoneNumber, resolveTwilioAccount, } from "./accounts.js";
|
|
2
|
+
import { twilioWhatsappOutbound } from "./outbound-adapter.js";
|
|
3
|
+
import { clearRuntimeForPhoneNumber, setRuntimeForPhoneNumber } from "./runtime-store.js";
|
|
4
|
+
export const twilioWhatsappPlugin = {
|
|
5
|
+
id: "twilio-whatsapp",
|
|
6
|
+
meta: {
|
|
7
|
+
id: "twilio-whatsapp",
|
|
8
|
+
label: "Twilio WhatsApp",
|
|
9
|
+
selectionLabel: "WhatsApp (Twilio)",
|
|
10
|
+
docsPath: "/channels/twilio-whatsapp",
|
|
11
|
+
blurb: "WhatsApp messaging via Twilio Business API",
|
|
12
|
+
markdownCapable: false,
|
|
13
|
+
showInSetup: true,
|
|
14
|
+
showConfigured: true,
|
|
15
|
+
},
|
|
16
|
+
capabilities: {
|
|
17
|
+
chatTypes: ["direct"],
|
|
18
|
+
media: true,
|
|
19
|
+
},
|
|
20
|
+
config: {
|
|
21
|
+
listAccountIds: listTwilioAccountIds,
|
|
22
|
+
resolveAccount(cfg, accountId) {
|
|
23
|
+
return resolveTwilioAccount(cfg, accountId);
|
|
24
|
+
},
|
|
25
|
+
isConfigured(account) {
|
|
26
|
+
return Boolean(account.accountSid && account.authToken && account.phoneNumber);
|
|
27
|
+
},
|
|
28
|
+
describeAccount(account) {
|
|
29
|
+
return {
|
|
30
|
+
accountId: account.accountId,
|
|
31
|
+
configured: Boolean(account.accountSid && account.authToken && account.phoneNumber),
|
|
32
|
+
name: `WhatsApp ${account.phoneNumber}`,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
hasConfiguredState({ cfg }) {
|
|
36
|
+
return listTwilioAccountIds(cfg).length > 0;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
outbound: twilioWhatsappOutbound,
|
|
40
|
+
gateway: {
|
|
41
|
+
async startAccount(ctx) {
|
|
42
|
+
const account = ctx.account;
|
|
43
|
+
const phoneNumber = normalizePhoneNumber(account.phoneNumber);
|
|
44
|
+
if (!ctx.channelRuntime) {
|
|
45
|
+
console.error(`[twilio-whatsapp] channelRuntime not provided for account ${ctx.accountId}. ` +
|
|
46
|
+
"Inbound dispatch will not work.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setRuntimeForPhoneNumber(phoneNumber, {
|
|
50
|
+
cfg: ctx.cfg,
|
|
51
|
+
accountId: ctx.accountId,
|
|
52
|
+
channelRuntime: ctx.channelRuntime,
|
|
53
|
+
});
|
|
54
|
+
ctx.setStatus({
|
|
55
|
+
accountId: ctx.accountId,
|
|
56
|
+
running: true,
|
|
57
|
+
connected: true,
|
|
58
|
+
configured: true,
|
|
59
|
+
name: `WhatsApp ${account.phoneNumber}`,
|
|
60
|
+
webhookPath: "/twilio/whatsapp/events",
|
|
61
|
+
});
|
|
62
|
+
console.log(`[twilio-whatsapp] Account ${ctx.accountId} started, listening on /twilio/whatsapp/events for ${account.phoneNumber}`);
|
|
63
|
+
await new Promise((_, reject) => {
|
|
64
|
+
ctx.abortSignal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
65
|
+
}).catch(() => {
|
|
66
|
+
clearRuntimeForPhoneNumber(phoneNumber);
|
|
67
|
+
ctx.setStatus({
|
|
68
|
+
accountId: ctx.accountId,
|
|
69
|
+
running: false,
|
|
70
|
+
connected: false,
|
|
71
|
+
});
|
|
72
|
+
console.log(`[twilio-whatsapp] Account ${ctx.accountId} stopped`);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm.js";
|
|
2
|
+
import { findTwilioAccountByPhoneNumber, normalizePhoneNumber, } from "./accounts.js";
|
|
3
|
+
import { parseTwilioWebhook, verifyTwilioSignature } from "./webhook.js";
|
|
4
|
+
import { sendTwilioWhatsappMessage } from "./send.js";
|
|
5
|
+
import { getRuntimeForPhoneNumber } from "./runtime-store.js";
|
|
6
|
+
async function readBody(req) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
10
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
11
|
+
req.on("error", reject);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function twimlOk(res) {
|
|
15
|
+
res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
|
|
16
|
+
res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
|
|
17
|
+
}
|
|
18
|
+
function twimlError(res, status, message) {
|
|
19
|
+
res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
|
|
20
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`);
|
|
21
|
+
}
|
|
22
|
+
export function registerTwilioWhatsappHttpRoutes(api) {
|
|
23
|
+
api.registerHttpRoute({
|
|
24
|
+
path: "/twilio/whatsapp/events",
|
|
25
|
+
auth: "plugin",
|
|
26
|
+
handler: async (req, res) => {
|
|
27
|
+
const rawBody = await readBody(req);
|
|
28
|
+
const headers = req.headers;
|
|
29
|
+
const signature = String(headers["x-twilio-signature"] ?? "");
|
|
30
|
+
const webhookUrl = String(headers["x-twilio-webhook-url"] ?? "");
|
|
31
|
+
const fields = parseTwilioWebhook(rawBody);
|
|
32
|
+
const toPhone = normalizePhoneNumber(fields.to);
|
|
33
|
+
const stored = getRuntimeForPhoneNumber(toPhone);
|
|
34
|
+
if (!stored) {
|
|
35
|
+
console.error(`[twilio-whatsapp] No runtime found for To=${fields.to}`);
|
|
36
|
+
twimlError(res, 404, "No agent bound to this number");
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const account = findTwilioAccountByPhoneNumber(stored.cfg, toPhone);
|
|
40
|
+
if (!account) {
|
|
41
|
+
console.error(`[twilio-whatsapp] Account not found for phone=${toPhone}`);
|
|
42
|
+
twimlError(res, 404, "Account not found");
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (webhookUrl && signature) {
|
|
46
|
+
const valid = verifyTwilioSignature({
|
|
47
|
+
authToken: account.authToken,
|
|
48
|
+
signature,
|
|
49
|
+
url: webhookUrl,
|
|
50
|
+
rawBody,
|
|
51
|
+
});
|
|
52
|
+
if (!valid) {
|
|
53
|
+
console.error("[twilio-whatsapp] Invalid Twilio signature");
|
|
54
|
+
twimlError(res, 403, "Forbidden");
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.warn("[twilio-whatsapp] Skipping sig verification — headers missing");
|
|
60
|
+
}
|
|
61
|
+
const fromPhone = normalizePhoneNumber(fields.from);
|
|
62
|
+
const senderAddress = `whatsapp:${fromPhone}`;
|
|
63
|
+
const recipientAddress = `whatsapp:${toPhone}`;
|
|
64
|
+
const conversationLabel = fields.profileName || fromPhone;
|
|
65
|
+
const messageText = fields.body.trim();
|
|
66
|
+
const hasMedia = fields.media.length > 0;
|
|
67
|
+
if (!messageText && !hasMedia) {
|
|
68
|
+
twimlOk(res);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const bodyForAgent = hasMedia
|
|
72
|
+
? [messageText, ...fields.media.map((m) => `[Media: ${m.url}]`)].filter(Boolean).join("\n")
|
|
73
|
+
: messageText;
|
|
74
|
+
twimlOk(res);
|
|
75
|
+
dispatchInboundDirectDmWithRuntime({
|
|
76
|
+
cfg: stored.cfg,
|
|
77
|
+
runtime: { channel: stored.channelRuntime },
|
|
78
|
+
channel: "twilio-whatsapp",
|
|
79
|
+
channelLabel: "WhatsApp",
|
|
80
|
+
accountId: account.accountId,
|
|
81
|
+
peer: { kind: "direct", id: fromPhone },
|
|
82
|
+
senderId: fromPhone,
|
|
83
|
+
senderAddress,
|
|
84
|
+
recipientAddress,
|
|
85
|
+
conversationLabel,
|
|
86
|
+
rawBody: messageText,
|
|
87
|
+
bodyForAgent,
|
|
88
|
+
messageId: fields.messageSid,
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
commandAuthorized: true,
|
|
91
|
+
deliver: async (payload) => {
|
|
92
|
+
const text = payload.text ?? "";
|
|
93
|
+
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
|
94
|
+
if (!text && !mediaUrl)
|
|
95
|
+
return;
|
|
96
|
+
await sendTwilioWhatsappMessage({
|
|
97
|
+
accountSid: account.accountSid,
|
|
98
|
+
authToken: account.authToken,
|
|
99
|
+
from: toPhone,
|
|
100
|
+
to: fromPhone,
|
|
101
|
+
body: text || undefined,
|
|
102
|
+
mediaUrl,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
onRecordError: (err) => {
|
|
106
|
+
console.error("[twilio-whatsapp] Session record error:", err);
|
|
107
|
+
},
|
|
108
|
+
onDispatchError: (err, info) => {
|
|
109
|
+
console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
|
|
110
|
+
},
|
|
111
|
+
}).catch((err) => {
|
|
112
|
+
console.error("[twilio-whatsapp] Unhandled dispatch error:", err);
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolveTwilioAccount } from "./accounts.js";
|
|
2
|
+
import { sendTwilioWhatsappMessage } from "./send.js";
|
|
3
|
+
async function deliver(ctx) {
|
|
4
|
+
const account = resolveTwilioAccount(ctx.cfg, ctx.accountId);
|
|
5
|
+
const sid = await sendTwilioWhatsappMessage({
|
|
6
|
+
accountSid: account.accountSid,
|
|
7
|
+
authToken: account.authToken,
|
|
8
|
+
from: account.phoneNumber,
|
|
9
|
+
to: ctx.to,
|
|
10
|
+
body: ctx.text || undefined,
|
|
11
|
+
mediaUrl: ctx.mediaUrl,
|
|
12
|
+
});
|
|
13
|
+
return sid;
|
|
14
|
+
}
|
|
15
|
+
export const twilioWhatsappOutbound = {
|
|
16
|
+
deliveryMode: "direct",
|
|
17
|
+
async sendText(ctx) {
|
|
18
|
+
const messageId = await deliver(ctx);
|
|
19
|
+
return {
|
|
20
|
+
channel: "twilio-whatsapp",
|
|
21
|
+
messageId,
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
async sendMedia(ctx) {
|
|
25
|
+
const messageId = await deliver(ctx);
|
|
26
|
+
return {
|
|
27
|
+
channel: "twilio-whatsapp",
|
|
28
|
+
messageId,
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/channels/plugins/types.js";
|
|
2
|
+
export type StoredTwilioRuntime = {
|
|
3
|
+
cfg: OpenClawConfig;
|
|
4
|
+
accountId: string;
|
|
5
|
+
channelRuntime: NonNullable<import("openclaw/channels/plugins/types.adapters.js").ChannelGatewayContext["channelRuntime"]>;
|
|
6
|
+
};
|
|
7
|
+
export declare function setRuntimeForPhoneNumber(phoneNumber: string, runtime: StoredTwilioRuntime): void;
|
|
8
|
+
export declare function clearRuntimeForPhoneNumber(phoneNumber: string): void;
|
|
9
|
+
export declare function getRuntimeForPhoneNumber(phoneNumber: string): StoredTwilioRuntime | undefined;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const phoneToRuntime = new Map();
|
|
2
|
+
export function setRuntimeForPhoneNumber(phoneNumber, runtime) {
|
|
3
|
+
phoneToRuntime.set(phoneNumber, runtime);
|
|
4
|
+
}
|
|
5
|
+
export function clearRuntimeForPhoneNumber(phoneNumber) {
|
|
6
|
+
phoneToRuntime.delete(phoneNumber);
|
|
7
|
+
}
|
|
8
|
+
export function getRuntimeForPhoneNumber(phoneNumber) {
|
|
9
|
+
return phoneToRuntime.get(phoneNumber);
|
|
10
|
+
}
|
package/dist/src/send.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import twilio from "twilio";
|
|
2
|
+
export async function sendTwilioWhatsappMessage(opts) {
|
|
3
|
+
const client = twilio(opts.accountSid, opts.authToken);
|
|
4
|
+
const from = opts.from.startsWith("whatsapp:") ? opts.from : `whatsapp:${opts.from}`;
|
|
5
|
+
const to = opts.to.startsWith("whatsapp:") ? opts.to : `whatsapp:${opts.to}`;
|
|
6
|
+
const msg = await client.messages.create({
|
|
7
|
+
from,
|
|
8
|
+
to,
|
|
9
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
10
|
+
...(opts.mediaUrl ? { mediaUrl: [opts.mediaUrl] } : {}),
|
|
11
|
+
});
|
|
12
|
+
return msg.sid;
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ParsedTwilioWebhook = {
|
|
2
|
+
from: string;
|
|
3
|
+
to: string;
|
|
4
|
+
body: string;
|
|
5
|
+
waId: string;
|
|
6
|
+
profileName: string;
|
|
7
|
+
messageSid: string;
|
|
8
|
+
media: Array<{
|
|
9
|
+
url: string;
|
|
10
|
+
contentType: string;
|
|
11
|
+
}>;
|
|
12
|
+
repliedToSid?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function verifyTwilioSignature(params: {
|
|
15
|
+
authToken: string;
|
|
16
|
+
signature: string;
|
|
17
|
+
url: string;
|
|
18
|
+
rawBody: string;
|
|
19
|
+
}): boolean;
|
|
20
|
+
export declare function parseTwilioWebhook(rawBody: string): ParsedTwilioWebhook;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import twilio from "twilio";
|
|
2
|
+
export function verifyTwilioSignature(params) {
|
|
3
|
+
try {
|
|
4
|
+
const parsed = Object.fromEntries(new URLSearchParams(params.rawBody));
|
|
5
|
+
return twilio.validateRequest(params.authToken, params.signature, params.url, parsed);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function parseTwilioWebhook(rawBody) {
|
|
12
|
+
const p = Object.fromEntries(new URLSearchParams(rawBody));
|
|
13
|
+
const numMedia = parseInt(p["NumMedia"] ?? "0", 10);
|
|
14
|
+
const media = Array.from({ length: numMedia }, (_, i) => ({
|
|
15
|
+
url: p[`MediaUrl${i}`] ?? "",
|
|
16
|
+
contentType: p[`MediaContentType${i}`] ?? "application/octet-stream",
|
|
17
|
+
}));
|
|
18
|
+
return {
|
|
19
|
+
from: (p["From"] ?? "").replace("whatsapp:", ""),
|
|
20
|
+
to: (p["To"] ?? "").replace("whatsapp:", ""),
|
|
21
|
+
body: p["Body"] ?? "",
|
|
22
|
+
waId: p["WaId"] ?? "",
|
|
23
|
+
profileName: p["ProfileName"] ?? "",
|
|
24
|
+
messageSid: p["MessageSid"] ?? "",
|
|
25
|
+
media,
|
|
26
|
+
repliedToSid: p["OriginalRepliedMessageSid"],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "twilio-whatsapp",
|
|
3
|
+
"name": "Twilio WhatsApp",
|
|
4
|
+
"description": "WhatsApp channel via Twilio Business API (ISV/WABA)",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"channels": ["twilio-whatsapp"],
|
|
7
|
+
"channelConfigs": {
|
|
8
|
+
"twilio-whatsapp": {
|
|
9
|
+
"label": "Twilio WhatsApp",
|
|
10
|
+
"description": "WhatsApp messaging via Twilio ISV Business Account",
|
|
11
|
+
"schema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"dmPolicy": { "type": "string" },
|
|
16
|
+
"allowFrom": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": { "type": "string" }
|
|
19
|
+
},
|
|
20
|
+
"accounts": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"properties": {
|
|
26
|
+
"accountSid": { "type": "string" },
|
|
27
|
+
"authToken": { "type": "string" },
|
|
28
|
+
"phoneNumber": { "type": "string" }
|
|
29
|
+
},
|
|
30
|
+
"required": ["accountSid", "authToken", "phoneNumber"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"configSchema": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"properties": {}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@daolmedo/openclaw-twilio-whatsapp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Twilio WhatsApp channel plugin for OpenClaw (Cody ISV)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"openclaw.plugin.json"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "esbuild index.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outfile=dist/index.js",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"twilio": "^5.4.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"esbuild": "^0.28.0",
|
|
22
|
+
"typescript": "^5.4.0"
|
|
23
|
+
},
|
|
24
|
+
"openclaw": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./dist/index.js"
|
|
27
|
+
],
|
|
28
|
+
"channel": {
|
|
29
|
+
"id": "twilio-whatsapp",
|
|
30
|
+
"label": "Twilio WhatsApp"
|
|
31
|
+
},
|
|
32
|
+
"install": {
|
|
33
|
+
"minHostVersion": ">=2026.3.24-beta.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|