import { createPost } from "../../db/community"; import { usePrisma } from "../../utils/prisma"; import { pickRandomTemplate, LYRA_POST_CATALOG, } from "../../lib/lyraPostCatalog"; /** * POST /api/cron/lyra-post * * Lyra postet ab und zu in der Community – motivierend, human, nicht zu viel. * Max. 3x pro Woche. * * Aufruf via Server-Cron (z.B. pm2-cron oder Linux crontab): * 0 10 * * 1,3,5 curl -X POST https://rebreak.org/api/cron/lyra-post \ * -H "x-cron-secret: $NUXT_CRON_SECRET" * * Feature-Flag: * USE_TEMPLATE_CATALOG=true → Template-Catalog (i18n-fähig, kein LLM) * USE_TEMPLATE_CATALOG=false → LLM-Path via OpenRouter (Legacy, Default) * * Infisical Secrets: * NUXT_LYRA_BOT_USER_ID – UUID des Lyra-Profils in der DB * NUXT_CRON_SECRET – zufälliger langer Token * NUXT_OPENROUTER_API_KEY – bereits vorhanden (nur LLM-Path) * * Einmalig auf Server einrichten: * Registriere einen Account mit Username "lyra" in der App, * kopiere die user.id und trage sie als NUXT_LYRA_BOT_USER_ID ein. */ const TOPICS = [ "motivation", "tipp", "zitat", "witzig", "news", "feature", ] as const; const SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der ReBreak-App – einer Gemeinschaft für Menschen auf dem Weg aus der Glücksspielsucht. Du postest gelegentlich kurze Beiträge in der Community. Deine Tonalität: - Warm, ermutigend, menschlich – nie klinisch oder robotisch - Kurz (max. 3–4 Sätze) - Niemals übertrieben motivierend ("Du schaffst das!!!") – eher still stark - Keine Casino-Werbung, keine Links, keine medizinischen Ratschläge - Auf Deutsch Je nach Thema postest du: - "motivation": Ein stiller Gedanke zum Durchhalten - "tipp": Ein konkreter kleiner Tipp aus der Verhaltensforschung/CBT - "news": Eine kurze Einordnung einer Entwicklung in der Glücksspielbanche (warnend, sachlich) - "feature": Ein Hinweis auf ein neues ReBreak-Feature – wie ein Freund der sagt "Übrigens haben wir..." Antworte NUR mit dem Post-Text. Kein "Lyra:" Prefix, keine Anführungszeichen.`; export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); // Auth via Cron-Secret const secret = getHeader(event, "x-cron-secret"); if (!config.cronSecret || secret !== config.cronSecret) { throw createError({ statusCode: 401, message: "Unauthorized" }); } const lyraBotUserId = config.lyraBotUserId; if (!lyraBotUserId) { throw createError({ statusCode: 500, message: "LYRA_BOT_USER_ID nicht konfiguriert", }); } // Max 3x pro Woche: letzten Lyra-Post prüfen const db = usePrisma(); const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); const recentPost = await db.communityPost.findFirst({ where: { userId: lyraBotUserId, createdAt: { gte: threeDaysAgo }, }, orderBy: { createdAt: "desc" }, }); if (recentPost) { return { skipped: true, reason: "Lyra hat in den letzten 3 Tagen bereits gepostet", }; } // Feature-flag: USE_TEMPLATE_CATALOG=true → template-path, false → LLM-path const useTemplateCatalog = process.env.USE_TEMPLATE_CATALOG === "true"; if (useTemplateCatalog) { return await postFromCatalog(db, lyraBotUserId); } else { return await postFromLLM(db, lyraBotUserId, config); } }); // ── Template-Catalog Path ──────────────────────────────────────────────────── async function postFromCatalog(db: ReturnType, lyraBotUserId: string) { // Collect recently used template IDs (last 30 posts) to avoid repeats const recentPosts = await db.communityPost.findMany({ where: { userId: lyraBotUserId }, orderBy: { createdAt: "desc" }, take: LYRA_POST_CATALOG.length, select: { i18nKey: true }, }); const usedIds = recentPosts .map((p) => p.i18nKey) .filter((k): k is string => !!k); const template = pickRandomTemplate(usedIds); // content = DE-fallback text so the DB column is never empty. // Frontend will prefer the i18nKey translation when available. // NOTE: DE fallback text is fetched from locale at runtime in the future; // for now we store the template ID as a sentinel so legacy fallback still // works. Production should have DE locale populated before enabling flag. const fallbackContent = `[lyra:${template.id}]`; const post = await createPost(lyraBotUserId, "community", fallbackContent, undefined, null, template.id); return { success: true, postId: post.id, topic: template.topic, i18nKey: template.id, path: "catalog" }; } // ── LLM Path (Legacy) ──────────────────────────────────────────────────────── async function postFromLLM( _db: ReturnType, lyraBotUserId: string, config: ReturnType, ) { if (!config.openrouterApiKey) { throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" }); } // Zufälliges Thema const topic = TOPICS[Math.floor(Math.random() * TOPICS.length)]; const topicHint: Record<(typeof TOPICS)[number], string> = { motivation: "Schreibe einen kurzen, stillen Gedanken für Menschen die heute kämpfen. Nicht übertrieben – eher ruhig stark.", tipp: "Teile einen kleinen, konkreten Trick aus der Verhaltensforschung oder CBT gegen Spieldrang. Praktisch und direkt.", zitat: "Teile ein tiefgründiges Zitat aus Psychologie, Stoizismus oder Verhaltensforschung – ohne das Wort 'Sucht' zu verwenden. Kurz kommentiert.", witzig: "Schreibe einen witzigen, selbstironischen Post über das Thema Ablenkung, Impulskontrolle oder Gewohnheiten – leicht und menschlich, nicht flach.", news: "Beschreibe kurz eine typische Taktik der Glücksspielindustrie (z.B. Push-Notifications, Bonusangebote) – sachlich und als Warnung formuliert.", feature: "Weise freundlich auf ein ReBreak-Feature hin (Blocker, Streak, Mail-Agent, Lyra-Chat, SOS-Atemübung) – wähle eines zufällig.", }; const response = await $fetch<{ choices: { message: { content: string } }[]; }>("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${config.openrouterApiKey}`, "Content-Type": "application/json", "HTTP-Referer": "https://rebreak.org", "X-Title": "ReBreak - Lyra Community Post", }, body: { model: "meta-llama/llama-3.2-3b-instruct:free", max_tokens: 200, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: topicHint[topic] }, ], }, }); const content = response.choices?.[0]?.message?.content?.trim(); if (!content) { throw createError({ statusCode: 500, message: "Keine Antwort von LLM" }); } const post = await createPost(lyraBotUserId, "community", content); return { success: true, postId: post.id, topic, path: "llm" }; }