mirror of
https://github.com/complexcaresolutions/whatsapp-bot.git
synced 2026-03-17 13:54:05 +00:00
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:
commit
8847358507
30 changed files with 3660 additions and 0 deletions
27
.env.example
Normal file
27
.env.example
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
19
ecosystem.config.cjs
Normal file
19
ecosystem.config.cjs
Normal 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
32
package.json
Normal 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
1437
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
111
src/bot/ConversationManager.ts
Normal file
111
src/bot/ConversationManager.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
81
src/bot/EscalationManager.ts
Normal file
81
src/bot/EscalationManager.ts
Normal 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
140
src/bot/MessageRouter.ts
Normal 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
54
src/config.ts
Normal 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
20
src/lib/deduplication.ts
Normal 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
43
src/lib/logger.ts
Normal 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
70
src/lib/rate-limiter.ts
Normal 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
105
src/llm/ClaudeProvider.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
31
src/llm/HealthcarePrompt.ts
Normal file
31
src/llm/HealthcarePrompt.ts
Normal 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
25
src/llm/LLMProvider.ts
Normal 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
12
src/llm/OllamaProvider.ts
Normal 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
12
src/llm/OpenAIProvider.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
109
src/payload/InteractionWriter.ts
Normal file
109
src/payload/InteractionWriter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
72
src/payload/PayloadClient.ts
Normal file
72
src/payload/PayloadClient.ts
Normal 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
124
src/payload/RulesLoader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
89
src/payload/TemplateResolver.ts
Normal file
89
src/payload/TemplateResolver.ts
Normal 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
21
src/queue/message-job.ts
Normal 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
135
src/queue/message-worker.ts
Normal 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
202
src/server.ts
Normal 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
87
src/webhook/handler.ts
Normal 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
45
src/webhook/signature.ts
Normal 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
|
||||
}
|
||||
33
src/webhook/verification.ts
Normal file
33
src/webhook/verification.ts
Normal 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' })
|
||||
}
|
||||
221
src/whatsapp/WhatsAppClient.ts
Normal file
221
src/whatsapp/WhatsAppClient.ts
Normal 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
272
src/whatsapp/types.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue