mama-bot / app.py
maxkru92's picture
Fix: Green API Integration für WhatsApp
7c1b99c verified
Raw
History Blame Contribute Delete
23.9 kB
"""
app.py - Hauptanwendung (FastAPI) für den Mama Bot
Läuft auf Hugging Face Spaces (Docker).
Nutzt Hugging Face Inference API für das LLM.
WhatsApp-Anbindung über WAHA (WhatsApp HTTP API).
Enthält:
- WhatsApp Webhook-Handler mit Intent-Erkennung
- Keep-Alive System (Self-Ping + externe Endpoints)
- Integrierter Scheduler (Morgennachrichten, Check-in, Wochenprogramm)
- Reminder-System
- Benutzerprofil-Verwaltung
"""
import os
import json
import random
import logging
import threading
import time as _time
from datetime import datetime
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, PlainTextResponse
from bot_config import (
EMERGENCY_KEYWORDS, AUTHORITY_KEYWORDS, HEALTH_KEYWORDS, REMINDER_KEYWORDS,
EMERGENCY_NUMBERS,
FALLBACK_MESSAGES, get_system_prompt, load_user_profile, save_user_profile,
MAX_HISTORY, TOPICS, MORNING_TEMPLATES, CHECKIN_MESSAGES, WEEKLY_PROGRAM_MESSAGES
)
# ============================================================
# LOGGING
# ============================================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("mama-bot")
# ============================================================
# FASTAPI
# ============================================================
app = FastAPI(
title="Mama Bot",
version="3.0.0",
description="Persönlicher KI-Assistent für ältere Personen – Hugging Face Spaces Edition"
)
# ============================================================
# KONFIGURATION
# ============================================================
HF_API_TOKEN = os.getenv("HF_API_TOKEN", os.getenv("HF_TOKEN", ""))
HF_MODEL = os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.3")
# Green API (WhatsApp)
GREEN_API_URL = os.getenv("GREEN_API_URL", "https://7107.api.greenapi.com")
GREEN_API_ID_INSTANCE = os.getenv("GREEN_API_ID_INSTANCE", "7107662501")
GREEN_API_TOKEN = os.getenv("GREEN_API_TOKEN", "")
# Legacy: WAHA (fallback)
WAHA_API_URL = os.getenv("WAHA_API_URL", "http://waha:3000")
WAHA_API_KEY = os.getenv("WAHA_API_KEY", "")
WAHA_SESSION = os.getenv("WAHA_SESSION", "default")
USER_NAME = os.getenv("USER_NAME", "Sabine")
USER_PHONE = os.getenv("USER_PHONE", "")
# Scheduler-Konfiguration
MORNING_HOUR = int(os.getenv("MORNING_HOUR", 8))
MORNING_MINUTE = int(os.getenv("MORNING_MINUTE", 0))
CHECKIN_DAYS = int(os.getenv("CHECKIN_DAYS", 3))
# Persistenz-Datei für Scheduler-Zustand
SCHEDULER_STATE_FILE = os.path.join(os.path.dirname(__file__), "memory", "scheduler_state.json")
# ============================================================
# IN-MEMORY STORES
# ============================================================
conversation_history: list[dict] = []
reminders: list[dict] = []
# ============================================================
# SCHEDULER-STATE (persistent)
# ============================================================
def load_scheduler_state() -> dict:
"""Lädt den Scheduler-Zustand aus der JSON-Datei."""
try:
with open(SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {
"last_morning": None,
"last_checkin": None,
"sent_topic_ids": []
}
def save_scheduler_state(state: dict):
"""Speichert den Scheduler-Zustand."""
os.makedirs(os.path.dirname(SCHEDULER_STATE_FILE), exist_ok=True)
with open(SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
# ============================================================
# LLM – Hugging Face Inference API
# ============================================================
def call_hf_inference(system_prompt: str, user_message: str, history: list[dict]) -> str:
"""Ruft die Hugging Face Inference API auf."""
if not HF_API_TOKEN:
logger.warning("Kein HF_API_TOKEN gesetzt – nutze Fallback.")
return random.choice(FALLBACK_MESSAGES)
url = f"https://api-inference.huggingface.co/models/{HF_MODEL}"
prompt_parts = [f"[INST] <<SYS>>\n{system_prompt}\n<</SYS>>\n\n"]
for h in history[-MAX_HISTORY:]:
if h["role"] == "user":
prompt_parts.append(f"{h['content']} [/INST] ")
elif h["role"] == "assistant":
prompt_parts.append(f"{h['content']} [INST] ")
prompt_parts.append(f"{user_message} [/INST]")
full_prompt = "".join(prompt_parts)
headers = {"Authorization": f"Bearer {HF_API_TOKEN}", "Content-Type": "application/json"}
payload = {
"inputs": full_prompt,
"parameters": {
"max_new_tokens": 350,
"temperature": 0.7,
"top_p": 0.9,
"repetition_penalty": 1.1,
"return_full_text": False
}
}
try:
with httpx.Client(timeout=60) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code == 503:
logger.info("Modell wird geladen, warte 20s...")
_time.sleep(20)
response = client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
if isinstance(data, list) and len(data) > 0:
generated = data[0].get("generated_text", "")
return generated.strip() if generated.strip() else random.choice(FALLBACK_MESSAGES)
return random.choice(FALLBACK_MESSAGES)
except httpx.TimeoutException:
logger.error("Timeout bei HF Inference API")
return "Entschuldige, das hat etwas gedauert. Kannst du das bitte wiederholen? 😊"
except Exception as e:
logger.error(f"Fehler bei HF Inference API: {e}")
return random.choice(FALLBACK_MESSAGES)
# ============================================================
# WHATSAPP SENDEN (Green API)
# ============================================================
def send_whatsapp(phone: str, message: str) -> bool:
"""Sendet eine WhatsApp-Nachricht über Green API."""
if not phone:
return False
try:
url = f"{GREEN_API_URL}/waInstance{GREEN_API_ID_INSTANCE}/sendMessage/{GREEN_API_TOKEN}"
payload = {
"chatId": f"{phone}@s.whatsapp.net",
"message": message
}
with httpx.Client(timeout=30) as client:
resp = client.post(url, json=payload)
data = resp.json()
return data.get("idMessage", "") != ""
except Exception as e:
logger.error(f"Fehler beim WhatsApp-Senden (Green API): {e}")
return False
def receive_whatsapp_webhook(data: dict) -> tuple:
"""Parst eine Green API Webhook-Nachricht."""
try:
# Green API Webhook Format
message_body = data.get("message", {}).get("text", "")
sender = data.get("sender", {}).get("id", "").replace("@s.whatsapp.net", "")
return message_body, sender
except Exception as e:
logger.error(f"Fehler beim Parsen der Green API Nachricht: {e}")
return "", ""
# ============================================================
# INTENT-ERKENNUNG
# ============================================================
def detect_intent(message: str) -> str:
"""Erkennt die Absicht: emergency, authority, health, reminder, chat."""
msg = message.lower().strip()
for kw in EMERGENCY_KEYWORDS:
if kw in msg:
return "emergency"
for kw in HEALTH_KEYWORDS:
if kw in msg:
return "health"
for kw in AUTHORITY_KEYWORDS:
if kw in msg:
return "authority"
for kw in REMINDER_KEYWORDS:
if kw in msg:
return "reminder"
return "chat"
# ============================================================
# DOMAIN-HANDLER
# ============================================================
def handle_emergency() -> str:
numbers = "\n".join(f"📞 {n}: {nr}" for n, nr in EMERGENCY_NUMBERS.items())
return (
"🚨 Ich bin hier, um dir zu helfen!\n\n"
"Atme erst einmal tief durch. Alles wird gut.\n\n"
f"Wichtige Telefonnummern:\n{numbers}\n\n"
"Wenn es ein medizinischer Notfall ist, wähle bitte direkt die 112.\n"
"Sag mir genau, was passiert ist, dann helfe ich dir weiter. 💝"
)
def handle_authority(user_message: str) -> str:
msg = user_message.lower()
disclaimer = "⚠️ Das sind allgemeine Informationen, kein Rechtsrat. Bei wichtigen Entscheidungen bitte Beratung suchen."
if "widerspruch" in msg:
return (
"📋 Einen Widerspruch kannst du innerhalb von 1 Monat nach Zustellung des Bescheids einlegen.\n\n"
"Schreibe einen Brief, in dem du erklärst, warum du nicht einverstanden bist, "
"und schicke ihn an die Behörde, die den Bescheid geschickt hat.\n\n"
"💡 Kostenlose Beratung: VdK 0800 888 0555\n\n"
f"{disclaimer}"
)
if "rente" in msg:
return (
"💰 Den Rentenantrag solltest du 3 Monate vor deinem Rentenbeginn stellen.\n\n"
"Deutsche Rentenversicherung: 0800 1000 100\n"
"VdK-Beratung: 0800 888 0555\n\n"
f"{disclaimer}"
)
if "pflegegrad" in msg:
return (
"🏥 Den Pflegegrad beantragst du bei deiner Krankenkasse.\n"
"Du brauchst einen Arztbericht. Der MDK kommt zu dir und schaut, wie viel Hilfe du brauchst.\n\n"
"💡 Beratung: VdK 0800 888 0555\n\n"
f"{disclaimer}"
)
return (
"🏛️ Kostenlose Beratung:\n"
"• VdK: 0800 888 0555\n"
"• SoVD: 0800 554 5555\n"
"• Deutsche Rentenversicherung: 0800 1000 100\n\n"
"Wichtige Fristen:\n"
"• Widerspruch: 1 Monat nach Bescheid\n"
"• Rente beantragen: 3 Monate vor Rentenbeginn\n\n"
f"{disclaimer}"
)
def handle_health(user_message: str) -> str:
msg = user_message.lower()
if "pflegegeld" in msg or "sachleistung" in msg:
return (
"💊 Es gibt zwei Arten der Pflegeunterstützung:\n\n"
"1. Pflegegeld: Geld für die häusliche Pflege durch Angehörige.\n"
"2. Pflegesachleistung: Ein Pflegedienst kommt zu dir nach Hause.\n\n"
"Du kannst auch beides mischen (Kombinationsleistung).\n\n"
"💡 Nach § 45b SGB XI hast du auch Anspruch auf Entlastungsleistung "
"(bis zu 120,80 € pro Monat).\n\n"
"Ruf deine Krankenkasse an oder geh zum Pflegestützpunkt vor Ort. 💝"
)
if "mdk" in msg or "gutachtung" in msg:
return (
"🏥 Der MDK (Medizinischer Dienst) schaut bei deinem Pflegegrad-Antrag.\n\n"
"So läuft's ab:\n"
"1. Antrag bei der Krankenkasse\n"
"2. Arztbericht erstellen\n"
"3. MDK-Besuch bei dir zu Hause\n"
"4. Pflegegrad-Bescheid (1-5)\n\n"
"Das dauert meist 4-6 Wochen. Hab etwas Geduld! 😊"
)
return (
"💚 Gesundheit ist das Wichtigste!\n\n"
"Anlaufstellen:\n"
"• Deine Krankenkasse (Nummer auf der Versichertenkarte)\n"
"• Pflegestützpunkt vor Ort\n"
"• VdK-Beratung: 0800 888 0555\n"
"• Ärztlicher Notdienst: 116 117\n\n"
"Wenn du Schmerzen hast, ruf bitte den Arzt an. "
"Ich bin nur ein Computerprogramm und kann keine medizinische Beratung geben. 💝"
)
def handle_reminder(user_message: str) -> str:
reminder_id = str(datetime.now().timestamp()).replace(".", "")
reminders.append({
"id": reminder_id,
"message": user_message,
"created_at": datetime.now().isoformat(),
"status": "pending"
})
return f"✅ Alles klar! Ich habe mir notiert: \"{user_message}\"\n\nIch werde dich daran erinnern! 📝"
def handle_chat(user_message: str, history: list) -> str:
"""Normaler Chat – ruft HF Inference API auf."""
profile = load_user_profile()
system_prompt = get_system_prompt(
user_name=profile.get("name", USER_NAME),
preferences=profile.get("preferences", []),
concerns=profile.get("concerns", [])
)
return call_hf_inference(system_prompt, user_message, history)
# ============================================================
# WEBHOOK-HANDLER
# ============================================================
@app.post("/webhook")
async def webhook(request: Request):
"""Empfängt WhatsApp-Nachrichten von WAHA und sendet Antworten."""
try:
data = await request.json()
logger.info(f"Webhook empfangen: {json.dumps(data, ensure_ascii=False)[:500]}")
# Green API Webhook Format
message_body, sender = receive_whatsapp_webhook(data)
# Fallback: WAHA Format
if not message_body:
message_body = data.get("body", data.get("text", ""))
if not sender:
sender = data.get("from", data.get("sender", ""))
if not message_body:
return JSONResponse({"status": "ok", "message": "Keine Nachricht"})
intent = detect_intent(message_body)
logger.info(f"Intent: {intent}")
if intent == "emergency":
response_text = handle_emergency()
elif intent == "authority":
response_text = handle_authority(message_body)
elif intent == "health":
response_text = handle_health(message_body)
elif intent == "reminder":
response_text = handle_reminder(message_body)
else:
conversation_history.append({"role": "user", "content": message_body})
response_text = handle_chat(message_body, conversation_history)
conversation_history.append({"role": "assistant", "content": response_text})
if len(conversation_history) > MAX_HISTORY * 2:
conversation_history[:] = conversation_history[-MAX_HISTORY:]
profile = load_user_profile()
profile["last_contact"] = datetime.now().isoformat()
save_user_profile(profile)
if sender:
send_whatsapp(sender, response_text)
return JSONResponse({"status": "ok", "reply": response_text, "intent": intent})
except Exception as e:
logger.error(f"Webhook-Fehler: {e}")
return JSONResponse({"status": "error", "message": str(e)})
# ============================================================
# HEALTH & INFO ENDPOINTS
# ============================================================
@app.get("/")
async def root():
return {
"status": "running",
"service": "Mama Bot",
"version": "3.0.0",
"model": HF_MODEL,
"timestamp": datetime.now().isoformat()
}
@app.get("/health")
async def health():
state = load_scheduler_state()
return {
"status": "healthy",
"model": HF_MODEL,
"hf_token_set": bool(HF_API_TOKEN),
"whatsapp": "green-api",
"green_api_instance": GREEN_API_ID_INSTANCE,
"conversation_length": len(conversation_history),
"active_reminders": len([r for r in reminders if r["status"] == "pending"]),
"last_morning": state.get("last_morning"),
"last_checkin": state.get("last_checkin"),
"sent_topics_count": len(state.get("sent_topic_ids", [])),
"timestamp": datetime.now().isoformat()
}
# ============================================================
# KEEP-ALIVE ENDPOINTS
# ============================================================
_last_self_ping = datetime.now()
@app.get("/keepalive")
async def keepalive():
"""
Keep-Alive Endpoint für externe Ping-Services.
Rufen Sie diesen Endpoint alle 30-60 Minuten auf,
um den Space aktiv zu halten.
Empfohlene externe Services (kostenlos):
- cron-job.org (kostenlos, bis 10 Jobs)
- UptimeRobot (kostenlos, 5-Minuten-Intervall)
- Freshping (kostenlos, 1-Minuten-Intervall)
"""
global _last_self_ping
_last_self_ping = datetime.now()
return {
"status": "alive",
"service": "Mama Bot",
"message": "Space ist wach! 💚",
"timestamp": datetime.now().isoformat()
}
@app.get("/ping")
async def ping():
"""Einfacher Ping-Endpoint (kompatibel mit UptimeRobot etc.)."""
return "pong"
# ============================================================
# SCHEDULER (integriert – läuft im Hauptprozess)
# ============================================================
def load_topics(topic_key: str) -> list:
"""Lädt Themen aus der JSON-Datei."""
cfg = TOPICS.get(topic_key)
if not cfg:
return []
path = os.path.join(os.path.dirname(__file__), cfg["file"])
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f).get("topics", [])
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Themen laden fehlgeschlagen ({topic_key}): {e}")
return []
def get_today_topic_key() -> str:
"""Bestimmt das Thema basierend auf dem Wochentag."""
keys = list(TOPICS.keys())
return keys[datetime.now().weekday() % len(keys)]
def generate_morning_message() -> str:
"""Generiert die tägliche Morgennachricht."""
profile = load_user_profile()
user_name = profile.get("name", USER_NAME)
state = load_scheduler_state()
topic_key = get_today_topic_key()
topic_cfg = TOPICS[topic_key]
emoji = topic_cfg["emoji"]
topic_name = topic_cfg["name"]
all_topics = load_topics(topic_key)
available = [t for t in all_topics if t.get("id") not in state.get("sent_topic_ids", [])]
if not available:
state["sent_topic_ids"] = []
save_scheduler_state(state)
available = all_topics
if available:
chosen = random.choice(available)
topic_id = chosen.get("id", "")
state.setdefault("sent_topic_ids", []).append(topic_id)
save_scheduler_state(state)
base_content = chosen.get("content", "")
else:
base_content = f"Heute geht es um {topic_name}! {emoji}"
# Mit LLM aufbereiten
system = (
"Du bist ein liebevoller, geduldiger Assistent für eine ältere Person. "
"Schreibe eine kurze, warme Morgennachricht auf Deutsch (max. 3 Sätze). "
"Du verwendest einfache Wörter, ein freundliches 'Du' und passende Emojis. "
"Die Person heißt Sabine – sprich sie direkt mit Namen an."
)
prompt = (
f"Schreibe eine Morgennachricht für {user_name}. "
f"Thema heute: {emoji} {topic_name}. "
f"Info: {base_content}"
)
generated = call_hf_inference(system, prompt, [])
if generated and len(generated) > 20:
return generated
# Fallback: Template-basiert
template = random.choice(MORNING_TEMPLATES)
return template.format(name=user_name, content=base_content)
def generate_weekly_message() -> str:
"""Generiert die Montags-Frage für das Wochenprogramm."""
profile = load_user_profile()
user_name = profile.get("name", USER_NAME)
return random.choice(WEEKLY_PROGRAM_MESSAGES).format(name=user_name)
def generate_checkin_message() -> str:
"""Generiert eine sanfte Check-in Nachricht."""
profile = load_user_profile()
user_name = profile.get("name", USER_NAME)
return random.choice(CHECKIN_MESSAGES).format(name=user_name)
def check_morning():
"""Prüft und sendet die Morgennachricht."""
now = datetime.now()
state = load_scheduler_state()
# Heute schon gesendet?
last = state.get("last_morning")
if last and datetime.fromisoformat(last).date() == now.date():
return
# Zur richtigen Uhrzeit?
if now.hour != MORNING_HOUR or now.minute < MORNING_MINUTE:
return
if now.weekday() == 0:
msg = generate_weekly_message()
logger.info("Sende Wochenprogramm (Montag)...")
else:
msg = generate_morning_message()
logger.info("Sende Morgennachricht...")
if USER_PHONE:
ok = send_whatsapp(USER_PHONE, msg)
if ok:
state["last_morning"] = now.isoformat()
save_scheduler_state(state)
logger.info("Morgennachricht gesendet!")
else:
logger.error("Morgennachricht senden fehlgeschlagen.")
else:
logger.info(f"[KEIN PHONE] Nachricht: {msg}")
def check_checkin():
"""Prüft ob Check-in nötig ist und sendet."""
profile = load_user_profile()
last_contact = profile.get("last_contact")
if not last_contact:
return
days = (datetime.now() - datetime.fromisoformat(last_contact)).days
if days >= CHECKIN_DAYS:
msg = generate_checkin_message()
if USER_PHONE:
ok = send_whatsapp(USER_PHONE, msg)
if ok:
logger.info(f"Check-in nach {days} Tagen gesendet.")
else:
logger.error("Check-in senden fehlgeschlagen.")
else:
logger.info(f"Letzter Kontakt {days} Tage her. Kein Check-in.")
# ============================================================
# BACKGROUND THREADS (Keep-Alive + Scheduler)
# ============================================================
def _keep_alive_loop():
"""
Keep-Alive Thread: Pingt sich selbst alle 30 Minuten
und führt die Scheduler-Aufgaben aus.
"""
# Warte 2 Minuten nach Start
_time.sleep(120)
last_scheduler_check = datetime.now()
while True:
try:
# Self-Ping (hält Space wach)
port = int(os.getenv("PORT", 7860))
url = f"http://127.0.0.1:{port}/keepalive"
with httpx.Client(timeout=10) as client:
resp = client.get(url)
if resp.status_code == 200:
logger.info("✅ Keep-Alive Ping erfolgreich – Space bleibt wach.")
else:
logger.warning(f"Keep-Alive Ping fehlgeschlagen: {resp.status_code}")
except Exception as e:
logger.warning(f"Keep-Alive Ping Fehler: {e}")
# Scheduler-Aufgaben prüfen (alle 5 Minuten)
now = datetime.now()
if (now - last_scheduler_check).total_seconds() >= 300:
last_scheduler_check = now
try:
check_morning()
check_checkin()
except Exception as e:
logger.error(f"Scheduler-Fehler: {e}")
# Alle 30 Minuten pingen
_time.sleep(1800)
def start_background_threads():
"""Startet alle Hintergrund-Threads."""
# Keep-Alive + Scheduler Thread
t = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive-scheduler")
t.start()
logger.info("🔄 Keep-Alive + Scheduler Thread gestartet.")
# ============================================================
# USER PROFILE & REMINDER ENDPOINTS
# ============================================================
@app.get("/profile")
async def get_profile():
return load_user_profile()
@app.put("/profile")
async def update_profile(request: Request):
data = await request.json()
save_user_profile(data)
return {"status": "ok", "profile": data}
@app.get("/reminders")
async def list_reminders():
active = [r for r in reminders if r["status"] == "pending"]
return {"reminders": active, "count": len(active)}
# ============================================================
# ENTRYPOINT
# ============================================================
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 7860))
# Hintergrund-Threads starten (Keep-Alive + Scheduler)
start_background_threads()
uvicorn.run(app, host="0.0.0.0", port=port)