feat: initial WhatsApp Business Bot scaffold

Phase 1 implementation with all core modules:
- Fastify webhook server with Meta signature validation
- WhatsApp Cloud API client (send text/template/interactive, mark as read)
- LLM abstraction layer with Claude provider (Haiku for speed)
- BullMQ message processing pipeline (dedup, rate limiting)
- Bot routing (MessageRouter, ConversationManager, EscalationManager)
- Payload CMS integration (InteractionWriter via direct DB, RulesLoader, TemplateResolver)
- Healthcare-safe system prompt with medical keyword detection
- PM2 ecosystem config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-03-02 10:58:51 +00:00
commit 8847358507
30 changed files with 3660 additions and 0 deletions

27
.env.example Normal file
View file

@ -0,0 +1,27 @@
# WhatsApp Cloud API
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_VERIFY_TOKEN=
WHATSAPP_APP_SECRET=
# Claude API
ANTHROPIC_API_KEY=
# Payload CMS (Production)
PAYLOAD_API_URL=http://localhost:3001/api
PAYLOAD_API_KEY=
# PostgreSQL (shared with Payload CMS)
DATABASE_URL=postgresql://payload:@localhost:5432/payload-cms
# Redis (local)
REDIS_URL=redis://localhost:6379/0
# Server
PORT=3000
NODE_ENV=production
LOG_LEVEL=info
# CCS Tenant ID in Payload
CCS_TENANT_ID=10

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

19
ecosystem.config.cjs Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
apps: [
{
name: 'whatsapp-bot',
script: 'dist/server.js',
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '512M',
env: {
NODE_ENV: 'production',
},
error_file: '/var/log/pm2/whatsapp-bot-error.log',
out_file: '/var/log/pm2/whatsapp-bot-out.log',
time: true,
},
],
}

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@c2s/whatsapp-bot",
"version": "1.0.0",
"description": "WhatsApp Business Bot for Complex Care Solutions GmbH",
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"bullmq": "^5.34.8",
"fastify": "^5.2.1",
"ioredis": "5.9.3",
"pg": "^8.13.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

