diff --git a/src/bot/BusinessHoursChecker.ts b/src/bot/BusinessHoursChecker.ts new file mode 100644 index 0000000..226da4d --- /dev/null +++ b/src/bot/BusinessHoursChecker.ts @@ -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 { + 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 { + 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 = { + 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 = { + 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) + } +} diff --git a/src/bot/ConsentManager.ts b/src/bot/ConsentManager.ts new file mode 100644 index 0000000..677d58c --- /dev/null +++ b/src/bot/ConsentManager.ts @@ -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 { + 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 { + 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 { + 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') + } + } +} diff --git a/src/bot/ConversationManager.ts b/src/bot/ConversationManager.ts index 6ddc11e..d1ecd90 100644 --- a/src/bot/ConversationManager.ts +++ b/src/bot/ConversationManager.ts @@ -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 { + 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 { + 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 { await this.redis.set( this.key(conv.phone), diff --git a/src/bot/MessageRouter.ts b/src/bot/MessageRouter.ts index 3cfb337..81f74bb 100644 --- a/src/bot/MessageRouter.ts +++ b/src/bot/MessageRouter.ts @@ -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 { 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 { + 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, diff --git a/src/config.ts b/src/config.ts index 50f6a29..9f99160 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 diff --git a/src/payload/InteractionWriter.ts b/src/payload/InteractionWriter.ts index 1bab11a..fd140b7 100644 --- a/src/payload/InteractionWriter.ts +++ b/src/payload/InteractionWriter.ts @@ -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' } diff --git a/src/server.ts b/src/server.ts index 1c0a08c..7be130b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 ---