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>
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
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<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" };
|
||
}
|