1437
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
import type Redis from 'ioredis'
import { getLogger } from '../lib/logger.js'
import type { ConversationMessage } from '../llm/LLMProvider.js'
const log = getLogger('conversation-manager')
const CONVERSATION_TTL = 86400 // 24h = WhatsApp Service Window
const MAX_HISTORY_LENGTH = 10
export interface ConversationState {
phone: string
senderName: string
messages: ConversationMessage[]
isEscalated: boolean
assignedAgent: string | null
language: string
firstContact: boolean
createdAt: string
updatedAt: string
}
export class ConversationManager {
constructor(private redis: Redis) {}
private key(phone: string): string {
return `wa:conv:${phone}`
}
async getConversation(phone: string): Promise<ConversationState | null> {
const data = await this.redis.get(this.key(phone))
if (!data) return null
return JSON.parse(data) as ConversationState
}
async getOrCreateConversation(
phone: string,
senderName: string,
): Promise<{ conversation: ConversationState; isNew: boolean }> {
const existing = await this.getConversation(phone)
if (existing) {
return { conversation: existing, isNew: false }
}
const conversation: ConversationState = {
phone,
senderName,
messages: [],
isEscalated: false,
assignedAgent: null,
language: 'de',
firstContact: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
await this.saveConversation(conversation)
log.info({ phone }, 'New conversation started')
return { conversation, isNew: true }
}
async addMessage(
phone: string,
role: 'user' | 'assistant',
content: string,
): Promise<void> {
const conv = await this.getConversation(phone)
if (!conv) return
conv.messages.push({ role, content, timestamp: new Date() })
// Keep only last N messages for LLM context
if (conv.messages.length > MAX_HISTORY_LENGTH) {
conv.messages = conv.messages.slice(-MAX_HISTORY_LENGTH)
}
conv.firstContact = false
conv.updatedAt = new Date().toISOString()
await this.saveConversation(conv)
}
async escalate(phone: string, agent: string | null = null): Promise<void> {
const conv = await this.getConversation(phone)
if (!conv) return
conv.isEscalated = true
conv.assignedAgent = agent
conv.updatedAt = new Date().toISOString()
await this.saveConversation(conv)
log.info({ phone, agent }, 'Conversation escalated')
}
async deescalate(phone: string): Promise<void> {
const conv = await this.getConversation(phone)
if (!conv) return
conv.isEscalated = false
conv.assignedAgent = null
conv.updatedAt = new Date().toISOString()
await this.saveConversation(conv)
log.info({ phone }, 'Conversation de-escalated')
}
private async saveConversation(conv: ConversationState): Promise<void> {
await this.redis.set(
this.key(conv.phone),
JSON.stringify(conv),
'EX',
CONVERSATION_TTL,
)
}
}

View file

@ -0,0 +1,81 @@
import { getLogger } from '../lib/logger.js'
import type { ConversationManager } from './ConversationManager.js'
import type { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
const log = getLogger('escalation-manager')
export interface EscalationReason {
type: 'medical' | 'explicit_request' | 'rule_match' | 'sentiment' | 'repeated_question'
detail: string
}
export class EscalationManager {
constructor(
private conversationManager: ConversationManager,
private whatsappClient: WhatsAppClient,
) {}
async shouldEscalate(
_phone: string,
text: string,
isMedicalQuestion: boolean,
): Promise<EscalationReason | null> {
// Medical questions → always escalate
if (isMedicalQuestion) {
return { type: 'medical', detail: 'Medical content detected' }
}
// Explicit escalation requests
const escalationPhrases = [
'mitarbeiter',
'mensch',
'echte person',
'berater',
'agent',
'human',
'real person',
'sprechen',
'anrufen',
'rückruf',
'beschwerde',
'complaint',
]
const lower = text.toLowerCase()
const matchedPhrase = escalationPhrases.find((phrase) =>
lower.includes(phrase),
)
if (matchedPhrase) {
return {
type: 'explicit_request',
detail: `User requested human: "${matchedPhrase}"`,
}
}
return null
}
async escalate(
phone: string,
reason: EscalationReason,
): Promise<void> {
await this.conversationManager.escalate(phone)
let message: string
switch (reason.type) {
case 'medical':
message =
'Ich verstehe, dass Sie eine medizinische Frage haben. Ich leite Sie an einen unserer Mitarbeiter weiter, der Ihnen besser helfen kann. Bitte haben Sie einen Moment Geduld.\n\nBei Notfällen rufen Sie bitte den Notruf 112 an.'
break
case 'explicit_request':
message =
'Ich verbinde Sie gerne mit einem Mitarbeiter. Bitte haben Sie einen Moment Geduld — wir melden uns schnellstmöglich bei Ihnen.'
break
default:
message =
'Ich leite Ihre Anfrage an einen Mitarbeiter weiter. Bitte haben Sie einen Moment Geduld.'
}
await this.whatsappClient.sendTextMessage(phone, message)
log.info({ phone, reason }, 'Conversation escalated to human agent')
}
}

140
src/bot/MessageRouter.ts Normal file
View file

@ -0,0 +1,140 @@
import { getLogger } from '../lib/logger.js'
import type { NormalizedMessage } from '../whatsapp/types.js'
import type { ConversationManager, ConversationState } from './ConversationManager.js'
import type { EscalationManager } from './EscalationManager.js'
import type { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
import type { LLMProvider, LLMResponse } from '../llm/LLMProvider.js'
import type { RulesLoader, MatchedRule } from '../payload/RulesLoader.js'
import type { TemplateResolver } from '../payload/TemplateResolver.js'
import type { InteractionWriter } from '../payload/InteractionWriter.js'
const log = getLogger('message-router')
export type RouteResult =
| { action: 'bot_response'; response: LLMResponse }
| { action: 'escalated'; reason: string }
| { action: 'rule_matched'; rule: MatchedRule }
| { action: 'skipped'; reason: string }
export class MessageRouter {
constructor(
private conversationManager: ConversationManager,
private escalationManager: EscalationManager,
private whatsappClient: WhatsAppClient,
private llmProvider: LLMProvider,
private rulesLoader: RulesLoader,
private templateResolver: TemplateResolver,
private interactionWriter: InteractionWriter,
) {}
async route(message: NormalizedMessage): Promise<RouteResult> {
const { from: phone, senderName, text, type, messageId } = message
// Skip non-text messages for now (Phase 4: media handling)
if (type !== 'text' && type !== 'interactive' && type !== 'button') {
const mediaAck =
'Vielen Dank für Ihre Nachricht. Aktuell kann ich nur Textnachrichten verarbeiten. Bitte schreiben Sie mir Ihr Anliegen als Text.'
await this.whatsappClient.sendTextMessage(phone, mediaAck)
await this.interactionWriter.writeIncoming(message, null)
return { action: 'skipped', reason: `Unsupported type: ${type}` }
}
if (!text) {
return { action: 'skipped', reason: 'Empty message text' }
}
// Get or create conversation
const { conversation, isNew } =
await this.conversationManager.getOrCreateConversation(phone, senderName)
// If already escalated, store but don't respond
if (conversation.isEscalated) {
await this.interactionWriter.writeIncoming(message, null)
await this.conversationManager.addMessage(phone, 'user', text)
log.info({ phone }, 'Message from escalated conversation, stored only')
return {
action: 'skipped',
reason: 'Conversation is escalated to human agent',
}
}
// Check community rules
const matchedRule = await this.rulesLoader.findMatchingRule(text, {
isFirstContact: isNew,
phone,
})
if (matchedRule) {
return this.handleRuleMatch(message, matchedRule, conversation)
}
// Generate bot response
const systemPrompt = await this.templateResolver.getSystemPrompt()
const llmResponse = await this.llmProvider.generateResponse({
systemPrompt,
conversationHistory: conversation.messages,
userMessage: text,
})
// Check if escalation is needed
const escalationReason = await this.escalationManager.shouldEscalate(
phone,
text,
llmResponse.isMedicalQuestion,
)
if (escalationReason) {
await this.interactionWriter.writeIncoming(message, llmResponse)
await this.conversationManager.addMessage(phone, 'user', text)
await this.escalationManager.escalate(phone, escalationReason)
return { action: 'escalated', reason: escalationReason.detail }
}
// Send bot response
await this.whatsappClient.sendTextMessage(phone, llmResponse.text)
await this.conversationManager.addMessage(phone, 'user', text)
await this.conversationManager.addMessage(phone, 'assistant', llmResponse.text)
// Store both messages
await this.interactionWriter.writeIncoming(message, llmResponse)
await this.interactionWriter.writeOutgoing(phone, llmResponse.text, messageId)
log.info(
{
phone,
inputTokens: llmResponse.inputTokens,
outputTokens: llmResponse.outputTokens,
isMedical: llmResponse.isMedicalQuestion,
},
'Bot response sent',
)
return { action: 'bot_response', response: llmResponse }
}
private async handleRuleMatch(
message: NormalizedMessage,
rule: MatchedRule,
_conversation: ConversationState,
): Promise<RouteResult> {
const { from: phone } = message
if (rule.action === 'escalate') {
await this.interactionWriter.writeIncoming(message, null)
await this.escalationManager.escalate(phone, {
type: 'rule_match',
detail: `Rule: ${rule.ruleName}`,
})
return { action: 'escalated', reason: `Rule match: ${rule.ruleName}` }
}
if (rule.action === 'auto_reply' && rule.replyText) {
await this.whatsappClient.sendTextMessage(phone, rule.replyText)
await this.interactionWriter.writeIncoming(message, null)
await this.interactionWriter.writeOutgoing(phone, rule.replyText, message.messageId)
return { action: 'rule_matched', rule }
}
return { action: 'skipped', reason: `Unknown rule action: ${rule.action}` }
}
}

54
src/config.ts Normal file
View file

@ -0,0 +1,54 @@
import { z } from 'zod'
const envSchema = z.object({
// WhatsApp Cloud API
WHATSAPP_PHONE_NUMBER_ID: z.string().min(1),
WHATSAPP_BUSINESS_ACCOUNT_ID: z.string().min(1),
WHATSAPP_ACCESS_TOKEN: z.string().min(1),
WHATSAPP_VERIFY_TOKEN: z.string().min(1),
WHATSAPP_APP_SECRET: z.string().min(1),
// Claude API
ANTHROPIC_API_KEY: z.string().min(1),
// Payload CMS
PAYLOAD_API_URL: z.string().url().default('http://localhost:3001/api'),
PAYLOAD_API_KEY: z.string().optional(),
// PostgreSQL
DATABASE_URL: z.string().min(1),
// Redis
REDIS_URL: z.string().default('redis://localhost:6379/0'),
// Server
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
// CCS Tenant
CCS_TENANT_ID: z.coerce.number().default(10),
})
export type Config = z.infer<typeof envSchema>
let _config: Config | null = null
export function loadConfig(): Config {
if (_config) return _config
const result = envSchema.safeParse(process.env)
if (!result.success) {
const missing = result.error.issues
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
.join('\n')
console.error(`Missing or invalid environment variables:\n${missing}`)
process.exit(1)
}
_config = result.data
return _config
}
export function getConfig(): Config {
if (!_config) return loadConfig()
return _config
}

20
src/lib/deduplication.ts Normal file
View file

@ -0,0 +1,20 @@
import type Redis from 'ioredis'
import { getLogger } from './logger.js'
const log = getLogger('deduplication')
const DEDUP_TTL = 86400 // 24 hours
export class MessageDeduplicator {
constructor(private redis: Redis) {}
async isDuplicate(messageId: string): Promise<boolean> {
const key = `wa:dedup:${messageId}`
const result = await this.redis.set(key, '1', 'EX', DEDUP_TTL, 'NX')
if (result === null) {
log.warn({ messageId }, 'Duplicate message detected')
return true
}
return false
}
}

43
src/lib/logger.ts Normal file
View file

@ -0,0 +1,43 @@
import pino from 'pino'
import { getConfig } from '../config.js'
const SENSITIVE_PATTERNS = [
/("?(?:token|secret|password|api_key|authorization)"?\s*[:=]\s*"?)([^"}\s]+)/gi,
]
function redact(msg: string): string {
let result = msg
for (const pattern of SENSITIVE_PATTERNS) {
result = result.replace(pattern, '$1[REDACTED]')
}
return result
}
let _logger: pino.Logger | null = null
export function getLogger(name?: string): pino.Logger {
if (!_logger) {
const config = getConfig()
_logger = pino({
level: config.LOG_LEVEL,
formatters: {
level(label) {
return { level: label }
},
},
hooks: {
logMethod(inputArgs, method) {
if (typeof inputArgs[0] === 'string') {
inputArgs[0] = redact(inputArgs[0])
}
return method.apply(this, inputArgs as Parameters<typeof method>)
},
},
transport:
config.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
})
}
return name ? _logger.child({ module: name }) : _logger
}

70
src/lib/rate-limiter.ts Normal file
View file

@ -0,0 +1,70 @@
import type Redis from 'ioredis'
import { getLogger } from './logger.js'
const log = getLogger('rate-limiter')
/**
* Token bucket rate limiter for outgoing WhatsApp messages.
* Meta allows 80 messages/second we cap at 70/s for safety margin.
*/
export class OutgoingRateLimiter {
private readonly maxTokens: number
private readonly refillRate: number // tokens per second
private readonly key = 'wa:ratelimit:outgoing'
constructor(
private redis: Redis,
{ maxPerSecond = 70 }: { maxPerSecond?: number } = {},
) {
this.maxTokens = maxPerSecond
this.refillRate = maxPerSecond
}
async acquire(): Promise<void> {
// Lua script for atomic token bucket
const script = `
local key = KEYS[1]
local max_tokens = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or max_tokens
local last_refill = tonumber(data[2]) or now
-- Refill tokens based on elapsed time
local elapsed = now - last_refill
tokens = math.min(max_tokens, tokens + elapsed * refill_rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 10)
return 0 -- success
else
return 1 -- rate limited
end
`
const now = Date.now() / 1000
let attempts = 0
const maxAttempts = 50 // ~500ms max wait
while (attempts < maxAttempts) {
const result = await this.redis.eval(
script,
1,
this.key,
this.maxTokens,
this.refillRate,
now + attempts * 0.01,
)
if (result === 0) return
attempts++
await new Promise((resolve) => setTimeout(resolve, 10))
}
log.warn('Rate limit exceeded after max attempts, proceeding anyway')
}
}

105
src/llm/ClaudeProvider.ts Normal file
View file

@ -0,0 +1,105 @@
import Anthropic from '@anthropic-ai/sdk'
import { getLogger } from '../lib/logger.js'
import type { LLMProvider, LLMRequest, LLMResponse } from './LLMProvider.js'
const log = getLogger('claude-provider')
const MEDICAL_KEYWORDS = [
'diagnose',
'symptom',
'medikament',
'therapie',
'behandlung',
'schmerz',
'blut',
'operation',
'arzt',
'krankenhaus',
'notfall',
'tumor',
'krebs',
'chemo',
'bestrahlung',
'nebenwirkung',
'dosis',
'rezept',
'krankheit',
'infektion',
'diagnosis',
'symptom',
'medication',
'treatment',
'pain',
'emergency',
'cancer',
'disease',
]
export class ClaudeProvider implements LLMProvider {
readonly name = 'claude'
private client: Anthropic
private model: string
constructor(apiKey: string, model = 'claude-3-5-haiku-20241022') {
this.client = new Anthropic({ apiKey })
this.model = model
}
async generateResponse(request: LLMRequest): Promise<LLMResponse> {
const { systemPrompt, conversationHistory, userMessage, maxTokens = 500 } =
request
const isMedical = this.detectMedicalContent(userMessage)
const messages: Anthropic.MessageParam[] = [
...conversationHistory.map((msg) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
})),
{ role: 'user', content: userMessage },
]
// Append medical warning to system prompt if needed
const effectiveSystemPrompt = isMedical
? `${systemPrompt}\n\nWICHTIG: Diese Nachricht enthält möglicherweise medizinische Fragen. Gib KEINE medizinischen Diagnosen, Empfehlungen oder Behandlungsvorschläge. Verweise stattdessen auf die CCS-Hotline oder den Notruf 112 bei Notfällen.`
: systemPrompt
try {
const response = await this.client.messages.create({
model: this.model,
max_tokens: maxTokens,
system: effectiveSystemPrompt,
messages,
})
const text =
response.content[0].type === 'text' ? response.content[0].text : ''
log.info(
{
model: response.model,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
isMedical,
},
'Claude response generated',
)
return {
text,
model: response.model,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
isMedicalQuestion: isMedical,
}
} catch (err) {
log.error({ error: (err as Error).message }, 'Claude API error')
throw err
}
}
private detectMedicalContent(text: string): boolean {
const lower = text.toLowerCase()
return MEDICAL_KEYWORDS.some((keyword) => lower.includes(keyword))
}
}

