mirror of
https://github.com/complexcaresolutions/whatsapp-bot.git
synced 2026-03-17 18:34:07 +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
|
assignedAgent: string | null
|
||||||
language: string
|
language: string
|
||||||
firstContact: boolean
|
firstContact: boolean
|
||||||
|
consentGiven: boolean
|
||||||
|
consentTimestamp: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +51,8 @@ export class ConversationManager {
|
||||||
assignedAgent: null,
|
assignedAgent: null,
|
||||||
language: 'de',
|
language: 'de',
|
||||||
firstContact: true,
|
firstContact: true,
|
||||||
|
consentGiven: false,
|
||||||
|
consentTimestamp: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +104,28 @@ export class ConversationManager {
|
||||||
log.info({ phone }, 'Conversation de-escalated')
|
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> {
|
private async saveConversation(conv: ConversationState): Promise<void> {
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
this.key(conv.phone),
|
this.key(conv.phone),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { getLogger } from '../lib/logger.js'
|
||||||
import type { NormalizedMessage } from '../whatsapp/types.js'
|
import type { NormalizedMessage } from '../whatsapp/types.js'
|
||||||
import type { ConversationManager, ConversationState } from './ConversationManager.js'
|
import type { ConversationManager, ConversationState } from './ConversationManager.js'
|
||||||
import type { EscalationManager } from './EscalationManager.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 { WhatsAppClient } from '../whatsapp/WhatsAppClient.js'
|
||||||
import type { LLMProvider, LLMResponse } from '../llm/LLMProvider.js'
|
import type { LLMProvider, LLMResponse } from '../llm/LLMProvider.js'
|
||||||
import type { RulesLoader, MatchedRule } from '../payload/RulesLoader.js'
|
import type { RulesLoader, MatchedRule } from '../payload/RulesLoader.js'
|
||||||
|
|
@ -15,6 +17,11 @@ export type RouteResult =
|
||||||
| { action: 'escalated'; reason: string }
|
| { action: 'escalated'; reason: string }
|
||||||
| { action: 'rule_matched'; rule: MatchedRule }
|
| { action: 'rule_matched'; rule: MatchedRule }
|
||||||
| { action: 'skipped'; reason: string }
|
| { 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 {
|
export class MessageRouter {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -25,29 +32,64 @@ export class MessageRouter {
|
||||||
private rulesLoader: RulesLoader,
|
private rulesLoader: RulesLoader,
|
||||||
private templateResolver: TemplateResolver,
|
private templateResolver: TemplateResolver,
|
||||||
private interactionWriter: InteractionWriter,
|
private interactionWriter: InteractionWriter,
|
||||||
|
private businessHoursChecker: BusinessHoursChecker,
|
||||||
|
private consentManager: ConsentManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async route(message: NormalizedMessage): Promise<RouteResult> {
|
async route(message: NormalizedMessage): Promise<RouteResult> {
|
||||||
const { from: phone, senderName, text, type, messageId } = message
|
const { from: phone, senderName, text, type, messageId } = message
|
||||||
|
|
||||||
// Skip non-text messages for now (Phase 4: media handling)
|
// --- 1. Business Hours Check ---
|
||||||
if (type !== 'text' && type !== 'interactive' && type !== 'button') {
|
const hoursCheck = await this.businessHoursChecker.check()
|
||||||
const mediaAck =
|
if (!hoursCheck.isOpen) {
|
||||||
'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)
|
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) {
|
if (!text) {
|
||||||
return { action: 'skipped', reason: 'Empty message text' }
|
return { action: 'skipped', reason: 'Empty message text' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create conversation
|
// --- 5. Escalation Check ---
|
||||||
const { conversation, isNew } =
|
|
||||||
await this.conversationManager.getOrCreateConversation(phone, senderName)
|
|
||||||
|
|
||||||
// If already escalated, store but don't respond
|
|
||||||
if (conversation.isEscalated) {
|
if (conversation.isEscalated) {
|
||||||
await this.interactionWriter.writeIncoming(message, null)
|
await this.interactionWriter.writeIncoming(message, null)
|
||||||
await this.conversationManager.addMessage(phone, 'user', text)
|
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, {
|
const matchedRule = await this.rulesLoader.findMatchingRule(text, {
|
||||||
isFirstContact: isNew,
|
isFirstContact: isNew,
|
||||||
phone,
|
phone,
|
||||||
|
|
@ -68,7 +110,7 @@ export class MessageRouter {
|
||||||
return this.handleRuleMatch(message, matchedRule, conversation)
|
return this.handleRuleMatch(message, matchedRule, conversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate bot response
|
// --- 7. LLM Response ---
|
||||||
const systemPrompt = await this.templateResolver.getSystemPrompt()
|
const systemPrompt = await this.templateResolver.getSystemPrompt()
|
||||||
const llmResponse = await this.llmProvider.generateResponse({
|
const llmResponse = await this.llmProvider.generateResponse({
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
|
@ -76,7 +118,7 @@ export class MessageRouter {
|
||||||
userMessage: text,
|
userMessage: text,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if escalation is needed
|
// --- 8. Escalation Detection ---
|
||||||
const escalationReason = await this.escalationManager.shouldEscalate(
|
const escalationReason = await this.escalationManager.shouldEscalate(
|
||||||
phone,
|
phone,
|
||||||
text,
|
text,
|
||||||
|
|
@ -90,12 +132,11 @@ export class MessageRouter {
|
||||||
return { action: 'escalated', reason: escalationReason.detail }
|
return { action: 'escalated', reason: escalationReason.detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send bot response
|
// --- 9. Send Response ---
|
||||||
await this.whatsappClient.sendTextMessage(phone, llmResponse.text)
|
await this.whatsappClient.sendTextMessage(phone, llmResponse.text)
|
||||||
await this.conversationManager.addMessage(phone, 'user', text)
|
await this.conversationManager.addMessage(phone, 'user', text)
|
||||||
await this.conversationManager.addMessage(phone, 'assistant', llmResponse.text)
|
await this.conversationManager.addMessage(phone, 'assistant', llmResponse.text)
|
||||||
|
|
||||||
// Store both messages
|
|
||||||
await this.interactionWriter.writeIncoming(message, llmResponse)
|
await this.interactionWriter.writeIncoming(message, llmResponse)
|
||||||
await this.interactionWriter.writeOutgoing(phone, llmResponse.text, messageId)
|
await this.interactionWriter.writeOutgoing(phone, llmResponse.text, messageId)
|
||||||
|
|
||||||
|
|
@ -112,6 +153,83 @@ export class MessageRouter {
|
||||||
return { action: 'bot_response', response: llmResponse }
|
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(
|
private async handleRuleMatch(
|
||||||
message: NormalizedMessage,
|
message: NormalizedMessage,
|
||||||
rule: MatchedRule,
|
rule: MatchedRule,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ const envSchema = z.object({
|
||||||
// CMS entity IDs (may differ between Dev and Production)
|
// CMS entity IDs (may differ between Dev and Production)
|
||||||
WHATSAPP_PLATFORM_ID: z.coerce.number().default(4),
|
WHATSAPP_PLATFORM_ID: z.coerce.number().default(4),
|
||||||
WHATSAPP_ACCOUNT_ID: z.coerce.number().default(1),
|
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>
|
export type Config = z.infer<typeof envSchema>
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,21 @@ export class InteractionWriter {
|
||||||
name: message.senderName || message.from,
|
name: message.senderName || message.from,
|
||||||
handle: message.from,
|
handle: message.from,
|
||||||
},
|
},
|
||||||
message: message.text ?? `[${message.type}]`,
|
message: message.text ?? message.caption ?? `[${message.type}]`,
|
||||||
publishedAt: new Date().toISOString(),
|
publishedAt: new Date().toISOString(),
|
||||||
status: 'new',
|
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) {
|
if (analysis?.isMedicalQuestion) {
|
||||||
data.flags = { isMedicalQuestion: true }
|
data.flags = { isMedicalQuestion: true }
|
||||||
data.analysis = { sentiment: 'question' }
|
data.analysis = { sentiment: 'question' }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { ClaudeProvider } from './llm/ClaudeProvider.js'
|
||||||
import { ConversationManager } from './bot/ConversationManager.js'
|
import { ConversationManager } from './bot/ConversationManager.js'
|
||||||
import { EscalationManager } from './bot/EscalationManager.js'
|
import { EscalationManager } from './bot/EscalationManager.js'
|
||||||
import { MessageRouter } from './bot/MessageRouter.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 { PayloadClient } from './payload/PayloadClient.js'
|
||||||
import { InteractionWriter } from './payload/InteractionWriter.js'
|
import { InteractionWriter } from './payload/InteractionWriter.js'
|
||||||
import { RulesLoader } from './payload/RulesLoader.js'
|
import { RulesLoader } from './payload/RulesLoader.js'
|
||||||
|
|
@ -50,6 +52,9 @@ const interactionWriter = new InteractionWriter(payloadClient)
|
||||||
const rulesLoader = new RulesLoader(payloadClient)
|
const rulesLoader = new RulesLoader(payloadClient)
|
||||||
const templateResolver = new TemplateResolver(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(
|
const messageRouter = new MessageRouter(
|
||||||
conversationManager,
|
conversationManager,
|
||||||
escalationManager,
|
escalationManager,
|
||||||
|
|
@ -58,6 +63,8 @@ const messageRouter = new MessageRouter(
|
||||||
rulesLoader,
|
rulesLoader,
|
||||||
templateResolver,
|
templateResolver,
|
||||||
interactionWriter,
|
interactionWriter,
|
||||||
|
businessHoursChecker,
|
||||||
|
consentManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- BullMQ queues ---
|
// --- BullMQ queues ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue