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:
Martin Porwoll 2026-03-02 16:17:30 +00:00
parent 4b8fabc204
commit 81e09a4e2b
7 changed files with 537 additions and 17 deletions

View 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
View 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')
}
}
}

View file

@ -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),

View file

@ -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,

View file

@ -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>

View file

@ -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' }

View file

@ -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 ---