View file

@ -0,0 +1,31 @@
/**
* Default system prompt for the CCS WhatsApp bot.
* This is the fallback the primary prompt is loaded from Payload CMS
* as a CommunityTemplate (category: whatsapp_system_prompt).
*/
export const DEFAULT_HEALTHCARE_PROMPT = `Du bist der virtuelle Assistent der Complex Care Solutions GmbH (CCS).
CCS ist ein Gesundheitsunternehmen, das medizinische Zweitmeinungen vermittelt.
WICHTIGE REGELN:
1. Du darfst KEINE medizinischen Diagnosen stellen oder Behandlungsempfehlungen geben.
2. Bei medizinischen Fragen verweise IMMER auf:
- CCS-Hotline: +49 174 3032053 (Mo-Fr 9-17 Uhr)
- Bei Notfällen: Notruf 112
3. Du beantwortest allgemeine Fragen zu:
- Ablauf einer Zweitmeinung
- Kosten und Erstattung durch Krankenkassen
- Datenschutz und Vertraulichkeit
- Öffnungszeiten und Kontaktmöglichkeiten
- Allgemeine Informationen über CCS
4. Antworte auf Deutsch, kurz (3-4 Sätze), empathisch und professionell.
5. Wenn du dir unsicher bist, sage ehrlich, dass du die Frage an einen Mitarbeiter weiterleiten wirst.
6. Verwende keine Emojis außer gelegentlich oder .
7. Sprich den Nutzer mit "Sie" an.
ÜBER CCS:
- Vermittlung medizinischer Zweitmeinungen durch Fachärzte
- Beratung zu Behandlungsoptionen (ohne eigene Empfehlung)
- Zusammenarbeit mit spezialisierten Kliniken und Ärzten
- Kostenübernahme durch viele gesetzliche und private Krankenkassen möglich
- Standort: Deutschland
- Website: complexcaresolutions.de`

25
src/llm/LLMProvider.ts Normal file
View file

@ -0,0 +1,25 @@
export interface ConversationMessage {
role: 'user' | 'assistant'
content: string
timestamp?: Date
}
export interface LLMRequest {
systemPrompt: string
conversationHistory: ConversationMessage[]
userMessage: string
maxTokens?: number
}
export interface LLMResponse {
text: string
model: string
inputTokens: number
outputTokens: number
isMedicalQuestion: boolean
}
export interface LLMProvider {
generateResponse(request: LLMRequest): Promise<LLMResponse>
readonly name: string
}

12
src/llm/OllamaProvider.ts Normal file
View file

@ -0,0 +1,12 @@
import type { LLMProvider, LLMRequest, LLMResponse } from './LLMProvider.js'
/**
* Ollama provider stub for local/private LLM deployment.
*/
export class OllamaProvider implements LLMProvider {
readonly name = 'ollama'
async generateResponse(_request: LLMRequest): Promise<LLMResponse> {
throw new Error('Ollama provider not yet implemented')
}
}

12
src/llm/OpenAIProvider.ts Normal file
View file

@ -0,0 +1,12 @@
import type { LLMProvider, LLMRequest, LLMResponse } from './LLMProvider.js'
/**
* OpenAI provider stub implement when needed.
*/
export class OpenAIProvider implements LLMProvider {
readonly name = 'openai'
async generateResponse(_request: LLMRequest): Promise<LLMResponse> {
throw new Error('OpenAI provider not yet implemented')
}
}

View file

@ -0,0 +1,109 @@
import type { Pool } from 'pg'
import { getLogger } from '../lib/logger.js'
import type { NormalizedMessage } from '../whatsapp/types.js'
import type { LLMResponse } from '../llm/LLMProvider.js'
import { getConfig } from '../config.js'
const log = getLogger('interaction-writer')
/**
* Writes messages directly to the community_interactions table.
* Uses direct DB access for speed the Payload REST API adds unnecessary
* overhead for high-volume message storage.
*/
export class InteractionWriter {
private platformId: number | null = null
constructor(private db: Pool) {}
/**
* Resolve the WhatsApp platform ID from social_platforms table.
* Called once on startup.
*/
async init(): Promise<void> {
const result = await this.db.query<{ id: number }>(
`SELECT id FROM social_platforms WHERE name ILIKE $1 LIMIT 1`,
['whatsapp'],
)
if (result.rows[0]) {
this.platformId = result.rows[0].id
log.info({ platformId: this.platformId }, 'WhatsApp platform resolved')
} else {
log.warn(
'WhatsApp platform not found in social_platforms — interactions will be stored without platform reference',
)
}
}
async writeIncoming(
message: NormalizedMessage,
analysis: LLMResponse | null,
): Promise<number> {
const tenantId = getConfig().CCS_TENANT_ID
const result = await this.db.query<{ id: number }>(
`INSERT INTO community_interactions (
platform_id, type, external_id,
author_name, author_handle,
message, message_type,
analysis_sentiment, analysis_is_medical_question,
status, tenant_id,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
RETURNING id`,
[
this.platformId,
'dm',
message.messageId,
message.senderName,
message.from,
message.text ?? `[${message.type}]`,
message.type,
analysis ? 'neutral' : null,
analysis?.isMedicalQuestion ?? false,
'new',
tenantId,
],
)
const id = result.rows[0].id
log.debug({ id, messageId: message.messageId }, 'Incoming message stored')
return id
}
async writeOutgoing(
to: string,
text: string,
replyToMessageId: string,
): Promise<number> {
const tenantId = getConfig().CCS_TENANT_ID
const result = await this.db.query<{ id: number }>(
`INSERT INTO community_interactions (
platform_id, type,
author_name, author_handle,
message, message_type,
status, response_text, response_type,
tenant_id,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING id`,
[
this.platformId,
'dm',
'CCS Bot',
'bot',
text,
'text',
'responded',
text,
'bot',
tenantId,
],
)
const id = result.rows[0].id
log.debug({ id, to, replyTo: replyToMessageId }, 'Outgoing message stored')
return id
}
}

View file

