@iflow-mcp/mmarqueti-whatsapp-mcp 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/Dockerfile +15 -0
- package/README.md +220 -0
- package/dist/config/env.js +25 -0
- package/dist/index.js +43 -0
- package/dist/mcp/server.js +104 -0
- package/dist/whatsapp/__tests__/webhook.test.js +52 -0
- package/dist/whatsapp/client.js +111 -0
- package/dist/whatsapp/normalize.js +47 -0
- package/dist/whatsapp/storage.js +17 -0
- package/dist/whatsapp/webhook.js +22 -0
- package/docs/testing_guide.md +111 -0
- package/docs/use_case.md +115 -0
- package/language.json +1 -0
- package/package.json +1 -0
- package/package_name +1 -0
- package/push_info.json +5 -0
- package/src/config/env.ts +28 -0
- package/src/index.ts +48 -0
- package/src/mcp/server.ts +141 -0
- package/src/whatsapp/__tests__/webhook.test.ts +67 -0
- package/src/whatsapp/client.ts +114 -0
- package/src/whatsapp/webhook.ts +27 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +10 -0
- package/whatsapp_mcp_spec_v1.md +264 -0
package/Dockerfile
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# WhatsApp Cloud API MCP Server – Spec v1.0
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
This project defines version **1.0** of an **MCP Server** that exposes the **WhatsApp Cloud API** to LLM agents through standardized MCP tools.
|
|
5
|
+
|
|
6
|
+
Goal: Provide a minimal, clean, production-ready baseline so developers can use WhatsApp messaging inside AI agents with zero complexity.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 1. Objectives
|
|
11
|
+
|
|
12
|
+
- Create a lightweight MCP server (TypeScript + Node.js).
|
|
13
|
+
- Expose WhatsApp Cloud API functionalities as tools.
|
|
14
|
+
- Provide helpers for webhook verification.
|
|
15
|
+
- Be deployable with a single command (Docker + local dev).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# 2. Tech Stack
|
|
20
|
+
|
|
21
|
+
- **Node.js 20+**
|
|
22
|
+
- **TypeScript**
|
|
23
|
+
- **Express** (or Fastify) for HTTP + webhooks
|
|
24
|
+
- **MCP Server SDK**
|
|
25
|
+
- **Axios** for outbound WhatsApp API requests
|
|
26
|
+
- **WebSocket** for MCP transport (mandatory)
|
|
27
|
+
- **Dotenv** for configs
|
|
28
|
+
- **Pino** or console logging
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# 3. Project Structure (v1.0)
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
/src
|
|
36
|
+
/mcp
|
|
37
|
+
server.ts
|
|
38
|
+
/whatsapp
|
|
39
|
+
client.ts
|
|
40
|
+
webhook.ts
|
|
41
|
+
/config
|
|
42
|
+
env.ts
|
|
43
|
+
index.ts
|
|
44
|
+
.env.example
|
|
45
|
+
mcp.json
|
|
46
|
+
package.json
|
|
47
|
+
README.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
# 4. Environment Variables
|
|
53
|
+
|
|
54
|
+
Required:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
META_WHATSAPP_TOKEN=
|
|
58
|
+
META_WHATSAPP_PHONE_ID=
|
|
59
|
+
META_WHATSAPP_WABA_ID=
|
|
60
|
+
META_VERIFY_TOKEN=
|
|
61
|
+
PORT=4000
|
|
62
|
+
MCP_PORT=8000
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Optional:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
LOG_LEVEL=debug
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
# 5. WhatsApp Cloud API Endpoints to Support (v1.0)
|
|
74
|
+
|
|
75
|
+
The MCP server will call these official endpoints:
|
|
76
|
+
|
|
77
|
+
### **Send Text Message**
|
|
78
|
+
```
|
|
79
|
+
POST /v18.0/{PHONE_ID}/messages
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### **Send Template Message**
|
|
83
|
+
```
|
|
84
|
+
POST /v18.0/{PHONE_ID}/messages
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### **Get Media URL**
|
|
88
|
+
```
|
|
89
|
+
GET /v18.0/{MEDIA_ID}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### **Download Media**
|
|
93
|
+
```
|
|
94
|
+
GET {MEDIA_URL}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### **Health Check**
|
|
98
|
+
```
|
|
99
|
+
GET /v18.0/{PHONE_ID}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
# 6. Webhook Handling
|
|
105
|
+
|
|
106
|
+
Expose:
|
|
107
|
+
```
|
|
108
|
+
GET /webhook (verification)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Features:
|
|
112
|
+
- Validate `hub.verify_token`
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
# 7. MCP Tools (v1.0)
|
|
117
|
+
|
|
118
|
+
Tools exposed to agents:
|
|
119
|
+
|
|
120
|
+
## **1. send_text_message**
|
|
121
|
+
Send a simple text message to a WhatsApp user.
|
|
122
|
+
|
|
123
|
+
### Params:
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"to": "string",
|
|
127
|
+
"text": "string"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## **2. send_template_message**
|
|
132
|
+
Send a WhatsApp template message.
|
|
133
|
+
|
|
134
|
+
### Params:
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"to": "string",
|
|
138
|
+
"template_name": "string",
|
|
139
|
+
"language": "string",
|
|
140
|
+
"components": []
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## **3. get_media**
|
|
145
|
+
Retrieve a media file by MEDIA_ID.
|
|
146
|
+
|
|
147
|
+
### Params:
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"media_id": "string"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## **4. health_check**
|
|
155
|
+
Verify connectivity with WhatsApp Cloud API.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
# 8. Security
|
|
160
|
+
|
|
161
|
+
- Validate Meta webhook signature (optional v1).
|
|
162
|
+
- Restrict MCP tool usage to authenticated WebSocket clients.
|
|
163
|
+
- Sanitize user inputs.
|
|
164
|
+
- Never log tokens.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
# 9. Documentation Requirements
|
|
169
|
+
|
|
170
|
+
## Setup Instructions
|
|
171
|
+
|
|
172
|
+
1. **Install Dependencies**
|
|
173
|
+
```bash
|
|
174
|
+
npm install
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
2. **Configure Environment**
|
|
178
|
+
Copy `.env.example` to `.env` and fill in your Meta WhatsApp API credentials.
|
|
179
|
+
```bash
|
|
180
|
+
cp .env.example .env
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
3. **Run Locally**
|
|
184
|
+
```bash
|
|
185
|
+
npm run dev
|
|
186
|
+
```
|
|
187
|
+
Server will start on port 4000 (HTTP) and expose MCP on WebSocket.
|
|
188
|
+
|
|
189
|
+
4. **Configure Webhook**
|
|
190
|
+
- Expose your local server using ngrok or similar: `ngrok http 4000`
|
|
191
|
+
- In Meta App Dashboard, set Webhook URL to `https://<your-ngrok-url>/webhook`
|
|
192
|
+
- Set Verify Token to match `META_VERIFY_TOKEN` in `.env`
|
|
193
|
+
|
|
194
|
+
## Docker Usage
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
docker build -t whatsapp-mcp .
|
|
198
|
+
docker run -p 4000:4000 --env-file .env whatsapp-mcp
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Example Usage with Claude Desktop
|
|
202
|
+
|
|
203
|
+
Add to your `claude_desktop_config.json`:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"mcpServers": {
|
|
208
|
+
"whatsapp": {
|
|
209
|
+
"command": "docker",
|
|
210
|
+
"args": ["run", "-i", "--rm", "--env-file", "/path/to/.env", "whatsapp-mcp"]
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
(Note: For local dev without docker, point to the build output)
|
|
216
|
+
|
|
217
|
+
# 10. Contact
|
|
218
|
+
|
|
219
|
+
For any inquiries or similar projects, please contact: marcelo@marcelomarchetti.com
|
|
220
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
// Suppress dotenv debug output by not logging to console
|
|
4
|
+
dotenv.config({ quiet: true });
|
|
5
|
+
const envSchema = z.object({
|
|
6
|
+
META_WHATSAPP_TOKEN: z.string().default('test_token'),
|
|
7
|
+
META_WHATSAPP_PHONE_ID: z.string().default('test_phone_id'),
|
|
8
|
+
META_WHATSAPP_WABA_ID: z.string().default('test_waba_id'),
|
|
9
|
+
META_VERIFY_TOKEN: z.string().default('test_verify_token'),
|
|
10
|
+
PORT: z.string().default('4000'),
|
|
11
|
+
MCP_PORT: z.string().default('8000'),
|
|
12
|
+
LOG_LEVEL: z.string().default('info'),
|
|
13
|
+
});
|
|
14
|
+
const parseEnv = () => {
|
|
15
|
+
try {
|
|
16
|
+
return envSchema.parse(process.env);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof z.ZodError) {
|
|
20
|
+
console.error('❌ Invalid environment variables:', error.flatten().fieldErrors);
|
|
21
|
+
}
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export const config = parseEnv();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { createServer } from 'http';
|
|
4
|
+
import { config } from './config/env.js';
|
|
5
|
+
import { webhookRouter } from './whatsapp/webhook.js';
|
|
6
|
+
import { mcpServer } from './mcp/server.js';
|
|
7
|
+
// Start servers
|
|
8
|
+
async function main() {
|
|
9
|
+
try {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const useStdio = args.includes('--stdio');
|
|
12
|
+
if (useStdio) {
|
|
13
|
+
// Start MCP Server (Stdio) only - no HTTP server needed
|
|
14
|
+
await mcpServer.startStdio();
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
// Setup HTTP server for WebSocket mode
|
|
18
|
+
const app = express();
|
|
19
|
+
const httpServer = createServer(app);
|
|
20
|
+
// Middleware
|
|
21
|
+
app.use(express.json());
|
|
22
|
+
// Routes
|
|
23
|
+
app.use('/webhook', webhookRouter);
|
|
24
|
+
// Health check
|
|
25
|
+
app.get('/health', (req, res) => {
|
|
26
|
+
res.status(200).json({ status: 'ok' });
|
|
27
|
+
});
|
|
28
|
+
// Start MCP Server (WebSocket)
|
|
29
|
+
await mcpServer.start(httpServer);
|
|
30
|
+
// Start HTTP Server
|
|
31
|
+
httpServer.listen(config.PORT, () => {
|
|
32
|
+
console.error(`🚀 Server running on port ${config.PORT}`);
|
|
33
|
+
console.error(`webhook: http://localhost:${config.PORT}/webhook`);
|
|
34
|
+
console.error(`mcp: ws://localhost:${config.PORT}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('Failed to start server:', error);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
main();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { whatsAppClient } from '../whatsapp/client.js';
|
|
6
|
+
class WhatsAppMcpServer {
|
|
7
|
+
server;
|
|
8
|
+
wss = null;
|
|
9
|
+
constructor() {
|
|
10
|
+
this.server = new McpServer({
|
|
11
|
+
name: 'WhatsApp MCP Server',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
});
|
|
14
|
+
this.registerTools();
|
|
15
|
+
}
|
|
16
|
+
registerTools() {
|
|
17
|
+
this.server.tool('send_text_message', {
|
|
18
|
+
to: z.string().describe('The WhatsApp phone number to send the message to'),
|
|
19
|
+
text: z.string().describe('The text content of the message'),
|
|
20
|
+
}, async ({ to, text }) => {
|
|
21
|
+
const result = await whatsAppClient.sendText(to, text);
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
this.server.tool('send_template_message', {
|
|
27
|
+
to: z.string().describe('The WhatsApp phone number'),
|
|
28
|
+
template_name: z.string().describe('Name of the template'),
|
|
29
|
+
language: z.string().describe('Language code (e.g., en_US)'),
|
|
30
|
+
components: z.array(z.any()).optional().describe('Template components'),
|
|
31
|
+
}, async ({ to, template_name, language, components }) => {
|
|
32
|
+
const result = await whatsAppClient.sendTemplate(to, template_name, language, components);
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
this.server.tool('get_media', {
|
|
38
|
+
media_id: z.string().describe('The ID of the media to retrieve'),
|
|
39
|
+
}, async ({ media_id }) => {
|
|
40
|
+
const url = await whatsAppClient.getMediaUrl(media_id);
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: url }],
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
this.server.tool('health_check', {}, async () => {
|
|
46
|
+
const isHealthy = await whatsAppClient.healthCheck();
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text', text: isHealthy ? 'healthy' : 'unhealthy' }],
|
|
49
|
+
isError: !isHealthy,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async start(httpServer) {
|
|
54
|
+
this.wss = new WebSocketServer({ server: httpServer });
|
|
55
|
+
this.wss.on('connection', async (ws) => {
|
|
56
|
+
console.error('New MCP WebSocket connection');
|
|
57
|
+
// Minimal Transport implementation for WebSocket
|
|
58
|
+
const transport = {
|
|
59
|
+
start: async () => { },
|
|
60
|
+
send: async (message) => {
|
|
61
|
+
if (ws.readyState === ws.OPEN) {
|
|
62
|
+
ws.send(JSON.stringify(message));
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
close: async () => {
|
|
66
|
+
ws.close();
|
|
67
|
+
},
|
|
68
|
+
onclose: undefined,
|
|
69
|
+
onerror: undefined,
|
|
70
|
+
onmessage: undefined,
|
|
71
|
+
};
|
|
72
|
+
ws.on('message', (data) => {
|
|
73
|
+
try {
|
|
74
|
+
const message = JSON.parse(data.toString());
|
|
75
|
+
if (transport.onmessage) {
|
|
76
|
+
transport.onmessage(message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error('Failed to parse message:', error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
ws.on('close', () => {
|
|
84
|
+
if (transport.onclose) {
|
|
85
|
+
transport.onclose();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
ws.on('error', (error) => {
|
|
89
|
+
console.error('WebSocket error:', error);
|
|
90
|
+
if (transport.onerror) {
|
|
91
|
+
transport.onerror(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// @ts-ignore - McpServer.connect expects a Transport interface which matches our object structure
|
|
95
|
+
await this.server.connect(transport);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async startStdio() {
|
|
99
|
+
const transport = new StdioServerTransport();
|
|
100
|
+
await this.server.connect(transport);
|
|
101
|
+
console.error('MCP Server running on stdio');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export const mcpServer = new WhatsAppMcpServer();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { webhookRouter } from '../webhook.js';
|
|
5
|
+
// Mock config
|
|
6
|
+
vi.mock('../../config/env.js', () => ({
|
|
7
|
+
config: {
|
|
8
|
+
META_VERIFY_TOKEN: 'test-token',
|
|
9
|
+
META_WHATSAPP_TOKEN: 'test-token',
|
|
10
|
+
META_WHATSAPP_PHONE_ID: 'test-phone-id',
|
|
11
|
+
PORT: 4000
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
const app = express();
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
app.use('/webhook', webhookRouter);
|
|
17
|
+
describe('Webhook Integration', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Clear storage before each test
|
|
20
|
+
// We need to access the private array or just add a clear method to storage
|
|
21
|
+
// For now, we can just assume it works or add a clear method if needed.
|
|
22
|
+
// But since we are testing ingestion, we can just check if count increases.
|
|
23
|
+
});
|
|
24
|
+
describe('GET /webhook', () => {
|
|
25
|
+
it('should verify token correctly', async () => {
|
|
26
|
+
const response = await request(app)
|
|
27
|
+
.get('/webhook')
|
|
28
|
+
.query({
|
|
29
|
+
'hub.mode': 'subscribe',
|
|
30
|
+
'hub.verify_token': 'test-token',
|
|
31
|
+
'hub.challenge': '12345'
|
|
32
|
+
});
|
|
33
|
+
expect(response.status).toBe(200);
|
|
34
|
+
expect(response.text).toBe('12345');
|
|
35
|
+
});
|
|
36
|
+
it('should reject invalid token', async () => {
|
|
37
|
+
const response = await request(app)
|
|
38
|
+
.get('/webhook')
|
|
39
|
+
.query({
|
|
40
|
+
'hub.mode': 'subscribe',
|
|
41
|
+
'hub.verify_token': 'wrong-token',
|
|
42
|
+
'hub.challenge': '12345'
|
|
43
|
+
});
|
|
44
|
+
expect(response.status).toBe(403);
|
|
45
|
+
});
|
|
46
|
+
it('should reject missing params', async () => {
|
|
47
|
+
const response = await request(app)
|
|
48
|
+
.get('/webhook');
|
|
49
|
+
expect(response.status).toBe(400);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { config } from '../config/env.js';
|
|
3
|
+
// Mock mode for testing without real API credentials
|
|
4
|
+
const isMockMode = !process.env.META_WHATSAPP_TOKEN ||
|
|
5
|
+
process.env.META_WHATSAPP_TOKEN === 'test_token' ||
|
|
6
|
+
!process.env.META_WHATSAPP_PHONE_ID;
|
|
7
|
+
export class WhatsAppClient {
|
|
8
|
+
client;
|
|
9
|
+
constructor() {
|
|
10
|
+
if (isMockMode) {
|
|
11
|
+
// Create mock client that doesn't make real API calls
|
|
12
|
+
this.client = {
|
|
13
|
+
get: async (url, config) => {
|
|
14
|
+
console.error(`[MOCK] GET request to ${url}`);
|
|
15
|
+
if (url.includes('PHONE_ID')) {
|
|
16
|
+
return { data: { id: 'mock_phone_id' } };
|
|
17
|
+
}
|
|
18
|
+
if (url.match(/^[0-9]+$/)) {
|
|
19
|
+
return { data: { url: 'https://mock.media.url/file.pdf' } };
|
|
20
|
+
}
|
|
21
|
+
return { data: {} };
|
|
22
|
+
},
|
|
23
|
+
post: async (url, data, config) => {
|
|
24
|
+
console.error(`[MOCK] POST request to ${url}`, data);
|
|
25
|
+
return { data: { success: true, message: 'Mock response' } };
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.client = axios.create({
|
|
31
|
+
baseURL: 'https://graph.facebook.com/v18.0',
|
|
32
|
+
headers: {
|
|
33
|
+
'Authorization': `Bearer ${config.META_WHATSAPP_TOKEN}`,
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async sendText(to, text) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await this.client.post(`/${config.META_WHATSAPP_PHONE_ID}/messages`, {
|
|
42
|
+
messaging_product: 'whatsapp',
|
|
43
|
+
recipient_type: 'individual',
|
|
44
|
+
to,
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: { body: text },
|
|
47
|
+
});
|
|
48
|
+
return response.data;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error('Error sending text message:', error.response?.data || error.message);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async sendTemplate(to, templateName, language, components = []) {
|
|
56
|
+
try {
|
|
57
|
+
const response = await this.client.post(`/${config.META_WHATSAPP_PHONE_ID}/messages`, {
|
|
58
|
+
messaging_product: 'whatsapp',
|
|
59
|
+
recipient_type: 'individual',
|
|
60
|
+
to,
|
|
61
|
+
type: 'template',
|
|
62
|
+
template: {
|
|
63
|
+
name: templateName,
|
|
64
|
+
language: { code: language },
|
|
65
|
+
components,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return response.data;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
console.error('Error sending template message:', error.response?.data || error.message);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async getMediaUrl(mediaId) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await this.client.get(`/${mediaId}`);
|
|
78
|
+
return response.data.url;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error('Error getting media URL:', error.response?.data || error.message);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async downloadMedia(url) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.client.get(url, {
|
|
88
|
+
responseType: 'arraybuffer',
|
|
89
|
+
headers: {
|
|
90
|
+
'Authorization': `Bearer ${config.META_WHATSAPP_TOKEN}`,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return response.data;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error('Error downloading media:', error.response?.data || error.message);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async healthCheck() {
|
|
101
|
+
try {
|
|
102
|
+
await this.client.get(`/${config.META_WHATSAPP_PHONE_ID}`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error('Health check failed:', error.response?.data || error.message);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export const whatsAppClient = new WhatsAppClient();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function normalizeMessage(webhookPayload) {
|
|
2
|
+
try {
|
|
3
|
+
const entry = webhookPayload.entry?.[0];
|
|
4
|
+
const change = entry?.changes?.[0];
|
|
5
|
+
const value = change?.value;
|
|
6
|
+
const message = value?.messages?.[0];
|
|
7
|
+
if (!message) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const normalized = {
|
|
11
|
+
from: message.from,
|
|
12
|
+
id: message.id,
|
|
13
|
+
timestamp: parseInt(message.timestamp, 10),
|
|
14
|
+
type: 'unknown',
|
|
15
|
+
raw: message,
|
|
16
|
+
};
|
|
17
|
+
switch (message.type) {
|
|
18
|
+
case 'text':
|
|
19
|
+
normalized.type = 'text';
|
|
20
|
+
normalized.text = message.text.body;
|
|
21
|
+
break;
|
|
22
|
+
case 'image':
|
|
23
|
+
normalized.type = 'image';
|
|
24
|
+
normalized.image = message.image;
|
|
25
|
+
break;
|
|
26
|
+
case 'audio':
|
|
27
|
+
normalized.type = 'audio';
|
|
28
|
+
normalized.audio = message.audio;
|
|
29
|
+
break;
|
|
30
|
+
case 'document':
|
|
31
|
+
normalized.type = 'document';
|
|
32
|
+
normalized.document = message.document;
|
|
33
|
+
break;
|
|
34
|
+
case 'interactive':
|
|
35
|
+
normalized.type = 'interactive';
|
|
36
|
+
normalized.interactive = message.interactive;
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
normalized.type = 'unknown';
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error normalizing message:', error);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class MessageStorage {
|
|
2
|
+
messages = [];
|
|
3
|
+
limit;
|
|
4
|
+
constructor(limit = 50) {
|
|
5
|
+
this.limit = limit;
|
|
6
|
+
}
|
|
7
|
+
addMessage(message) {
|
|
8
|
+
this.messages.unshift(message);
|
|
9
|
+
if (this.messages.length > this.limit) {
|
|
10
|
+
this.messages.pop();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
getRecentMessages(limit = 20) {
|
|
14
|
+
return this.messages.slice(0, limit);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const messageStorage = new MessageStorage();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { config } from '../config/env.js';
|
|
3
|
+
const router = express.Router();
|
|
4
|
+
// Verification endpoint
|
|
5
|
+
router.get('/', (req, res) => {
|
|
6
|
+
const mode = req.query['hub.mode'];
|
|
7
|
+
const token = req.query['hub.verify_token'];
|
|
8
|
+
const challenge = req.query['hub.challenge'];
|
|
9
|
+
if (mode && token) {
|
|
10
|
+
if (mode === 'subscribe' && token === config.META_VERIFY_TOKEN) {
|
|
11
|
+
console.log('WEBHOOK_VERIFIED');
|
|
12
|
+
res.status(200).send(challenge);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
res.sendStatus(403);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
res.sendStatus(400);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
export const webhookRouter = router;
|