whatsapp-cloud 0.0.5 → 0.0.6
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/CHANGELOG.md +7 -0
- package/docs/DEVELOPMENT.md +154 -0
- package/docs/webhooks.md +76 -0
- package/package.json +1 -1
- package/src/client/WhatsAppClient.ts +3 -0
- package/src/index.ts +7 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/webhooks/incoming-message.ts +38 -0
- package/src/schemas/webhooks/index.ts +3 -0
- package/src/schemas/webhooks/payload.ts +56 -0
- package/src/services/webhooks/WebhooksService.ts +214 -0
- package/src/services/webhooks/index.ts +3 -0
- package/src/services/webhooks/utils/extract-messages.ts +25 -0
- package/src/services/webhooks/utils/extract-statuses.ts +25 -0
- package/src/services/webhooks/utils/verify.ts +29 -0
- package/src/types/index.ts +1 -0
- package/src/types/webhooks/incoming-message.ts +16 -0
- package/src/types/webhooks/index.ts +3 -0
- package/src/types/webhooks/payload.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Development Guide
|
|
2
|
+
|
|
3
|
+
## Local Development with npm/pnpm link
|
|
4
|
+
|
|
5
|
+
To test the SDK in your own project without publishing to npm:
|
|
6
|
+
|
|
7
|
+
### Step 1: Link the package (in whatsapp-cloud directory)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd /Users/lukas/Developer/whatsapp-cloud
|
|
11
|
+
pnpm build # Build the package first
|
|
12
|
+
pnpm link --global # Creates a global symlink
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or with npm:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm link
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Step 2: Link in your project
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd /path/to/your/project
|
|
25
|
+
pnpm link whatsapp-cloud
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or with npm:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm link whatsapp-cloud
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**What this does:** Creates a symlink in your project's `node_modules` pointing to your local package. You don't need to:
|
|
35
|
+
|
|
36
|
+
- Add it to `package.json` (link handles it)
|
|
37
|
+
- Run `pnpm install` (link is enough)
|
|
38
|
+
- Publish to npm
|
|
39
|
+
|
|
40
|
+
### Step 3: Use it in your project
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { WhatsAppClient, type IncomingTextMessage } from "whatsapp-cloud";
|
|
44
|
+
|
|
45
|
+
const client = new WhatsAppClient({
|
|
46
|
+
accessToken: "...",
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Important Notes
|
|
51
|
+
|
|
52
|
+
- **Rebuild after changes**: After making changes to whatsapp-cloud, run `pnpm build` in the whatsapp-cloud directory
|
|
53
|
+
- **Hot reload**: Some bundlers (like Next.js) may need a restart to pick up changes
|
|
54
|
+
- **Unlink**: When done, unlink with `pnpm unlink whatsapp-cloud` in your project
|
|
55
|
+
|
|
56
|
+
## Production / CI/CD
|
|
57
|
+
|
|
58
|
+
For production builds and CI/CD, you have a few options:
|
|
59
|
+
|
|
60
|
+
### Option 1: Publish to npm (Recommended)
|
|
61
|
+
|
|
62
|
+
Once ready, publish the package:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd /Users/lukas/Developer/whatsapp-cloud
|
|
66
|
+
pnpm publish
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then in your project's `package.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"whatsapp-cloud": "^0.0.5"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Option 2: Git dependency (For private repos)
|
|
80
|
+
|
|
81
|
+
If your repo is private, use git dependency:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"dependencies": {
|
|
86
|
+
"whatsapp-cloud": "git+https://github.com/your-username/whatsapp-cloud.git"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Option 3: Local file path (For monorepos)
|
|
92
|
+
|
|
93
|
+
If both projects are in the same repo/monorepo:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"dependencies": {
|
|
98
|
+
"whatsapp-cloud": "file:../whatsapp-cloud"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Option 4: Conditional linking (Dev vs Prod)
|
|
104
|
+
|
|
105
|
+
Use environment detection:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"dependencies": {
|
|
110
|
+
"whatsapp-cloud": process.env.NODE_ENV === "development"
|
|
111
|
+
? "link:../whatsapp-cloud"
|
|
112
|
+
: "^0.0.5"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Note:** For CI/CD, you'll need Option 1 (npm publish) or Option 2 (git dependency). `pnpm link` only works locally.
|
|
118
|
+
|
|
119
|
+
## Type Exports
|
|
120
|
+
|
|
121
|
+
All types are properly exported and can be imported in React/Next.js projects:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// Import client
|
|
125
|
+
import { WhatsAppClient } from "whatsapp-cloud";
|
|
126
|
+
|
|
127
|
+
// Import types
|
|
128
|
+
import type {
|
|
129
|
+
IncomingTextMessage,
|
|
130
|
+
IncomingMessage,
|
|
131
|
+
WebhookPayload,
|
|
132
|
+
MessageContext,
|
|
133
|
+
CreateTemplateRequest,
|
|
134
|
+
// ... all other types
|
|
135
|
+
} from "whatsapp-cloud";
|
|
136
|
+
|
|
137
|
+
// Import schemas (for validation)
|
|
138
|
+
import {
|
|
139
|
+
incomingTextMessageSchema,
|
|
140
|
+
webhookPayloadSchema,
|
|
141
|
+
// ... all other schemas
|
|
142
|
+
} from "whatsapp-cloud";
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Verifying Exports
|
|
146
|
+
|
|
147
|
+
To verify all types are exported correctly:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# In your project
|
|
151
|
+
pnpm exec tsc --noEmit --skipLibCheck
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
This will check that all imported types are available.
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Webhooks
|
|
2
|
+
|
|
3
|
+
Handle incoming WhatsApp messages and status updates via webhooks.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { WhatsAppClient } from "@whatsapp-cloud/sdk";
|
|
9
|
+
|
|
10
|
+
const client = new WhatsAppClient({
|
|
11
|
+
accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// In your webhook endpoint
|
|
15
|
+
app.post("/webhook", async (req, res) => {
|
|
16
|
+
// handle() returns IMMEDIATELY - handlers run in background
|
|
17
|
+
client.webhooks.handle(req.body, {
|
|
18
|
+
text: async (message, context) => {
|
|
19
|
+
// This can take as long as needed (20s, 1min, etc.)
|
|
20
|
+
// Webhook already returned 200, so Meta is happy
|
|
21
|
+
|
|
22
|
+
// Process text message
|
|
23
|
+
console.log(`Received: ${message.text.body} from ${message.from}`);
|
|
24
|
+
|
|
25
|
+
// Store in database
|
|
26
|
+
await db.messages.create({
|
|
27
|
+
id: message.id,
|
|
28
|
+
from: message.from,
|
|
29
|
+
body: message.text.body,
|
|
30
|
+
phoneNumberId: context.metadata.phoneNumberId,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Send response
|
|
34
|
+
await client.messages.sendText({
|
|
35
|
+
to: `+${message.from}`,
|
|
36
|
+
text: { body: "Got it!" },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Returns 200 IMMEDIATELY (handlers continue in background)
|
|
42
|
+
res.json({ success: true });
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Webhook Verification
|
|
47
|
+
|
|
48
|
+
Meta sends GET requests to verify your webhook endpoint:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
app.get("/webhook", (req, res) => {
|
|
52
|
+
const challenge = client.webhooks.verify(req.query, process.env.VERIFY_TOKEN);
|
|
53
|
+
if (challenge) {
|
|
54
|
+
return res.send(challenge);
|
|
55
|
+
}
|
|
56
|
+
return res.status(403).send("Forbidden");
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Low-Level API
|
|
61
|
+
|
|
62
|
+
For more control, extract messages manually:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
const messages = client.webhooks.extractMessages(payload);
|
|
66
|
+
for (const message of messages) {
|
|
67
|
+
// Custom processing
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API Reference
|
|
72
|
+
|
|
73
|
+
- `client.webhooks.verify(query, token)` - Verify GET request, returns challenge or null
|
|
74
|
+
- `client.webhooks.extractMessages(payload)` - Extract messages from payload
|
|
75
|
+
- `client.webhooks.extractStatuses(payload)` - Extract status updates
|
|
76
|
+
- `client.webhooks.handle(payload, handlers, options?)` - Handle with type-safe callbacks
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { MessagesService } from "../services/messages/index";
|
|
|
5
5
|
import { AccountsService } from "../services/accounts/index";
|
|
6
6
|
import { BusinessService } from "../services/business/index";
|
|
7
7
|
import { TemplatesService } from "../services/templates/index";
|
|
8
|
+
import { WebhooksService } from "../services/webhooks/index";
|
|
8
9
|
import { ZodError } from "zod";
|
|
9
10
|
import { transformZodError } from "../utils/zod-error";
|
|
10
11
|
import type { DebugTokenResponse } from "../types/debug";
|
|
@@ -17,6 +18,7 @@ export class WhatsAppClient {
|
|
|
17
18
|
public readonly accounts: AccountsService;
|
|
18
19
|
public readonly business: BusinessService;
|
|
19
20
|
public readonly templates: TemplatesService;
|
|
21
|
+
public readonly webhooks: WebhooksService;
|
|
20
22
|
|
|
21
23
|
private readonly httpClient: HttpClient;
|
|
22
24
|
|
|
@@ -40,6 +42,7 @@ export class WhatsAppClient {
|
|
|
40
42
|
this.accounts = new AccountsService(this.httpClient);
|
|
41
43
|
this.business = new BusinessService(this.httpClient);
|
|
42
44
|
this.templates = new TemplatesService(this.httpClient);
|
|
45
|
+
this.webhooks = new WebhooksService(this.httpClient);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ export * from "./schemas/index";
|
|
|
7
7
|
// Export types (primary export point)
|
|
8
8
|
export type * from "./types/index";
|
|
9
9
|
|
|
10
|
+
// Export webhook handler types (convenience exports)
|
|
11
|
+
export type {
|
|
12
|
+
MessageContext,
|
|
13
|
+
MessageHandlers,
|
|
14
|
+
HandleOptions,
|
|
15
|
+
} from "./services/webhooks/index";
|
|
16
|
+
|
|
10
17
|
// Export errors for error handling
|
|
11
18
|
export {
|
|
12
19
|
WhatsAppError,
|
package/src/schemas/index.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base fields present in ALL incoming messages
|
|
5
|
+
*/
|
|
6
|
+
const baseIncomingMessageSchema = z.object({
|
|
7
|
+
from: z.string(), // WhatsApp ID (phone number without +)
|
|
8
|
+
id: z.string(), // Message ID (wamid.*)
|
|
9
|
+
timestamp: z.string(), // Unix timestamp as string
|
|
10
|
+
type: z.string(), // Message type discriminator
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Text content in incoming text messages
|
|
15
|
+
* Note: Incoming messages don't have preview_url like outgoing
|
|
16
|
+
*/
|
|
17
|
+
const incomingTextContentSchema = z.object({
|
|
18
|
+
body: z.string(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Incoming text message schema
|
|
23
|
+
* Uses discriminated union pattern (type: "text")
|
|
24
|
+
*/
|
|
25
|
+
export const incomingTextMessageSchema = baseIncomingMessageSchema.extend({
|
|
26
|
+
type: z.literal("text"),
|
|
27
|
+
text: incomingTextContentSchema,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Union of all incoming message types
|
|
32
|
+
* For now: just text. Others (image, audio, etc.) will be added later
|
|
33
|
+
*/
|
|
34
|
+
export const incomingMessageSchema = z.discriminatedUnion("type", [
|
|
35
|
+
incomingTextMessageSchema,
|
|
36
|
+
// Future: incomingImageMessageSchema, incomingAudioMessageSchema, etc.
|
|
37
|
+
]);
|
|
38
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { incomingMessageSchema } from "./incoming-message";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Contact information in webhook
|
|
6
|
+
*/
|
|
7
|
+
const contactSchema = z.object({
|
|
8
|
+
profile: z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
}),
|
|
11
|
+
wa_id: z.string(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Metadata in webhook value
|
|
16
|
+
*/
|
|
17
|
+
const webhookMetadataSchema = z.object({
|
|
18
|
+
display_phone_number: z.string(),
|
|
19
|
+
phone_number_id: z.string(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Webhook value (the actual data)
|
|
24
|
+
*/
|
|
25
|
+
const webhookValueSchema = z.object({
|
|
26
|
+
messaging_product: z.literal("whatsapp"),
|
|
27
|
+
metadata: webhookMetadataSchema,
|
|
28
|
+
contacts: z.array(contactSchema).optional(),
|
|
29
|
+
messages: z.array(incomingMessageSchema).optional(), // Incoming messages
|
|
30
|
+
statuses: z.array(z.any()).optional(), // Status updates (for later)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Webhook change entry
|
|
35
|
+
*/
|
|
36
|
+
const webhookChangeSchema = z.object({
|
|
37
|
+
value: webhookValueSchema,
|
|
38
|
+
field: z.literal("messages"), // For now: only messages field
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Webhook entry
|
|
43
|
+
*/
|
|
44
|
+
const webhookEntrySchema = z.object({
|
|
45
|
+
id: z.string(), // WABA ID
|
|
46
|
+
changes: z.array(webhookChangeSchema),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Full webhook payload schema
|
|
51
|
+
*/
|
|
52
|
+
export const webhookPayloadSchema = z.object({
|
|
53
|
+
object: z.literal("whatsapp_business_account"),
|
|
54
|
+
entry: z.array(webhookEntrySchema),
|
|
55
|
+
});
|
|
56
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { HttpClient } from "../../client/HttpClient";
|
|
2
|
+
import { extractMessages } from "./utils/extract-messages";
|
|
3
|
+
import { extractStatuses } from "./utils/extract-statuses";
|
|
4
|
+
import { verifyWebhook } from "./utils/verify";
|
|
5
|
+
import { webhookPayloadSchema } from "../../schemas/webhooks/payload";
|
|
6
|
+
import type {
|
|
7
|
+
WebhookPayload,
|
|
8
|
+
IncomingTextMessage,
|
|
9
|
+
IncomingMessage,
|
|
10
|
+
} from "../../types/webhooks";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context provided to message handlers
|
|
14
|
+
* Contains metadata and contact info (message is passed separately)
|
|
15
|
+
*/
|
|
16
|
+
export type MessageContext = {
|
|
17
|
+
metadata: {
|
|
18
|
+
phoneNumberId: string;
|
|
19
|
+
displayPhoneNumber: string;
|
|
20
|
+
wabaId: string;
|
|
21
|
+
};
|
|
22
|
+
contact?: {
|
|
23
|
+
name: string;
|
|
24
|
+
waId: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Handler functions for different message types
|
|
30
|
+
* Receives message and context separately - message is the focus, context is optional metadata
|
|
31
|
+
*/
|
|
32
|
+
export type MessageHandlers = {
|
|
33
|
+
text?: (
|
|
34
|
+
message: IncomingTextMessage,
|
|
35
|
+
context: MessageContext
|
|
36
|
+
) => Promise<void> | void;
|
|
37
|
+
// Future: image, audio, video, etc.
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for handle() method
|
|
42
|
+
*/
|
|
43
|
+
export type HandleOptions = {
|
|
44
|
+
/**
|
|
45
|
+
* Error handler called when a message handler throws an error
|
|
46
|
+
* If not provided, errors are logged and processing continues
|
|
47
|
+
*/
|
|
48
|
+
onError?: (error: Error, message: IncomingMessage) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Webhooks service for handling incoming webhook payloads
|
|
53
|
+
*
|
|
54
|
+
* Provides utilities for extracting messages and a convenience handler
|
|
55
|
+
* for type-safe message processing.
|
|
56
|
+
*/
|
|
57
|
+
export class WebhooksService {
|
|
58
|
+
constructor(private readonly httpClient: HttpClient) {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verify webhook GET request from Meta
|
|
62
|
+
*
|
|
63
|
+
* Meta sends GET requests to verify webhook endpoints during setup.
|
|
64
|
+
* Returns the challenge string if valid, null if invalid.
|
|
65
|
+
*
|
|
66
|
+
* @param query - Query parameters from GET request
|
|
67
|
+
* @param verifyToken - Your verification token (stored on your server)
|
|
68
|
+
* @returns Challenge string if valid, null if invalid
|
|
69
|
+
*/
|
|
70
|
+
verify(
|
|
71
|
+
query: {
|
|
72
|
+
"hub.mode"?: string;
|
|
73
|
+
"hub.verify_token"?: string;
|
|
74
|
+
"hub.challenge"?: string;
|
|
75
|
+
},
|
|
76
|
+
verifyToken: string
|
|
77
|
+
): string | null {
|
|
78
|
+
return verifyWebhook(query, verifyToken);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract all incoming messages from webhook payload
|
|
83
|
+
*
|
|
84
|
+
* Low-level utility that flattens the nested webhook structure
|
|
85
|
+
* and returns messages directly.
|
|
86
|
+
*
|
|
87
|
+
* @param payload - Webhook payload from Meta
|
|
88
|
+
* @returns Flat array of incoming messages
|
|
89
|
+
*/
|
|
90
|
+
extractMessages(payload: WebhookPayload): IncomingMessage[] {
|
|
91
|
+
return extractMessages(payload);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract status updates from webhook payload
|
|
96
|
+
*
|
|
97
|
+
* Low-level utility for extracting status updates for outgoing messages.
|
|
98
|
+
*
|
|
99
|
+
* @param payload - Webhook payload from Meta
|
|
100
|
+
* @returns Flat array of status updates
|
|
101
|
+
*/
|
|
102
|
+
extractStatuses(payload: WebhookPayload): unknown[] {
|
|
103
|
+
return extractStatuses(payload);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate webhook payload structure
|
|
108
|
+
*
|
|
109
|
+
* Validates the payload against the schema. Logs errors if malformed
|
|
110
|
+
* but doesn't throw, allowing processing to continue.
|
|
111
|
+
*
|
|
112
|
+
* @param payload - Raw payload to validate
|
|
113
|
+
* @returns Validated payload if valid, original payload if invalid (with logged error)
|
|
114
|
+
*/
|
|
115
|
+
private validatePayload(payload: unknown): WebhookPayload {
|
|
116
|
+
const result = webhookPayloadSchema.safeParse(payload);
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
console.error(
|
|
119
|
+
"Webhook payload validation failed:",
|
|
120
|
+
result.error.format()
|
|
121
|
+
);
|
|
122
|
+
// Return as-is (TypeScript will treat it as WebhookPayload, but it's actually invalid)
|
|
123
|
+
// This allows processing to continue, but handlers should be defensive
|
|
124
|
+
return payload as WebhookPayload;
|
|
125
|
+
}
|
|
126
|
+
return result.data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle webhook payload with type-safe callbacks
|
|
131
|
+
*
|
|
132
|
+
* High-level convenience method that extracts messages and dispatches
|
|
133
|
+
* them to appropriate handlers based on message type.
|
|
134
|
+
*
|
|
135
|
+
* **Important**: This method returns quickly to allow fast webhook responses.
|
|
136
|
+
* Handlers are processed asynchronously. If you need to await handler completion,
|
|
137
|
+
* use the low-level `extractMessages()` method instead.
|
|
138
|
+
*
|
|
139
|
+
* @param payload - Webhook payload from Meta (will be validated)
|
|
140
|
+
* @param handlers - Object with handler functions for each message type
|
|
141
|
+
* @param options - Optional error handling configuration
|
|
142
|
+
*/
|
|
143
|
+
handle(
|
|
144
|
+
payload: unknown,
|
|
145
|
+
handlers: MessageHandlers,
|
|
146
|
+
options?: HandleOptions
|
|
147
|
+
): void {
|
|
148
|
+
// Validate payload (logs error if malformed, but continues)
|
|
149
|
+
const validatedPayload = this.validatePayload(payload);
|
|
150
|
+
|
|
151
|
+
// Extract metadata and contacts from payload for context
|
|
152
|
+
for (const entry of validatedPayload.entry) {
|
|
153
|
+
for (const change of entry.changes) {
|
|
154
|
+
if (change.field === "messages" && change.value.messages) {
|
|
155
|
+
const metadata = {
|
|
156
|
+
phoneNumberId: change.value.metadata.phone_number_id,
|
|
157
|
+
displayPhoneNumber: change.value.metadata.display_phone_number,
|
|
158
|
+
wabaId: entry.id,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const contacts = change.value.contacts || [];
|
|
162
|
+
|
|
163
|
+
// Process each message with its context
|
|
164
|
+
for (const message of change.value.messages) {
|
|
165
|
+
// Find contact for this message (match by wa_id)
|
|
166
|
+
const contact = contacts.find((c) => c.wa_id === message.from);
|
|
167
|
+
|
|
168
|
+
// Build context (metadata + contact, no message duplication)
|
|
169
|
+
const context: MessageContext = {
|
|
170
|
+
metadata,
|
|
171
|
+
...(contact && {
|
|
172
|
+
contact: {
|
|
173
|
+
name: contact.profile.name,
|
|
174
|
+
waId: contact.wa_id,
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Process handler asynchronously (don't await)
|
|
180
|
+
// This allows long-running handlers without blocking webhook response
|
|
181
|
+
Promise.resolve()
|
|
182
|
+
.then(async () => {
|
|
183
|
+
// Type-safe dispatch based on message type
|
|
184
|
+
switch (message.type) {
|
|
185
|
+
case "text":
|
|
186
|
+
if (handlers.text) {
|
|
187
|
+
await handlers.text(message, context);
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
// Future: image, audio, video, etc.
|
|
192
|
+
default:
|
|
193
|
+
// Unhandled message type - silently continue
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
.catch((error) => {
|
|
198
|
+
// Handle errors in handler execution
|
|
199
|
+
if (options?.onError) {
|
|
200
|
+
options.onError(error as Error, message);
|
|
201
|
+
} else {
|
|
202
|
+
// Default: log and continue (don't break webhook response)
|
|
203
|
+
console.error(
|
|
204
|
+
`Error handling ${message.type} message ${message.id}:`,
|
|
205
|
+
error
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebhookPayload } from "../../../types/webhooks";
|
|
2
|
+
import type { IncomingMessage } from "../../../types/webhooks/incoming-message";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract all incoming messages from webhook payload
|
|
6
|
+
*
|
|
7
|
+
* Flattens the nested structure: entry[].changes[].value.messages[]
|
|
8
|
+
* Returns a flat array of messages directly
|
|
9
|
+
*
|
|
10
|
+
* @param payload - Webhook payload from Meta
|
|
11
|
+
* @returns Flat array of incoming messages
|
|
12
|
+
*/
|
|
13
|
+
export function extractMessages(payload: WebhookPayload): IncomingMessage[] {
|
|
14
|
+
const messages: IncomingMessage[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of payload.entry) {
|
|
17
|
+
for (const change of entry.changes) {
|
|
18
|
+
if (change.field === "messages" && change.value.messages) {
|
|
19
|
+
messages.push(...change.value.messages);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return messages;
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebhookPayload } from "../../../types/webhooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract status updates from webhook payload
|
|
5
|
+
*
|
|
6
|
+
* Flattens the nested structure: entry[].changes[].value.statuses[]
|
|
7
|
+
* Returns a flat array of status updates
|
|
8
|
+
*
|
|
9
|
+
* @param payload - Webhook payload from Meta
|
|
10
|
+
* @returns Flat array of status updates
|
|
11
|
+
*/
|
|
12
|
+
export function extractStatuses(payload: WebhookPayload): unknown[] {
|
|
13
|
+
const statuses: unknown[] = [];
|
|
14
|
+
|
|
15
|
+
for (const entry of payload.entry) {
|
|
16
|
+
for (const change of entry.changes) {
|
|
17
|
+
if (change.field === "messages" && change.value.statuses) {
|
|
18
|
+
statuses.push(...change.value.statuses);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return statuses;
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify webhook GET request from Meta
|
|
3
|
+
*
|
|
4
|
+
* Meta sends GET requests to verify webhook endpoints:
|
|
5
|
+
* GET /webhook?hub.mode=subscribe&hub.challenge=<CHALLENGE>&hub.verify_token=<TOKEN>
|
|
6
|
+
*
|
|
7
|
+
* @param query - Query parameters from GET request
|
|
8
|
+
* @param verifyToken - Your verification token (stored on your server)
|
|
9
|
+
* @returns Challenge string if valid, null if invalid
|
|
10
|
+
*/
|
|
11
|
+
export function verifyWebhook(
|
|
12
|
+
query: {
|
|
13
|
+
"hub.mode"?: string;
|
|
14
|
+
"hub.verify_token"?: string;
|
|
15
|
+
"hub.challenge"?: string;
|
|
16
|
+
},
|
|
17
|
+
verifyToken: string
|
|
18
|
+
): string | null {
|
|
19
|
+
const mode = query["hub.mode"];
|
|
20
|
+
const token = query["hub.verify_token"];
|
|
21
|
+
const challenge = query["hub.challenge"];
|
|
22
|
+
|
|
23
|
+
// Verify mode is "subscribe" and token matches
|
|
24
|
+
if (mode === "subscribe" && token === verifyToken && challenge) {
|
|
25
|
+
return challenge;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
incomingTextMessageSchema,
|
|
4
|
+
incomingMessageSchema,
|
|
5
|
+
} from "../../schemas/webhooks/incoming-message";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Type for incoming text message
|
|
9
|
+
*/
|
|
10
|
+
export type IncomingTextMessage = z.infer<typeof incomingTextMessageSchema>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Union type for all incoming message types
|
|
14
|
+
*/
|
|
15
|
+
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
|
16
|
+
|