@ -0,0 +1,72 @@
import { getLogger } from '../lib/logger.js'
import { getConfig } from '../config.js'
const log = getLogger('payload-client')
export class PayloadClient {
private baseUrl: string
private apiKey?: string
constructor() {
const config = getConfig()
this.baseUrl = config.PAYLOAD_API_URL
this.apiKey = config.PAYLOAD_API_KEY
}
async find<T>(
collection: string,
query: Record<string, unknown> = {},
): Promise<{ docs: T[]; totalDocs: number }> {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
params.set(key, String(value))
}
}
const url = `${this.baseUrl}/${collection}?${params.toString()}`
const response = await this.request<{ docs: T[]; totalDocs: number }>(url)
return response
}
async findByID<T>(collection: string, id: number | string): Promise<T> {
const url = `${this.baseUrl}/${collection}/${id}`
return this.request<T>(url)
}
async create<T>(
collection: string,
data: Record<string, unknown>,
): Promise<T> {
const url = `${this.baseUrl}/${collection}`
return this.request<T>(url, {
method: 'POST',
body: JSON.stringify(data),
})
}
private async request<T>(url: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`
}
const response = await fetch(url, {
...init,
headers: { ...headers, ...init?.headers },
})
if (!response.ok) {
const body = await response.text().catch(() => '')
log.error(
{ url, status: response.status, body: body.slice(0, 200) },
'Payload API error',
)
throw new Error(`Payload API error: ${response.status}`)
}
return response.json() as Promise<T>
}
}

124
src/payload/RulesLoader.ts Normal file
View file

@ -0,0 +1,124 @@
import { getLogger } from '../lib/logger.js'
import type { PayloadClient } from './PayloadClient.js'
import { getConfig } from '../config.js'
const log = getLogger('rules-loader')
interface CommunityRule {
id: number
name: string
platform: string | { name: string }
triggerType: string
triggerKeywords?: string[]
action: string
replyTemplate?: string | { content: string }
isActive: boolean
tenant?: number | { id: number }
}
export interface MatchedRule {
ruleName: string
action: 'auto_reply' | 'escalate' | 'tag' | 'ignore'
replyText: string | null
}
interface RuleMatchContext {
isFirstContact: boolean
phone: string
}
const RULES_CACHE_TTL = 300_000 // 5 minutes
export class RulesLoader {
private cachedRules: CommunityRule[] = []
private lastFetch = 0
constructor(private payloadClient: PayloadClient) {}
async findMatchingRule(
messageText: string,
context: RuleMatchContext,
): Promise<MatchedRule | null> {
const rules = await this.loadRules()
const lower = messageText.toLowerCase()
for (const rule of rules) {
if (!rule.isActive) continue
// Check platform match (whatsapp or all)
const platformName =
typeof rule.platform === 'string'
? rule.platform
: rule.platform?.name ?? ''
if (
platformName.toLowerCase() !== 'whatsapp' &&
platformName.toLowerCase() !== 'all'
) {
continue
}
// Check trigger
let matches = false
switch (rule.triggerType) {
case 'keyword':
matches =
rule.triggerKeywords?.some((kw) =>
lower.includes(kw.toLowerCase()),
) ?? false
break
case 'first_contact':
matches = context.isFirstContact
break
case 'all':
matches = true
break
}
if (matches) {
const replyText =
typeof rule.replyTemplate === 'string'
? rule.replyTemplate
: rule.replyTemplate?.content ?? null
log.info({ ruleName: rule.name, action: rule.action }, 'Rule matched')
return {
ruleName: rule.name,
action: rule.action as MatchedRule['action'],
replyText,
}
}
}
return null
}
private async loadRules(): Promise<CommunityRule[]> {
const now = Date.now()
if (now - this.lastFetch < RULES_CACHE_TTL && this.cachedRules.length > 0) {
return this.cachedRules
}
try {
const tenantId = getConfig().CCS_TENANT_ID
const result = await this.payloadClient.find<CommunityRule>(
'community-rules',
{
'where[tenant][equals]': tenantId,
'where[isActive][equals]': 'true',
limit: '100',
},
)
this.cachedRules = result.docs
this.lastFetch = now
log.info({ count: result.docs.length }, 'Community rules loaded')
} catch (err) {
log.error(
{ error: (err as Error).message },
'Failed to load community rules, using cache',
)
}
return this.cachedRules
}
}

View file

@ -0,0 +1,89 @@
import { getLogger } from '../lib/logger.js'
import type { PayloadClient } from './PayloadClient.js'
import { getConfig } from '../config.js'
import { DEFAULT_HEALTHCARE_PROMPT } from '../llm/HealthcarePrompt.js'
const log = getLogger('template-resolver')
interface CommunityTemplate {
id: number
name: string
category: string
content: string
tenant?: number | { id: number }
}
const TEMPLATE_CACHE_TTL = 600_000 // 10 minutes
export class TemplateResolver {
private systemPromptCache: string | null = null
private lastFetch = 0
constructor(private payloadClient: PayloadClient) {}
async getSystemPrompt(): Promise<string> {
const now = Date.now()
if (now - this.lastFetch < TEMPLATE_CACHE_TTL && this.systemPromptCache) {
return this.systemPromptCache
}
try {
const tenantId = getConfig().CCS_TENANT_ID
const result = await this.payloadClient.find<CommunityTemplate>(
'community-templates',
{
'where[category][equals]': 'whatsapp_system_prompt',
'where[tenant][equals]': tenantId,
limit: '1',
},
)
if (result.docs.length > 0) {
this.systemPromptCache = result.docs[0].content
this.lastFetch = now
log.info('System prompt loaded from CMS')
return this.systemPromptCache
}
} catch (err) {
log.error(
{ error: (err as Error).message },
'Failed to load system prompt from CMS',
)
}
// Fallback to hardcoded prompt
log.info('Using default healthcare prompt')
return DEFAULT_HEALTHCARE_PROMPT
}
async resolveTemplate(
category: string,
name?: string,
): Promise<string | null> {
try {
const tenantId = getConfig().CCS_TENANT_ID
const query: Record<string, unknown> = {
'where[category][equals]': category,
'where[tenant][equals]': tenantId,
limit: '1',
}
if (name) {
query['where[name][equals]'] = name
}
const result =
await this.payloadClient.find<CommunityTemplate>(
'community-templates',
query,
)
return result.docs[0]?.content ?? null
} catch (err) {
log.error(
{ category, name, error: (err as Error).message },
'Failed to resolve template',
)
return null
}
}
}

21
src/queue/message-job.ts Normal file
View file

@ -0,0 +1,21 @@
import type { NormalizedMessage } from '../whatsapp/types.js'
import type { MessageStatus } from '../whatsapp/types.js'
export const INCOMING_MESSAGE_QUEUE = 'whatsapp:incoming'
export const STATUS_UPDATE_QUEUE = 'whatsapp:status'
export interface IncomingMessageJobData {
message: NormalizedMessage
receivedAt: string
}
export interface StatusUpdateJobData {
status: MessageStatus
receivedAt: string
}
export interface IncomingMessageJobResult {
action: string
reason?: string
responseLength?: number
}

135
src/queue/message-worker.ts Normal file
View file

@ -0,0 +1,135 @@
import { Worker, type Job } from 'bullmq'
import type Redis from 'ioredis'
import { getLogger } from '../lib/logger.js'
import { MessageDeduplicator } from '../lib/deduplication.js'
import { OutgoingRateLimiter } from '../lib/rate-limiter.js'
import type { MessageRouter } from '../bot/MessageRouter.js'
import type { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
import {
INCOMING_MESSAGE_QUEUE,
STATUS_UPDATE_QUEUE,
type IncomingMessageJobData,
type IncomingMessageJobResult,
type StatusUpdateJobData,
} from './message-job.js'
const log = getLogger('message-worker')
interface MessageWorkerDeps {
redis: Redis
messageRouter: MessageRouter
whatsappClient: WhatsAppClient
}
export class MessageWorkerManager {
private incomingWorker: Worker<IncomingMessageJobData, IncomingMessageJobResult> | null = null
private statusWorker: Worker<StatusUpdateJobData, void> | null = null
private deduplicator: MessageDeduplicator
private rateLimiter: OutgoingRateLimiter
constructor(private deps: MessageWorkerDeps) {
this.deduplicator = new MessageDeduplicator(deps.redis)
this.rateLimiter = new OutgoingRateLimiter(deps.redis)
}
async start(): Promise<void> {
const { redis, messageRouter, whatsappClient } = this.deps
// Incoming message worker
this.incomingWorker = new Worker<IncomingMessageJobData, IncomingMessageJobResult>(
INCOMING_MESSAGE_QUEUE,
async (job: Job<IncomingMessageJobData>) => {
const { message } = job.data
const { messageId, from } = message
log.info(
{ jobId: job.id, messageId, from, attempt: job.attemptsMade + 1 },
'Processing incoming message',
)
// Deduplicate
if (await this.deduplicator.isDuplicate(messageId)) {
return { action: 'skipped', reason: 'duplicate' }
}
// Mark as read
try {
await whatsappClient.markAsRead(messageId)
} catch (err) {
log.warn({ messageId, error: (err as Error).message }, 'Failed to mark as read')
}
// Rate limit outgoing before routing (router may send messages)
await this.rateLimiter.acquire()
// Route and process
const result = await messageRouter.route(message)
return {
action: result.action,
reason: 'reason' in result ? (result.reason as string) : undefined,
responseLength:
result.action === 'bot_response'
? result.response.text.length
: undefined,
}
},
{
connection: redis,
concurrency: 3,
stalledInterval: 30_000,
maxStalledCount: 2,
},
)
// Status update worker
this.statusWorker = new Worker<StatusUpdateJobData, void>(
STATUS_UPDATE_QUEUE,
async (job: Job<StatusUpdateJobData>) => {
const { status } = job.data
log.debug(
{ messageId: status.id, status: status.status },
'Message status update',
)
if (status.status === 'failed' && status.errors?.length) {
log.error(
{ messageId: status.id, errors: status.errors },
'Message delivery failed',
)
}
},
{
connection: redis,
concurrency: 5,
},
)
// Event handlers
this.incomingWorker.on('completed', (job) => {
log.debug(
{ jobId: job.id, result: job.returnvalue },
'Message job completed',
)
})
this.incomingWorker.on('failed', (job, err) => {
log.error(
{ jobId: job?.id, error: err.message, attempts: job?.attemptsMade },
'Message job failed',
)
})
this.incomingWorker.on('stalled', (jobId) => {
log.warn({ jobId }, 'Message job stalled')
})
log.info('Message workers started')
}
async stop(): Promise<void> {
await this.incomingWorker?.close()
await this.statusWorker?.close()
log.info('Message workers stopped')
}
}

202
src/server.ts Normal file
View file

@ -0,0 +1,202 @@
import Fastify from 'fastify'
import { Queue } from 'bullmq'
import Redis from 'ioredis'
import pg from 'pg'
import { loadConfig } from './config.js'
import { getLogger } from './lib/logger.js'
import { handleVerification } from './webhook/verification.js'
import { createWebhookHandler } from './webhook/handler.js'
import { WhatsAppClient } from './whatsapp/WhatsAppClient.js'
import { ClaudeProvider } from './llm/ClaudeProvider.js'
import { ConversationManager } from './bot/ConversationManager.js'
import { EscalationManager } from './bot/EscalationManager.js'
import { MessageRouter } from './bot/MessageRouter.js'
import { PayloadClient } from './payload/PayloadClient.js'
import { InteractionWriter } from './payload/InteractionWriter.js'
import { RulesLoader } from './payload/RulesLoader.js'
import { TemplateResolver } from './payload/TemplateResolver.js'
import { MessageWorkerManager } from './queue/message-worker.js'
import {
INCOMING_MESSAGE_QUEUE,
STATUS_UPDATE_QUEUE,
type IncomingMessageJobData,
type StatusUpdateJobData,
} from './queue/message-job.js'
const config = loadConfig()
const log = getLogger('server')
// --- Initialize infrastructure ---
const redis = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null, // Required by BullMQ
enableReadyCheck: false,
})
const dbPool = new pg.Pool({
connectionString: config.DATABASE_URL,
max: 5,
})
// --- Initialize services ---
const whatsappClient = new WhatsAppClient({
phoneNumberId: config.WHATSAPP_PHONE_NUMBER_ID,
accessToken: config.WHATSAPP_ACCESS_TOKEN,
})
const llmProvider = new ClaudeProvider(config.ANTHROPIC_API_KEY)
const conversationManager = new ConversationManager(redis)
const escalationManager = new EscalationManager(conversationManager, whatsappClient)
const payloadClient = new PayloadClient()
const interactionWriter = new InteractionWriter(dbPool)
const rulesLoader = new RulesLoader(payloadClient)
const templateResolver = new TemplateResolver(payloadClient)
const messageRouter = new MessageRouter(
conversationManager,
escalationManager,
whatsappClient,
llmProvider,
rulesLoader,
templateResolver,
interactionWriter,
)
// --- BullMQ queues ---
const incomingQueue = new Queue<IncomingMessageJobData>(INCOMING_MESSAGE_QUEUE, {
connection: redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
})
const statusQueue = new Queue<StatusUpdateJobData>(STATUS_UPDATE_QUEUE, {
connection: redis,
defaultJobOptions: {
attempts: 2,
removeOnComplete: { count: 500 },
removeOnFail: { count: 1000 },
},
})
// --- Workers ---
const workerManager = new MessageWorkerManager({
redis,
messageRouter,
whatsappClient,
})
// --- Fastify server ---
const app = Fastify({
logger: false, // Using pino directly
trustProxy: true,
})
// Custom JSON parser that preserves rawBody for signature validation
app.removeContentTypeParser('application/json')
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
;(req as typeof req & { rawBody: Buffer }).rawBody = body as Buffer
try {
const json = JSON.parse((body as Buffer).toString())
done(null, json)
} catch (err) {
done(err as Error, undefined)
}
},
)
// --- Routes ---
// Meta Webhook verification (GET)
app.get('/webhook', handleVerification)
// Meta Webhook handler (POST)
const webhookHandler = createWebhookHandler({
onMessage: async (message) => {
await incomingQueue.add('process', {
message,
receivedAt: new Date().toISOString(),
})
},
onStatusUpdate: async (status) => {
await statusQueue.add('update', {
status,
receivedAt: new Date().toISOString(),
})
},
})
app.post('/webhook', webhookHandler)
// Health check
app.get('/health', async () => {
const redisOk = redis.status === 'ready'
let dbOk = false
try {
await dbPool.query('SELECT 1')
dbOk = true
} catch {
// DB down
}
const status = redisOk && dbOk ? 'ok' : 'degraded'
return {
status,
timestamp: new Date().toISOString(),
services: {
redis: redisOk ? 'ok' : 'down',
database: dbOk ? 'ok' : 'down',
},
}
})
// --- Lifecycle ---
async function start(): Promise<void> {
try {
// Initialize interaction writer (resolve platform ID)
await interactionWriter.init()
// Start workers
await workerManager.start()
// Start HTTP server
await app.listen({ port: config.PORT, host: '0.0.0.0' })
log.info(
{ port: config.PORT, env: config.NODE_ENV },
'WhatsApp Bot server started',
)
} catch (err) {
log.fatal({ error: (err as Error).message }, 'Failed to start server')
process.exit(1)
}
}
async function shutdown(): Promise<void> {
log.info('Shutting down...')
await workerManager.stop()
await incomingQueue.close()
await statusQueue.close()
await app.close()
await dbPool.end()
redis.disconnect()
log.info('Shutdown complete')
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
start()

87
src/webhook/handler.ts Normal file
View file

@ -0,0 +1,87 @@
import type { FastifyRequest, FastifyReply } from 'fastify'
import { getConfig } from '../config.js'
import { getLogger } from '../lib/logger.js'
import { validateSignature } from './signature.js'
import type { WebhookPayload, WebhookContact, MessageStatus } from '../whatsapp/types.js'
import { normalizeMessage, type NormalizedMessage } from '../whatsapp/types.js'
interface RawBodyRequest extends FastifyRequest {
rawBody?: Buffer
}
const log = getLogger('webhook-handler')
export interface WebhookDeps {
onMessage: (message: NormalizedMessage) => Promise<void>
onStatusUpdate: (status: MessageStatus) => Promise<void>
}
export function createWebhookHandler(deps: WebhookDeps) {
return async function handleWebhook(
request: RawBodyRequest,
reply: FastifyReply,
): Promise<void> {
const config = getConfig()
// Validate signature
const signature = request.headers['x-hub-signature-256'] as
| string
| undefined
const rawBody = request.rawBody
if (!rawBody || !validateSignature(rawBody, signature, config.WHATSAPP_APP_SECRET)) {
log.warn({ ip: request.ip }, 'Webhook signature validation failed')
reply.code(401).send({ error: 'Invalid signature' })
return
}
// Respond immediately — Meta requires < 200ms
reply.code(200).send()
// Process asynchronously
const payload = request.body as WebhookPayload
if (payload.object !== 'whatsapp_business_account') {
log.debug({ object: payload.object }, 'Ignoring non-WhatsApp payload')
return
}
for (const entry of payload.entry) {
for (const change of entry.changes) {
const { value } = change
// Process messages
if (value.messages) {
const contactMap = new Map<string, WebhookContact>()
for (const contact of value.contacts ?? []) {
contactMap.set(contact.wa_id, contact)
}
for (const msg of value.messages) {
try {
const normalized = normalizeMessage(msg, contactMap.get(msg.from))
await deps.onMessage(normalized)
} catch (err) {
log.error(
{ messageId: msg.id, error: (err as Error).message },
'Failed to enqueue message',
)
}
}
}
// Process status updates
if (value.statuses) {
for (const status of value.statuses) {
try {
await deps.onStatusUpdate(status)
} catch (err) {
log.error(
{ messageId: status.id, error: (err as Error).message },
'Failed to process status update',
)
}
}
}
}
}
}
}

45
src/webhook/signature.ts Normal file
View file

@ -0,0 +1,45 @@
import crypto from 'node:crypto'
import { getLogger } from '../lib/logger.js'
const log = getLogger('webhook-signature')
/**
* Validates the X-Hub-Signature-256 header from Meta webhooks.
* Uses timing-safe comparison to prevent timing attacks.
*/
export function validateSignature(
payload: Buffer | string,
signature: string | undefined,
appSecret: string,
): boolean {
if (!signature) {
log.warn('Missing X-Hub-Signature-256 header')
return false
}
const [algorithm, hash] = signature.split('=')
if (algorithm !== 'sha256' || !hash) {
log.warn({ algorithm }, 'Invalid signature format')
return false
}
const expectedHash = crypto
.createHmac('sha256', appSecret)
.update(payload)
.digest('hex')
// Timing-safe comparison
const expectedBuffer = Buffer.from(expectedHash, 'hex')
const receivedBuffer = Buffer.from(hash, 'hex')
if (expectedBuffer.length !== receivedBuffer.length) {
log.warn('Signature length mismatch')
return false
}
const isValid = crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
if (!isValid) {
log.warn('Invalid webhook signature')
}
return isValid
}

View file

@ -0,0 +1,33 @@
import type { FastifyRequest, FastifyReply } from 'fastify'
import { getConfig } from '../config.js'
import { getLogger } from '../lib/logger.js'
const log = getLogger('webhook-verification')
interface VerificationQuery {
'hub.mode'?: string
'hub.verify_token'?: string
'hub.challenge'?: string
}
/**
* Meta Webhook Verification (GET /webhook)
* Meta sends this during webhook setup to verify we own the endpoint.
*/
export async function handleVerification(
request: FastifyRequest<{ Querystring: VerificationQuery }>,
reply: FastifyReply,
): Promise<void> {
const mode = request.query['hub.mode']
const token = request.query['hub.verify_token']
const challenge = request.query['hub.challenge']
if (mode === 'subscribe' && token === getConfig().WHATSAPP_VERIFY_TOKEN) {
log.info('Webhook verification successful')
reply.code(200).type('text/plain').send(challenge)
return
}
log.warn({ mode, hasToken: !!token }, 'Webhook verification failed')
reply.code(403).send({ error: 'Verification failed' })
}

View file

@ -0,0 +1,221 @@
import { getLogger } from '../lib/logger.js'
import type {
SendTextMessagePayload,
SendTemplateMessagePayload,
SendInteractiveMessagePayload,
SendMessageResponse,
MediaUrlResponse,
} from './types.js'
const log = getLogger('whatsapp-client')
const API_BASE = 'https://graph.facebook.com/v21.0'
export class WhatsAppApiError extends Error {
constructor(
message: string,
public statusCode: number,
public errorCode?: number,
public errorSubcode?: number,
) {
super(message)
this.name = 'WhatsAppApiError'
}
isRateLimitError(): boolean {
return this.statusCode === 429 || this.errorCode === 4
}
isAuthError(): boolean {
return this.errorCode === 190
}
isPermissionError(): boolean {
return this.errorCode === 10 || this.errorCode === 200
}
}
interface WhatsAppClientOptions {
phoneNumberId: string
accessToken: string
}
export class WhatsAppClient {
private phoneNumberId: string
private accessToken: string
constructor({ phoneNumberId, accessToken }: WhatsAppClientOptions) {
this.phoneNumberId = phoneNumberId
this.accessToken = accessToken
}
private async request<T>(
path: string,
options: {
method?: string
body?: unknown
params?: Record<string, string>
} = {},
): Promise<T> {
const { method = 'GET', body, params } = options
const url = new URL(`${API_BASE}/${path}`)
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v)
}
}
const headers: Record<string, string> = {
Authorization: `Bearer ${this.accessToken}`,
}
if (body) headers['Content-Type'] = 'application/json'
const response = await this.withRetry(async () => {
const res = await fetch(url.toString(), {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const errorBody = await res.json().catch(() => ({}))
const error = (errorBody as Record<string, Record<string, unknown>>)
.error ?? {}
throw new WhatsAppApiError(
(error.message as string) ?? `HTTP ${res.status}`,
res.status,
error.code as number | undefined,
error.error_subcode as number | undefined,
)
}
return res.json() as Promise<T>
})
return response
}
private async withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
let lastError: Error | null = null
let delay = 1000
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
lastError = err as Error
if (
err instanceof WhatsAppApiError &&
!err.isRateLimitError() &&
err.statusCode < 500
) {
throw err // Don't retry client errors (except rate limits)
}
if (attempt < maxRetries) {
log.warn(
{ attempt, delay, error: (err as Error).message },
'Retrying after error',
)
await new Promise((resolve) => setTimeout(resolve, delay))
delay *= 2
}
}
}
throw lastError!
}
async sendTextMessage(
to: string,
text: string,
previewUrl = false,
): Promise<SendMessageResponse> {
const payload: SendTextMessagePayload = {
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text, preview_url: previewUrl },
}
log.info({ to, textLength: text.length }, 'Sending text message')
return this.request<SendMessageResponse>(
`${this.phoneNumberId}/messages`,
{ method: 'POST', body: payload },
)
}
async sendTemplateMessage(
to: string,
templateName: string,
languageCode = 'de',
components?: SendTemplateMessagePayload['template']['components'],
): Promise<SendMessageResponse> {
const payload: SendTemplateMessagePayload = {
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name: templateName,
language: { code: languageCode },
components,
},
}
log.info({ to, template: templateName }, 'Sending template message')
return this.request<SendMessageResponse>(
`${this.phoneNumberId}/messages`,
{ method: 'POST', body: payload },
)
}
async sendInteractiveMessage(
to: string,
interactive: SendInteractiveMessagePayload['interactive'],
): Promise<SendMessageResponse> {
const payload: SendInteractiveMessagePayload = {
messaging_product: 'whatsapp',
to,
type: 'interactive',
interactive,
}
log.info({ to, interactiveType: interactive.type }, 'Sending interactive message')
return this.request<SendMessageResponse>(
`${this.phoneNumberId}/messages`,
{ method: 'POST', body: payload },
)
}
async markAsRead(messageId: string): Promise<void> {
await this.request(`${this.phoneNumberId}/messages`, {
method: 'POST',
body: {
messaging_product: 'whatsapp',
status: 'read',
message_id: messageId,
},
})
log.debug({ messageId }, 'Marked message as read')
}
async getMediaUrl(mediaId: string): Promise<MediaUrlResponse> {
return this.request<MediaUrlResponse>(mediaId)
}
async downloadMedia(mediaUrl: string): Promise<Buffer> {
const response = await fetch(mediaUrl, {
headers: { Authorization: `Bearer ${this.accessToken}` },
})
if (!response.ok) {
throw new WhatsAppApiError(
`Failed to download media: ${response.status}`,
response.status,
)
}
const arrayBuffer = await response.arrayBuffer()
return Buffer.from(arrayBuffer)
}
}

272
src/whatsapp/types.ts Normal file
View file

@ -0,0 +1,272 @@
// --- Incoming Webhook Types ---
export interface WebhookPayload {
object: 'whatsapp_business_account'
entry: WebhookEntry[]
}
export interface WebhookEntry {
id: string
changes: WebhookChange[]
}
export interface WebhookChange {
value: WebhookChangeValue
field: 'messages' | 'message_delivery_updates' | 'message_reads'
}
export interface WebhookChangeValue {
messaging_product: 'whatsapp'
metadata: {
display_phone_number: string
phone_number_id: string
}
contacts?: WebhookContact[]
messages?: IncomingMessage[]
statuses?: MessageStatus[]
}
export interface WebhookContact {
profile: { name: string }
wa_id: string
}
export interface IncomingMessage {
from: string
id: string
timestamp: string
type: MessageType
text?: { body: string }
image?: MediaMessage
audio?: MediaMessage
video?: MediaMessage
document?: MediaMessage & { filename?: string }
sticker?: MediaMessage
location?: LocationMessage
contacts?: ContactMessage[]
interactive?: InteractiveResponse
button?: { text: string; payload: string }
reaction?: { message_id: string; emoji: string }
context?: {
from: string
id: string
}
}
export type MessageType =
| 'text'
| 'image'
| 'audio'
| 'video'
| 'document'
| 'sticker'
| 'location'
| 'contacts'
| 'interactive'
| 'button'
| 'reaction'
| 'unknown'
export interface MediaMessage {
id: string
mime_type: string
sha256: string
caption?: string
}
export interface LocationMessage {
latitude: number
longitude: number
name?: string
address?: string
}
export interface ContactMessage {
name: { formatted_name: string; first_name?: string; last_name?: string }
phones?: { phone: string; type: string }[]
}
export interface InteractiveResponse {
type: 'button_reply' | 'list_reply'
button_reply?: { id: string; title: string }
list_reply?: { id: string; title: string; description?: string }
}
// --- Message Status Updates ---
export interface MessageStatus {
id: string
status: 'sent' | 'delivered' | 'read' | 'failed'
timestamp: string
recipient_id: string
errors?: MessageError[]
}
export interface MessageError {
code: number
title: string
message: string
error_data?: { details: string }
}
// --- Outgoing Message Types ---
export interface SendTextMessagePayload {
messaging_product: 'whatsapp'
to: string
type: 'text'
text: { body: string; preview_url?: boolean }
}
export interface SendTemplateMessagePayload {
messaging_product: 'whatsapp'
to: string
type: 'template'
template: {
name: string
language: { code: string }
components?: TemplateComponent[]
}
}
export interface TemplateComponent {
type: 'header' | 'body' | 'button'
parameters: TemplateParameter[]
}
export interface TemplateParameter {
type: 'text' | 'image' | 'document' | 'video'
text?: string
image?: { link: string }
}
export interface SendInteractiveMessagePayload {
messaging_product: 'whatsapp'
to: string
type: 'interactive'
interactive: InteractiveContent
}
export interface InteractiveContent {
type: 'button' | 'list'
header?: { type: 'text'; text: string }
body: { text: string }
footer?: { text: string }
action: InteractiveAction
}
export interface InteractiveAction {
buttons?: InteractiveButton[]
button?: string
sections?: InteractiveSection[]
}
export interface InteractiveButton {
type: 'reply'
reply: { id: string; title: string }
}
export interface InteractiveSection {
title: string
rows: { id: string; title: string; description?: string }[]
}
// --- API Response Types ---
export interface SendMessageResponse {
messaging_product: 'whatsapp'
contacts: { input: string; wa_id: string }[]
messages: { id: string }[]
}
export interface MediaUrlResponse {
url: string
mime_type: string
sha256: string
file_size: number
id: string
}
// --- Normalized Message ---
export interface NormalizedMessage {
messageId: string
from: string
senderName: string
timestamp: Date
type: MessageType
text: string | null
mediaId: string | null
mediaType: string | null
caption: string | null
isReply: boolean
replyToMessageId: string | null
raw: IncomingMessage
}
export function normalizeMessage(
msg: IncomingMessage,
contact?: WebhookContact,
): NormalizedMessage {
let text: string | null = null
let mediaId: string | null = null
let mediaType: string | null = null
let caption: string | null = null
switch (msg.type) {
case 'text':
text = msg.text?.body ?? null
break
case 'image':
mediaId = msg.image?.id ?? null
mediaType = msg.image?.mime_type ?? null
caption = msg.image?.caption ?? null
break
case 'audio':
mediaId = msg.audio?.id ?? null
mediaType = msg.audio?.mime_type ?? null
break
case 'video':
mediaId = msg.video?.id ?? null
mediaType = msg.video?.mime_type ?? null
caption = msg.video?.caption ?? null
break
case 'document':
mediaId = msg.document?.id ?? null
mediaType = msg.document?.mime_type ?? null
caption = msg.document?.filename ?? null
break
case 'interactive':
text =
msg.interactive?.button_reply?.title ??
msg.interactive?.list_reply?.title ??
null
break
case 'button':
text = msg.button?.text ?? null
break
case 'location':
text = msg.location
? `📍 ${msg.location.name ?? ''} ${msg.location.address ?? ''} (${msg.location.latitude}, ${msg.location.longitude})`
: null
break
case 'reaction':
text = msg.reaction?.emoji ?? null
break
}
return {
messageId: msg.id,
from: msg.from,
senderName: contact?.profile.name ?? msg.from,
timestamp: new Date(parseInt(msg.timestamp) * 1000),
type: msg.type,
text,
mediaId,
mediaType,
caption,
isReply: !!msg.context,
replyToMessageId: msg.context?.id ?? null,
raw: msg,
}
}

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}