mirror of
https://github.com/complexcaresolutions/whatsapp-bot.git
synced 2026-03-17 17:24:06 +00:00
feat: add business hours, media handling, and DSGVO consent
- BusinessHoursChecker: loads schedule from CMS site-settings, sends auto-away messages outside business hours with next-open time - Media handling: image+caption forwarded to LLM, image-only/doc/audio/ video get appropriate responses, stickers ignored, location acknowledged - DSGVO ConsentManager: interactive buttons for consent, revocation keywords, consent logging via API key to consent-logs collection - ConversationManager: consent fields (consentGiven, consentTimestamp) with grantConsent/revokeConsent methods - InteractionWriter: media attachments stored as whatsapp-media references - MessageRouter: integrates all features in order: business hours → consent → media → escalation → rules → LLM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4b8fabc204
commit
81e09a4e2b
7 changed files with 537 additions and 17 deletions
201
src/bot/BusinessHoursChecker.ts
Normal file
201
src/bot/BusinessHoursChecker.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { getLogger } from '../lib/logger.js'
|
||||
import { getConfig } from '../config.js'
|
||||
import type { PayloadClient } from '../payload/PayloadClient.js'
|
||||
|
||||
const log = getLogger('business-hours')
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
type DayName = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'
|
||||
|
||||
interface ScheduleEntry {
|
||||
day: DayName
|
||||
closed: boolean
|
||||
open?: string // "09:00"
|
||||
close?: string // "17:00"
|
||||
}
|
||||
|
||||
interface BusinessHoursSettings {
|
||||
timezone: string
|
||||
schedule: ScheduleEntry[]
|
||||
autoAwayMessage: string | null
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
isOpen: boolean
|
||||
message: string | null
|
||||
}
|
||||
|
||||
// JS getDay(): 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
const JS_DAY_TO_NAME: DayName[] = [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
]
|
||||
|
||||
export class BusinessHoursChecker {
|
||||
private cache: { settings: BusinessHoursSettings; fetchedAt: number } | null = null
|
||||
|
||||
constructor(private payloadClient: PayloadClient) {}
|
||||
|
||||
async check(): Promise<CheckResult> {
|
||||
const settings = await this.getSettings()
|
||||
if (!settings || settings.schedule.length === 0) {
|
||||
// No business hours configured → always open
|
||||
return { isOpen: true, message: null }
|
||||
}
|
||||
|
||||
const { timezone, schedule, autoAwayMessage } = settings
|
||||
const now = this.getNowInTimezone(timezone)
|
||||
const dayName = JS_DAY_TO_NAME[now.dayOfWeek]
|
||||
|
||||
const todayEntry = schedule.find((e) => e.day === dayName)
|
||||
if (!todayEntry || todayEntry.closed) {
|
||||
const nextOpen = this.getNextOpenTime(schedule, now, timezone)
|
||||
const message = this.formatAwayMessage(autoAwayMessage, nextOpen)
|
||||
return { isOpen: false, message }
|
||||
}
|
||||
|
||||
// Check if within open/close times
|
||||
const { open, close } = todayEntry
|
||||
if (!open || !close) {
|
||||
// No times specified → treat as open all day
|
||||
return { isOpen: true, message: null }
|
||||
}
|
||||
|
||||
const currentMinutes = now.hours * 60 + now.minutes
|
||||
const openMinutes = this.parseTime(open)
|
||||
const closeMinutes = this.parseTime(close)
|
||||
|
||||
if (currentMinutes >= openMinutes && currentMinutes < closeMinutes) {
|
||||
return { isOpen: true, message: null }
|
||||
}
|
||||
|
||||
const nextOpen = this.getNextOpenTime(schedule, now, timezone)
|
||||
const message = this.formatAwayMessage(autoAwayMessage, nextOpen)
|
||||
return { isOpen: false, message }
|
||||
}
|
||||
|
||||
private async getSettings(): Promise<BusinessHoursSettings | null> {
|
||||
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return this.cache.settings
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfig()
|
||||
const result = await this.payloadClient.find<{
|
||||
id: number
|
||||
businessHours?: {
|
||||
timezone?: string
|
||||
schedule?: ScheduleEntry[]
|
||||
autoAwayMessage?: string
|
||||
}
|
||||
}>('site-settings', {
|
||||
'where[tenant][equals]': String(config.CCS_TENANT_ID),
|
||||
limit: '1',
|
||||
})
|
||||
|
||||
if (!result.docs.length) {
|
||||
log.warn('No site settings found for CCS tenant')
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = result.docs[0]
|
||||
const bh = doc.businessHours
|
||||
if (!bh) return null
|
||||
|
||||
const settings: BusinessHoursSettings = {
|
||||
timezone: bh.timezone || 'Europe/Berlin',
|
||||
schedule: bh.schedule || [],
|
||||
autoAwayMessage: bh.autoAwayMessage || null,
|
||||
}
|
||||
|
||||
this.cache = { settings, fetchedAt: Date.now() }
|
||||
log.debug({ timezone: settings.timezone, entries: settings.schedule.length }, 'Business hours loaded')
|
||||
return settings
|
||||
} catch (err) {
|
||||
log.error({ error: (err as Error).message }, 'Failed to load business hours')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getNowInTimezone(timezone: string): { dayOfWeek: number; hours: number; minutes: number } {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(new Date())
|
||||
const weekday = parts.find((p) => p.type === 'weekday')?.value
|
||||
const hour = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10)
|
||||
const minute = parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10)
|
||||
|
||||
// Map short weekday name to JS day number
|
||||
const dayMap: Record<string, number> = {
|
||||
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
||||
}
|
||||
|
||||
return {
|
||||
dayOfWeek: dayMap[weekday ?? 'Mon'] ?? 1,
|
||||
hours: hour,
|
||||
minutes: minute,
|
||||
}
|
||||
}
|
||||
|
||||
private parseTime(time: string): number {
|
||||
const [h, m] = time.split(':').map(Number)
|
||||
return (h ?? 0) * 60 + (m ?? 0)
|
||||
}
|
||||
|
||||
private getNextOpenTime(
|
||||
schedule: ScheduleEntry[],
|
||||
now: { dayOfWeek: number; hours: number; minutes: number },
|
||||
_timezone: string,
|
||||
): string {
|
||||
const dayLabels: Record<DayName, string> = {
|
||||
monday: 'Montag',
|
||||
tuesday: 'Dienstag',
|
||||
wednesday: 'Mittwoch',
|
||||
thursday: 'Donnerstag',
|
||||
friday: 'Freitag',
|
||||
saturday: 'Samstag',
|
||||
sunday: 'Sonntag',
|
||||
}
|
||||
|
||||
// Check remaining days (starting from today+1, wrapping around)
|
||||
for (let offset = 0; offset <= 7; offset++) {
|
||||
const checkJsDay = (now.dayOfWeek + offset) % 7
|
||||
const checkDayName = JS_DAY_TO_NAME[checkJsDay]
|
||||
const entry = schedule.find((e) => e.day === checkDayName)
|
||||
|
||||
if (entry && !entry.closed && entry.open) {
|
||||
if (offset === 0) {
|
||||
// Today — only if the open time is still ahead
|
||||
const openMin = this.parseTime(entry.open)
|
||||
const nowMin = now.hours * 60 + now.minutes
|
||||
if (openMin > nowMin) {
|
||||
return `heute um ${entry.open} Uhr`
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (offset === 1) {
|
||||
return `morgen um ${entry.open} Uhr`
|
||||
}
|
||||
return `${dayLabels[checkDayName]} um ${entry.open} Uhr`
|
||||
}
|
||||
}
|
||||
|
||||
return 'am nächsten Werktag'
|
||||
}
|
||||
|
||||
private formatAwayMessage(template: string | null, nextOpen: string): string {
|
||||
const defaultMsg =
|
||||
`Vielen Dank für Ihre Nachricht. Wir sind gerade nicht erreichbar. ` +
|
||||
`Wir sind wieder ${nextOpen} für Sie da.\n\n` +
|
||||
`In dringenden Fällen erreichen Sie uns telefonisch unter +49 174 3032053.`
|
||||
|
||||
if (!template) return defaultMsg
|
||||
return template.replace(/\{\{nextOpen\}\}/g, nextOpen)
|
||||
}
|
||||
}
|
||||
155
src/bot/ConsentManager.ts
Normal file
155
src/bot/ConsentManager.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { getLogger } from '../lib/logger.js'
|
||||
import { getConfig } from '../config.js'
|
||||
import type { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
|
||||
import type { ConversationManager } from './ConversationManager.js'
|
||||
|
||||
const log = getLogger('consent-manager')
|
||||
|
||||
const CONSENT_ACCEPT_ID = 'consent_accept'
|
||||
const CONSENT_DECLINE_ID = 'consent_decline'
|
||||
|
||||
const REVOCATION_KEYWORDS = [
|
||||
'einwilligung widerrufen',
|
||||
'daten löschen',
|
||||
'opt out',
|
||||
'widerruf',
|
||||
'daten entfernen',
|
||||
'datenlöschung',
|
||||
]
|
||||
|
||||
export class ConsentManager {
|
||||
private apiKey: string | null
|
||||
|
||||
constructor(
|
||||
private whatsappClient: WhatsAppClient,
|
||||
private conversationManager: ConversationManager,
|
||||
private payloadApiUrl: string,
|
||||
) {
|
||||
const config = getConfig()
|
||||
this.apiKey = config.CONSENT_LOGGING_API_KEY || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text message contains a consent revocation keyword.
|
||||
*/
|
||||
isRevocationRequest(text: string): boolean {
|
||||
const lower = text.toLowerCase().trim()
|
||||
return REVOCATION_KEYWORDS.some((kw) => lower.includes(kw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a button response is a consent accept/decline.
|
||||
*/
|
||||
isConsentResponse(buttonId: string): boolean {
|
||||
return buttonId === CONSENT_ACCEPT_ID || buttonId === CONSENT_DECLINE_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Send DSGVO consent request as interactive buttons.
|
||||
*/
|
||||
async requestConsent(phone: string): Promise<void> {
|
||||
log.info({ phone }, 'Requesting DSGVO consent')
|
||||
|
||||
await this.whatsappClient.sendInteractiveMessage(phone, {
|
||||
type: 'button',
|
||||
body: {
|
||||
text:
|
||||
'Willkommen bei Complex Care Solutions! 🏥\n\n' +
|
||||
'Bevor wir Ihnen weiterhelfen können, benötigen wir Ihre Einwilligung zur Datenverarbeitung gemäß DSGVO.\n\n' +
|
||||
'Ihre Nachricht wird von einem KI-Assistenten verarbeitet. Ihre Daten werden vertraulich behandelt und ' +
|
||||
'nach 24 Stunden automatisch gelöscht.\n\n' +
|
||||
'Datenschutzerklärung: https://complexcaresolutions.de/datenschutz',
|
||||
},
|
||||
footer: { text: 'Complex Care Solutions GmbH' },
|
||||
action: {
|
||||
buttons: [
|
||||
{ type: 'reply', reply: { id: CONSENT_ACCEPT_ID, title: 'Ich stimme zu ✅' } },
|
||||
{ type: 'reply', reply: { id: CONSENT_DECLINE_ID, title: 'Ablehnen' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle consent button response.
|
||||
*/
|
||||
async handleConsentResponse(
|
||||
phone: string,
|
||||
buttonId: string,
|
||||
): Promise<{ accepted: boolean }> {
|
||||
if (buttonId === CONSENT_ACCEPT_ID) {
|
||||
await this.conversationManager.grantConsent(phone)
|
||||
await this.logConsent(phone, true)
|
||||
await this.whatsappClient.sendTextMessage(
|
||||
phone,
|
||||
'Vielen Dank für Ihre Einwilligung! Wie kann ich Ihnen helfen?',
|
||||
)
|
||||
log.info({ phone }, 'Consent granted')
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
// Declined
|
||||
await this.logConsent(phone, false)
|
||||
await this.whatsappClient.sendTextMessage(
|
||||
phone,
|
||||
'Wir respektieren Ihre Entscheidung. Ohne Einwilligung können wir leider keine automatische ' +
|
||||
'Bearbeitung Ihrer Anfrage vornehmen.\n\n' +
|
||||
'Sie erreichen uns telefonisch unter:\n📞 +49 174 3032053\n\n' +
|
||||
'Montag bis Freitag, 09:00 – 17:00 Uhr',
|
||||
)
|
||||
log.info({ phone }, 'Consent declined')
|
||||
return { accepted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle consent revocation request.
|
||||
*/
|
||||
async handleRevocation(phone: string): Promise<void> {
|
||||
await this.conversationManager.revokeConsent(phone)
|
||||
await this.logConsent(phone, false)
|
||||
await this.whatsappClient.sendTextMessage(
|
||||
phone,
|
||||
'Ihre Einwilligung wurde widerrufen. Ihre Gesprächsdaten werden gelöscht.\n\n' +
|
||||
'Sie erreichen uns telefonisch unter:\n📞 +49 174 3032053\n\n' +
|
||||
'Montag bis Freitag, 09:00 – 17:00 Uhr',
|
||||
)
|
||||
log.info({ phone }, 'Consent revoked')
|
||||
}
|
||||
|
||||
/**
|
||||
* Log consent action to Payload CMS consent-logs collection (via API key).
|
||||
*/
|
||||
private async logConsent(phone: string, accepted: boolean): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
log.warn('CONSENT_LOGGING_API_KEY not set, skipping consent log')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfig()
|
||||
const response = await fetch(`${this.payloadApiUrl}/consent-logs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant: config.CCS_TENANT_ID,
|
||||
clientRef: `wa:${phone}:${Date.now()}`,
|
||||
categories: { whatsapp_bot: accepted },
|
||||
revision: 1,
|
||||
userAgent: 'WhatsApp Business Bot',
|
||||
anonymizedIp: 'whatsapp-api', // No real IP for WhatsApp messages
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.error({ status: response.status }, 'Failed to log consent')
|
||||
} else {
|
||||
log.debug({ phone, accepted }, 'Consent logged')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ error: (err as Error).message }, 'Consent logging error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ export interface ConversationState {
|
|||
assignedAgent: string | null
|
||||
language: string
|
||||
firstContact: boolean
|
||||
consentGiven: boolean
|
||||
consentTimestamp: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
|
@ -49,6 +51,8 @@ export class ConversationManager {
|
|||
assignedAgent: null,
|
||||
language: 'de',
|
||||
firstContact: true,
|
||||
consentGiven: false,
|
||||
consentTimestamp: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
|
@ -100,6 +104,28 @@ export class ConversationManager {
|
|||
log.info({ phone }, 'Conversation de-escalated')
|
||||
}
|
||||
|
||||
async grantConsent(phone: string): Promise<void> {
|
||||
const conv = await this.getConversation(phone)
|
||||
if (!conv) return
|
||||
|
||||
conv.consentGiven = true
|
||||
conv.consentTimestamp = new Date().toISOString()
|
||||
conv.updatedAt = new Date().toISOString()
|
||||
await this.saveConversation(conv)
|
||||
log.info({ phone }, 'DSGVO consent granted')
|
||||
}
|
||||
|
||||
async revokeConsent(phone: string): Promise<void> {
|
||||
const conv = await this.getConversation(phone)
|
||||
if (!conv) return
|
||||
|
||||
conv.consentGiven = false
|
||||
conv.consentTimestamp = null
|
||||
conv.updatedAt = new Date().toISOString()
|
||||
await this.saveConversation(conv)
|
||||
log.info({ phone }, 'DSGVO consent revoked')
|
||||
}
|
||||
|
||||
private async saveConversation(conv: ConversationState): Promise<void> {
|
||||
await this.redis.set(
|
||||
this.key(conv.phone),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ 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 { BusinessHoursChecker } from './BusinessHoursChecker.js'
|
||||
import type { ConsentManager } from './ConsentManager.js'
|
||||
import type { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
|
||||
import type { LLMProvider, LLMResponse } from '../llm/LLMProvider.js'
|
||||
import type { RulesLoader, MatchedRule } from '../payload/RulesLoader.js'
|
||||
|
|
@ -15,6 +17,11 @@ export type RouteResult =
|
|||
| { action: 'escalated'; reason: string }
|
||||
| { action: 'rule_matched'; rule: MatchedRule }
|
||||
| { action: 'skipped'; reason: string }
|
||||
| { action: 'auto_away'; reason: string }
|
||||
| { action: 'consent_requested' }
|
||||
| { action: 'consent_handled'; accepted: boolean }
|
||||
| { action: 'consent_revoked' }
|
||||
| { action: 'media_handled'; mediaType: string }
|
||||
|
||||
export class MessageRouter {
|
||||
constructor(
|
||||
|
|
@ -25,29 +32,64 @@ export class MessageRouter {
|
|||
private rulesLoader: RulesLoader,
|
||||
private templateResolver: TemplateResolver,
|
||||
private interactionWriter: InteractionWriter,
|
||||
private businessHoursChecker: BusinessHoursChecker,
|
||||
private consentManager: ConsentManager,
|
||||
) {}
|
||||
|
||||
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)
|
||||
// --- 1. Business Hours Check ---
|
||||
const hoursCheck = await this.businessHoursChecker.check()
|
||||
if (!hoursCheck.isOpen) {
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
return { action: 'skipped', reason: `Unsupported type: ${type}` }
|
||||
if (hoursCheck.message) {
|
||||
await this.whatsappClient.sendTextMessage(phone, hoursCheck.message)
|
||||
await this.interactionWriter.writeOutgoing(phone, hoursCheck.message, messageId)
|
||||
}
|
||||
log.info({ phone }, 'Auto-away response sent (outside business hours)')
|
||||
return { action: 'auto_away', reason: 'Outside business hours' }
|
||||
}
|
||||
|
||||
// --- 2. Get or create conversation ---
|
||||
const { conversation, isNew } =
|
||||
await this.conversationManager.getOrCreateConversation(phone, senderName)
|
||||
|
||||
// --- 3. Consent Check ---
|
||||
// Handle interactive button responses for consent
|
||||
if (type === 'interactive' || type === 'button') {
|
||||
const buttonId = message.raw.interactive?.button_reply?.id ?? message.raw.button?.payload
|
||||
if (buttonId && this.consentManager.isConsentResponse(buttonId)) {
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
const result = await this.consentManager.handleConsentResponse(phone, buttonId)
|
||||
return { action: 'consent_handled', accepted: result.accepted }
|
||||
}
|
||||
}
|
||||
|
||||
// Check for consent revocation keywords
|
||||
if (text && this.consentManager.isRevocationRequest(text)) {
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
await this.consentManager.handleRevocation(phone)
|
||||
return { action: 'consent_revoked' }
|
||||
}
|
||||
|
||||
// Request consent if not yet given
|
||||
if (!conversation.consentGiven) {
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
await this.consentManager.requestConsent(phone)
|
||||
return { action: 'consent_requested' }
|
||||
}
|
||||
|
||||
// --- 4. Media Handling ---
|
||||
if (type !== 'text' && type !== 'interactive' && type !== 'button') {
|
||||
return this.handleMediaMessage(message)
|
||||
}
|
||||
|
||||
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
|
||||
// --- 5. Escalation Check ---
|
||||
if (conversation.isEscalated) {
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
await this.conversationManager.addMessage(phone, 'user', text)
|
||||
|
|
@ -58,7 +100,7 @@ export class MessageRouter {
|
|||
}
|
||||
}
|
||||
|
||||
// Check community rules
|
||||
// --- 6. Community Rules ---
|
||||
const matchedRule = await this.rulesLoader.findMatchingRule(text, {
|
||||
isFirstContact: isNew,
|
||||
phone,
|
||||
|
|
@ -68,7 +110,7 @@ export class MessageRouter {
|
|||
return this.handleRuleMatch(message, matchedRule, conversation)
|
||||
}
|
||||
|
||||
// Generate bot response
|
||||
// --- 7. LLM Response ---
|
||||
const systemPrompt = await this.templateResolver.getSystemPrompt()
|
||||
const llmResponse = await this.llmProvider.generateResponse({
|
||||
systemPrompt,
|
||||
|
|
@ -76,7 +118,7 @@ export class MessageRouter {
|
|||
userMessage: text,
|
||||
})
|
||||
|
||||
// Check if escalation is needed
|
||||
// --- 8. Escalation Detection ---
|
||||
const escalationReason = await this.escalationManager.shouldEscalate(
|
||||
phone,
|
||||
text,
|
||||
|
|
@ -90,12 +132,11 @@ export class MessageRouter {
|
|||
return { action: 'escalated', reason: escalationReason.detail }
|
||||
}
|
||||
|
||||
// Send bot response
|
||||
// --- 9. Send 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)
|
||||
|
||||
|
|
@ -112,6 +153,83 @@ export class MessageRouter {
|
|||
return { action: 'bot_response', response: llmResponse }
|
||||
}
|
||||
|
||||
private async handleMediaMessage(message: NormalizedMessage): Promise<RouteResult> {
|
||||
const { from: phone, type, caption, messageId } = message
|
||||
|
||||
await this.interactionWriter.writeIncoming(message, null)
|
||||
|
||||
let responseText: string
|
||||
|
||||
switch (type) {
|
||||
case 'image':
|
||||
if (caption) {
|
||||
// Image with caption — treat caption as the user's text message
|
||||
responseText =
|
||||
'Vielen Dank für das Bild. Ich habe Ihre Nachricht erhalten und bearbeite Ihr Anliegen.'
|
||||
// Forward caption to LLM by creating a text-like flow
|
||||
const systemPrompt = await this.templateResolver.getSystemPrompt()
|
||||
const conv = await this.conversationManager.getConversation(phone)
|
||||
if (conv) {
|
||||
const llmResponse = await this.llmProvider.generateResponse({
|
||||
systemPrompt,
|
||||
conversationHistory: conv.messages,
|
||||
userMessage: `[Der Kunde hat ein Bild gesendet mit folgender Nachricht:] ${caption}`,
|
||||
})
|
||||
responseText = llmResponse.text
|
||||
await this.conversationManager.addMessage(phone, 'user', `[Bild] ${caption}`)
|
||||
await this.conversationManager.addMessage(phone, 'assistant', responseText)
|
||||
}
|
||||
} else {
|
||||
responseText =
|
||||
'Vielen Dank für das Bild. Leider kann ich Bilder nicht analysieren. ' +
|
||||
'Bitte beschreiben Sie Ihr Anliegen als Text, damit ich Ihnen weiterhelfen kann.'
|
||||
}
|
||||
break
|
||||
|
||||
case 'document':
|
||||
responseText =
|
||||
'Vielen Dank für das Dokument. Ich habe den Empfang registriert. ' +
|
||||
'Bitte beschreiben Sie zusätzlich Ihr Anliegen als Text, damit ich Ihnen gezielt helfen kann.'
|
||||
break
|
||||
|
||||
case 'audio':
|
||||
responseText =
|
||||
'Vielen Dank für Ihre Sprachnachricht. Leider können Sprachnachrichten aktuell ' +
|
||||
'nicht verarbeitet werden. Bitte schreiben Sie mir Ihr Anliegen als Text.'
|
||||
break
|
||||
|
||||
case 'video':
|
||||
responseText =
|
||||
'Vielen Dank für das Video. Leider können Videos aktuell nicht verarbeitet werden. ' +
|
||||
'Bitte beschreiben Sie Ihr Anliegen als Text.'
|
||||
break
|
||||
|
||||
case 'sticker':
|
||||
// Stickers: acknowledge silently, no response
|
||||
log.debug({ phone }, 'Sticker received, no response')
|
||||
return { action: 'media_handled', mediaType: 'sticker' }
|
||||
|
||||
case 'location':
|
||||
responseText =
|
||||
'Vielen Dank für Ihren Standort. Unsere Leistungen finden Sie auf ' +
|
||||
'https://complexcaresolutions.de\n\n' +
|
||||
'Für Fragen zu Ihrem Anliegen schreiben Sie mir gerne eine Textnachricht.'
|
||||
break
|
||||
|
||||
default:
|
||||
responseText =
|
||||
'Vielen Dank für Ihre Nachricht. Diesen Nachrichtentyp kann ich leider nicht verarbeiten. ' +
|
||||
'Bitte schreiben Sie mir Ihr Anliegen als Text.'
|
||||
break
|
||||
}
|
||||
|
||||
await this.whatsappClient.sendTextMessage(phone, responseText)
|
||||
await this.interactionWriter.writeOutgoing(phone, responseText, messageId)
|
||||
|
||||
log.info({ phone, mediaType: type }, 'Media message handled')
|
||||
return { action: 'media_handled', mediaType: type }
|
||||
}
|
||||
|
||||
private async handleRuleMatch(
|
||||
message: NormalizedMessage,
|
||||
rule: MatchedRule,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ const envSchema = z.object({
|
|||
// CMS entity IDs (may differ between Dev and Production)
|
||||
WHATSAPP_PLATFORM_ID: z.coerce.number().default(4),
|
||||
WHATSAPP_ACCOUNT_ID: z.coerce.number().default(1),
|
||||
|
||||
// DSGVO Consent logging
|
||||
CONSENT_LOGGING_API_KEY: z.string().optional(),
|
||||
})
|
||||
|
||||
export type Config = z.infer<typeof envSchema>
|
||||
|
|
|
|||
|
|
@ -34,11 +34,21 @@ export class InteractionWriter {
|
|||
name: message.senderName || message.from,
|
||||
handle: message.from,
|
||||
},
|
||||
message: message.text ?? `[${message.type}]`,
|
||||
message: message.text ?? message.caption ?? `[${message.type}]`,
|
||||
publishedAt: new Date().toISOString(),
|
||||
status: 'new',
|
||||
}
|
||||
|
||||
// Attach media reference if present
|
||||
if (message.mediaId) {
|
||||
data.attachments = [
|
||||
{
|
||||
type: message.type === 'document' ? 'link' : message.type === 'sticker' ? 'sticker' : message.type,
|
||||
url: `whatsapp-media:${message.mediaId}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (analysis?.isMedicalQuestion) {
|
||||
data.flags = { isMedicalQuestion: true }
|
||||
data.analysis = { sentiment: 'question' }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ 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 { BusinessHoursChecker } from './bot/BusinessHoursChecker.js'
|
||||
import { ConsentManager } from './bot/ConsentManager.js'
|
||||
import { PayloadClient } from './payload/PayloadClient.js'
|
||||
import { InteractionWriter } from './payload/InteractionWriter.js'
|
||||
import { RulesLoader } from './payload/RulesLoader.js'
|
||||
|
|
@ -50,6 +52,9 @@ const interactionWriter = new InteractionWriter(payloadClient)
|
|||
const rulesLoader = new RulesLoader(payloadClient)
|
||||
const templateResolver = new TemplateResolver(payloadClient)
|
||||
|
||||
const businessHoursChecker = new BusinessHoursChecker(payloadClient)
|
||||
const consentManager = new ConsentManager(whatsappClient, conversationManager, config.PAYLOAD_API_URL)
|
||||
|
||||
const messageRouter = new MessageRouter(
|
||||
conversationManager,
|
||||
escalationManager,
|
||||
|
|
@ -58,6 +63,8 @@ const messageRouter = new MessageRouter(
|
|||
rulesLoader,
|
||||
templateResolver,
|
||||
interactionWriter,
|
||||
businessHoursChecker,
|
||||
consentManager,
|
||||
)
|
||||
|
||||
// --- BullMQ queues ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue