chahinebrini 2e409efaf0 feat(onboarding/android + backend/lyra-i18n): platform-dispatch + post-catalog scaffold
Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
  → wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
  cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*

Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
  (NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
  default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten

Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:48:25 +02:00

188 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. 34 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<typeof usePrisma>, 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<typeof usePrisma>,
lyraBotUserId: string,
config: ReturnType<typeof useRuntimeConfig>,
) {
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" };
}