Apply configurable-demo-persona: per-instance demo identity + voice
Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so self-hosted instances can replace Bruno-in-Berlin with their own demo account identity and content pools. No-op for trails.cool — the built-in Bruno persona remains the default. - DemoPersona type + Zod schema validation - loadPersona() cached at boot; falls back to default on any failure - ensureDemoUser throws DemoPersonaUsernameClashError when the persona username is already a real user; server declines to schedule demo jobs for that process - isDemoUser flag moved from hardcoded "bruno" check to loader-supplied boolean computed from the running persona - docs/demo-persona.md explains the schema + operator flow Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f447852d70
commit
fc4485f6ef
15 changed files with 877 additions and 113 deletions
|
|
@ -3,11 +3,14 @@ import { sql } from "drizzle-orm";
|
|||
import { getDb } from "./db.ts";
|
||||
import { activities, routes, users } from "@trails-cool/db/schema/journal";
|
||||
import {
|
||||
DEMO_USERNAME,
|
||||
DEFAULT_PERSONA,
|
||||
DemoPersonaUsernameClashError,
|
||||
__resetPersonaCacheForTests,
|
||||
ensureDemoUser,
|
||||
generateOneWalk,
|
||||
pruneSynthetic,
|
||||
} from "./demo-bot.server.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Opt-in: these tests talk to a real Postgres and are skipped unless
|
||||
// `DEMO_BOT_INTEGRATION=1` is set. CI gates them behind the same flag
|
||||
|
|
@ -41,6 +44,10 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await wipeSynthetic();
|
||||
// Scrub any test-inserted Bruno so the next test starts clean.
|
||||
const db = getDb();
|
||||
await db.execute(sql`DELETE FROM journal.users WHERE username = ${DEFAULT_PERSONA.username}`);
|
||||
__resetPersonaCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -53,10 +60,27 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => {
|
|||
const rows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(sql`${users.username} = ${DEMO_USERNAME}`);
|
||||
.where(sql`${users.username} = ${DEFAULT_PERSONA.username}`);
|
||||
expect(rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it("ensureDemoUser throws username-clash when a real user owns the persona name", async () => {
|
||||
const db = getDb();
|
||||
const domain = process.env.DOMAIN ?? "localhost";
|
||||
const realUserId = randomUUID();
|
||||
// Create a real user at the persona username with a NON-sentinel email.
|
||||
await db.execute(sql`
|
||||
INSERT INTO journal.users (id, email, username, domain, terms_accepted_at, terms_version)
|
||||
VALUES (${realUserId}, ${`real-${Date.now()}@${domain}`}, ${DEFAULT_PERSONA.username}, ${domain}, now(), '2026-04-19')
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(ensureDemoUser()).rejects.toBeInstanceOf(DemoPersonaUsernameClashError);
|
||||
} finally {
|
||||
await db.execute(sql`DELETE FROM journal.users WHERE id = ${realUserId}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("generateOneWalk inserts a public synthetic route + activity", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
import { and, eq, lt, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import { activities, routes, users } from "@trails-cool/db/schema/journal";
|
||||
|
|
@ -11,10 +13,198 @@ import {
|
|||
demoBotSyntheticRoutesTotal,
|
||||
} from "./metrics.server.ts";
|
||||
|
||||
export const DEMO_USERNAME = "bruno";
|
||||
export const DEMO_DISPLAY_NAME = "Bruno";
|
||||
export const DEMO_BIO =
|
||||
"Professional park inspector. Currently accepting tennis balls.";
|
||||
// --- Persona configuration -----------------------------------------------
|
||||
|
||||
export type DemoLocale = "en" | "de";
|
||||
|
||||
export interface DemoPersonaContent {
|
||||
names: { en?: string[]; de?: string[] };
|
||||
descriptions: { en?: string[]; de?: string[] };
|
||||
}
|
||||
|
||||
export interface DemoPersona {
|
||||
username: string;
|
||||
displayName: string;
|
||||
bio: string;
|
||||
locales: DemoLocale[];
|
||||
content: DemoPersonaContent;
|
||||
}
|
||||
|
||||
const LocaleSchema = z.enum(["en", "de"]);
|
||||
const PoolSchema = z.array(z.string().min(1)).min(3).max(50);
|
||||
|
||||
const PersonaSchema = z
|
||||
.object({
|
||||
username: z.string().regex(/^[a-z0-9][a-z0-9_-]{1,30}$/),
|
||||
displayName: z.string().min(1).max(200),
|
||||
bio: z.string().max(200),
|
||||
locales: z.array(LocaleSchema).min(1),
|
||||
content: z.object({
|
||||
names: z.object({ en: PoolSchema.optional(), de: PoolSchema.optional() }),
|
||||
descriptions: z.object({
|
||||
en: PoolSchema.optional(),
|
||||
de: PoolSchema.optional(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.superRefine((p, ctx) => {
|
||||
for (const loc of p.locales) {
|
||||
if (!p.content.names[loc]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content", "names", loc],
|
||||
message: `missing name pool for declared locale '${loc}'`,
|
||||
});
|
||||
}
|
||||
if (!p.content.descriptions[loc]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content", "descriptions", loc],
|
||||
message: `missing description pool for declared locale '${loc}'`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Built-in Bruno persona. Used verbatim when no `DEMO_BOT_PERSONA` is
|
||||
* supplied or the supplied override fails validation. Moving these
|
||||
* strings behind the persona abstraction means every reference to the
|
||||
* bot's identity flows through one place.
|
||||
*/
|
||||
export const DEFAULT_PERSONA: DemoPersona = Object.freeze<DemoPersona>({
|
||||
username: "bruno",
|
||||
displayName: "Bruno",
|
||||
bio: "Professional park inspector. Currently accepting tennis balls.",
|
||||
locales: ["en", "de"],
|
||||
content: {
|
||||
names: {
|
||||
en: [
|
||||
"Grunewald north-loop patrol",
|
||||
"Tiergarten perimeter audit",
|
||||
"Tempelhof runway inspection",
|
||||
"Spree embankment survey",
|
||||
"Bruno's morning rounds",
|
||||
"Sniff-check: Kreuzberg edition",
|
||||
"Bruno found three sticks today",
|
||||
"Dog-audit report: all squirrels present",
|
||||
"Volkspark compliance walk",
|
||||
"Tennis ball reconnaissance",
|
||||
"Pretzel-crumb cleanup detail",
|
||||
"Bruno vs. the pigeons",
|
||||
],
|
||||
de: [
|
||||
"Grunewald-Nordschleife-Patrouille",
|
||||
"Tiergarten-Umfangsprüfung",
|
||||
"Tempelhof-Startbahn-Inspektion",
|
||||
"Spreeufer-Rundgang",
|
||||
"Brunos Morgenrunde",
|
||||
"Schnüffelkontrolle: Kreuzberg",
|
||||
"Bruno hat heute drei Stöcke gefunden",
|
||||
"Hundeprüfbericht: alle Eichhörnchen anwesend",
|
||||
"Volkspark-Konformitätsgang",
|
||||
"Tennisball-Aufklärung",
|
||||
"Brezel-Krümel-Aufräumdienst",
|
||||
"Bruno gegen die Tauben",
|
||||
],
|
||||
},
|
||||
descriptions: {
|
||||
en: [
|
||||
"Inspected several bushes. All present and accounted for.",
|
||||
"Three squirrels successfully monitored. Two got away.",
|
||||
"Good sticks: 2. Great sticks: 1. Excellent sticks: 0.",
|
||||
"Pace was brisk. Sniffs were thorough.",
|
||||
"Encountered another dog. Diplomacy established.",
|
||||
"Weather: windy. Ears: flopping.",
|
||||
"Pigeons stood their ground. A follow-up visit is required.",
|
||||
"Finished the route ahead of schedule. Extra treats expected.",
|
||||
"Investigated one suspicious paper bag. False alarm.",
|
||||
"Logged one (1) successful puddle inspection.",
|
||||
],
|
||||
de: [
|
||||
"Mehrere Büsche inspiziert. Alle vorhanden.",
|
||||
"Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.",
|
||||
"Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.",
|
||||
"Tempo zügig. Schnüffeln gründlich.",
|
||||
"Einen anderen Hund getroffen. Diplomatie hergestellt.",
|
||||
"Wetter: windig. Ohren: flatternd.",
|
||||
"Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.",
|
||||
"Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.",
|
||||
"Eine verdächtige Papiertüte untersucht. Fehlalarm.",
|
||||
"Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sanity: the default persona itself must parse under the schema. Catch
|
||||
// accidental drift at module load rather than first invocation.
|
||||
PersonaSchema.parse(DEFAULT_PERSONA);
|
||||
|
||||
let _cachedPersona: DemoPersona | null = null;
|
||||
|
||||
/**
|
||||
* Read the persona from `DEMO_BOT_PERSONA`. Accepts either inline JSON
|
||||
* or `file:<absolute-path>`. Falls back to `DEFAULT_PERSONA` on any
|
||||
* failure (unset, bad JSON, schema violation, unreadable file) with a
|
||||
* single warn-level log line per process.
|
||||
*/
|
||||
export function loadPersona(): DemoPersona {
|
||||
if (_cachedPersona) return _cachedPersona;
|
||||
_cachedPersona = Object.freeze(loadPersonaUncached());
|
||||
return _cachedPersona;
|
||||
}
|
||||
|
||||
function loadPersonaUncached(): DemoPersona {
|
||||
const raw = process.env.DEMO_BOT_PERSONA;
|
||||
if (!raw) return DEFAULT_PERSONA;
|
||||
|
||||
let json: string;
|
||||
if (raw.startsWith("file:")) {
|
||||
const path = raw.slice("file:".length);
|
||||
try {
|
||||
json = readFileSync(path, "utf8");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ path, err: (err as Error).message },
|
||||
"DEMO_BOT_PERSONA file unreadable, using default",
|
||||
);
|
||||
return DEFAULT_PERSONA;
|
||||
}
|
||||
} else {
|
||||
json = raw;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err: (err as Error).message },
|
||||
"DEMO_BOT_PERSONA not valid JSON, using default",
|
||||
);
|
||||
return DEFAULT_PERSONA;
|
||||
}
|
||||
|
||||
const result = PersonaSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const first = result.error.issues[0];
|
||||
logger.warn(
|
||||
{ path: first?.path.join("."), message: first?.message },
|
||||
"DEMO_BOT_PERSONA schema violation, using default",
|
||||
);
|
||||
return DEFAULT_PERSONA;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only hook to reset the cached persona between tests. Never call
|
||||
* from application code.
|
||||
*/
|
||||
export function __resetPersonaCacheForTests() {
|
||||
_cachedPersona = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner Berlin (Tiergarten → Grunewald corridor). Keeps Bruno's walks
|
||||
|
|
@ -35,28 +225,59 @@ export function demoRetentionDays(): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Idempotent insert of the Bruno demo user. Safe to call every worker
|
||||
* boot; relies on `users.username` uniqueness to short-circuit duplicates.
|
||||
* Surfaced when the persona username already belongs to a real user.
|
||||
* Callers (worker boot) treat this as a signal to skip scheduling the
|
||||
* bot for this process instead of silently attaching to a human's
|
||||
* account.
|
||||
*/
|
||||
export async function ensureDemoUser(): Promise<string> {
|
||||
export class DemoPersonaUsernameClashError extends Error {
|
||||
readonly kind = "username-clash";
|
||||
readonly username: string;
|
||||
constructor(username: string) {
|
||||
super(`demo persona username '${username}' is already taken by a non-demo user`);
|
||||
this.username = username;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedSentinelEmail(persona: DemoPersona, domain: string): string {
|
||||
return `${persona.username}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent insert of the demo user described by `persona`. Safe to
|
||||
* call every worker boot; relies on `users.username` uniqueness to
|
||||
* short-circuit duplicates. Throws `DemoPersonaUsernameClashError` if
|
||||
* the row exists but looks like a real account (email doesn't match
|
||||
* the sentinel pattern) — callers should catch this and keep the bot
|
||||
* disabled for the process.
|
||||
*/
|
||||
export async function ensureDemoUser(
|
||||
persona: DemoPersona = loadPersona(),
|
||||
): Promise<string> {
|
||||
const db = getDb();
|
||||
const domain = process.env.DOMAIN ?? "localhost";
|
||||
const sentinelEmail = expectedSentinelEmail(persona, domain);
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: users.id })
|
||||
.select({ id: users.id, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.username, DEMO_USERNAME));
|
||||
if (existing) return existing.id;
|
||||
.where(eq(users.username, persona.username));
|
||||
if (existing) {
|
||||
if (existing.email !== sentinelEmail) {
|
||||
throw new DemoPersonaUsernameClashError(persona.username);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id,
|
||||
username: DEMO_USERNAME,
|
||||
email: `${DEMO_USERNAME}@${domain}`,
|
||||
displayName: DEMO_DISPLAY_NAME,
|
||||
bio: DEMO_BIO,
|
||||
username: persona.username,
|
||||
email: sentinelEmail,
|
||||
displayName: persona.displayName,
|
||||
bio: persona.bio,
|
||||
domain,
|
||||
termsAcceptedAt: new Date(),
|
||||
termsVersion: TERMS_VERSION,
|
||||
|
|
@ -65,10 +286,13 @@ export async function ensureDemoUser(): Promise<string> {
|
|||
|
||||
// Re-read in case a concurrent insert won the race
|
||||
const [row] = await db
|
||||
.select({ id: users.id })
|
||||
.select({ id: users.id, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.username, DEMO_USERNAME));
|
||||
.where(eq(users.username, persona.username));
|
||||
if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found");
|
||||
if (row.email !== sentinelEmail) {
|
||||
throw new DemoPersonaUsernameClashError(persona.username);
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
|
|
@ -141,63 +365,7 @@ export function pickEndpoints(bbox: [number, number, number, number]): Endpoints
|
|||
return { start, end };
|
||||
}
|
||||
|
||||
// --- Bruno-voiced copy --------------------------------------------------
|
||||
|
||||
const NAME_POOL_EN = [
|
||||
"Grunewald north-loop patrol",
|
||||
"Tiergarten perimeter audit",
|
||||
"Tempelhof runway inspection",
|
||||
"Spree embankment survey",
|
||||
"Bruno's morning rounds",
|
||||
"Sniff-check: Kreuzberg edition",
|
||||
"Bruno found three sticks today",
|
||||
"Dog-audit report: all squirrels present",
|
||||
"Volkspark compliance walk",
|
||||
"Tennis ball reconnaissance",
|
||||
"Pretzel-crumb cleanup detail",
|
||||
"Bruno vs. the pigeons",
|
||||
];
|
||||
|
||||
const NAME_POOL_DE = [
|
||||
"Grunewald-Nordschleife-Patrouille",
|
||||
"Tiergarten-Umfangsprüfung",
|
||||
"Tempelhof-Startbahn-Inspektion",
|
||||
"Spreeufer-Rundgang",
|
||||
"Brunos Morgenrunde",
|
||||
"Schnüffelkontrolle: Kreuzberg",
|
||||
"Bruno hat heute drei Stöcke gefunden",
|
||||
"Hundeprüfbericht: alle Eichhörnchen anwesend",
|
||||
"Volkspark-Konformitätsgang",
|
||||
"Tennisball-Aufklärung",
|
||||
"Brezel-Krümel-Aufräumdienst",
|
||||
"Bruno gegen die Tauben",
|
||||
];
|
||||
|
||||
const DESCRIPTION_POOL_EN = [
|
||||
"Inspected several bushes. All present and accounted for.",
|
||||
"Three squirrels successfully monitored. Two got away.",
|
||||
"Good sticks: 2. Great sticks: 1. Excellent sticks: 0.",
|
||||
"Pace was brisk. Sniffs were thorough.",
|
||||
"Encountered another dog. Diplomacy established.",
|
||||
"Weather: windy. Ears: flopping.",
|
||||
"Pigeons stood their ground. A follow-up visit is required.",
|
||||
"Finished the route ahead of schedule. Extra treats expected.",
|
||||
"Investigated one suspicious paper bag. False alarm.",
|
||||
"Logged one (1) successful puddle inspection.",
|
||||
];
|
||||
|
||||
const DESCRIPTION_POOL_DE = [
|
||||
"Mehrere Büsche inspiziert. Alle vorhanden.",
|
||||
"Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.",
|
||||
"Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.",
|
||||
"Tempo zügig. Schnüffeln gründlich.",
|
||||
"Einen anderen Hund getroffen. Diplomatie hergestellt.",
|
||||
"Wetter: windig. Ohren: flatternd.",
|
||||
"Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.",
|
||||
"Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.",
|
||||
"Eine verdächtige Papiertüte untersucht. Fehlalarm.",
|
||||
"Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.",
|
||||
];
|
||||
// --- Persona-driven copy -------------------------------------------------
|
||||
|
||||
function pickFrom<T>(pool: readonly T[], seed: number): T {
|
||||
const idx = Math.abs(Math.floor(seed)) % pool.length;
|
||||
|
|
@ -205,26 +373,46 @@ function pickFrom<T>(pool: readonly T[], seed: number): T {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deterministic-ish per day + per started-at minute, so same-day walks
|
||||
* don't land on the same name. Locale is chosen from the journal's
|
||||
* default — Bruno doesn't know German, but his walks come with German
|
||||
* subtitles when the reader expects them.
|
||||
* Pull a route name from the persona's name pool for the given locale.
|
||||
* Seeded by the start-time so same-day walks usually don't collide.
|
||||
* Falls back to the first persona locale if the caller asked for a
|
||||
* locale the persona doesn't support (defensive — the caller is
|
||||
* expected to pick from `persona.locales`).
|
||||
*/
|
||||
export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string {
|
||||
const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN;
|
||||
export function templateName(
|
||||
startedAt: Date,
|
||||
locale: DemoLocale,
|
||||
persona: DemoPersona = loadPersona(),
|
||||
): string {
|
||||
const pool = persona.content.names[locale] ?? persona.content.names[persona.locales[0]!]!;
|
||||
const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes();
|
||||
return pickFrom(pool, seed);
|
||||
}
|
||||
|
||||
export function templateDescription(
|
||||
startedAt: Date,
|
||||
locale: "en" | "de" = "en",
|
||||
locale: DemoLocale,
|
||||
persona: DemoPersona = loadPersona(),
|
||||
): string {
|
||||
const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN;
|
||||
const pool =
|
||||
persona.content.descriptions[locale] ??
|
||||
persona.content.descriptions[persona.locales[0]!]!;
|
||||
const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7;
|
||||
return pickFrom(pool, seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random locale from the persona's supported set. Used by the
|
||||
* generation job so instances with `locales: ["en"]` never emit
|
||||
* German-voiced walks.
|
||||
*/
|
||||
export function pickLocale(
|
||||
persona: DemoPersona = loadPersona(),
|
||||
rand: () => number = Math.random,
|
||||
): DemoLocale {
|
||||
return persona.locales[Math.floor(rand() * persona.locales.length)]!;
|
||||
}
|
||||
|
||||
// --- BRouter --------------------------------------------------------------
|
||||
|
||||
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
|
||||
|
|
@ -263,12 +451,14 @@ export async function requestBrouterGpx(
|
|||
|
||||
export interface GenerateOptions {
|
||||
now?: Date;
|
||||
/** Localise the walk name; defaults to random-ish. */
|
||||
locale?: "en" | "de";
|
||||
/** Override the locale; defaults to a random one from the persona. */
|
||||
locale?: DemoLocale;
|
||||
/** Test-only: inject a persona instead of reading the env. */
|
||||
persona?: DemoPersona;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core generate step: insert one Bruno route + activity pair. Callers
|
||||
* Core generate step: insert one demo route + activity pair. Callers
|
||||
* (the recurring job and the backfill loop) decide whether to invoke it.
|
||||
*
|
||||
* Returns the inserted route id on success, or `null` on BRouter failure
|
||||
|
|
@ -276,7 +466,11 @@ export interface GenerateOptions {
|
|||
*/
|
||||
export async function generateOneWalk(
|
||||
ownerId: string,
|
||||
{ now = new Date(), locale = Math.random() < 0.5 ? "en" : "de" }: GenerateOptions = {},
|
||||
{
|
||||
now = new Date(),
|
||||
persona = loadPersona(),
|
||||
locale = pickLocale(persona),
|
||||
}: GenerateOptions = {},
|
||||
): Promise<string | null> {
|
||||
const region = loadRegion();
|
||||
const endpoints = pickEndpoints(region.bbox);
|
||||
|
|
@ -287,8 +481,8 @@ export async function generateOneWalk(
|
|||
return null;
|
||||
}
|
||||
|
||||
const name = templateName(now, locale);
|
||||
const description = templateDescription(now, locale);
|
||||
const name = templateName(now, locale, persona);
|
||||
const description = templateDescription(now, locale, persona);
|
||||
|
||||
let distance: number;
|
||||
let elevationGain: number;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DEFAULT_PERSONA,
|
||||
__resetPersonaCacheForTests,
|
||||
berlinHour,
|
||||
loadPersona,
|
||||
loadRegion,
|
||||
pickEndpoints,
|
||||
pickLocale,
|
||||
shouldWalkNow,
|
||||
templateDescription,
|
||||
templateName,
|
||||
type DemoPersona,
|
||||
} from "./demo-bot.server.ts";
|
||||
|
||||
describe("pickEndpoints", () => {
|
||||
|
|
@ -117,3 +125,156 @@ describe("berlinHour", () => {
|
|||
expect(h).toBeLessThanOrEqual(23);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Persona configuration -------------------------------------------------
|
||||
|
||||
function minimalPersona(overrides: Partial<DemoPersona> = {}): DemoPersona {
|
||||
return {
|
||||
username: "test-demo",
|
||||
displayName: "Test",
|
||||
bio: "test bio",
|
||||
locales: ["en"],
|
||||
content: {
|
||||
names: {
|
||||
en: ["alpha walk", "bravo walk", "charlie walk"],
|
||||
},
|
||||
descriptions: {
|
||||
en: ["alpha desc", "bravo desc", "charlie desc"],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadPersona", () => {
|
||||
const envKey = "DEMO_BOT_PERSONA";
|
||||
let savedEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
__resetPersonaCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) delete process.env[envKey];
|
||||
else process.env[envKey] = savedEnv;
|
||||
__resetPersonaCacheForTests();
|
||||
});
|
||||
|
||||
it("returns the default persona when unset", () => {
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
|
||||
it("caches the result across calls", () => {
|
||||
const a = loadPersona();
|
||||
const b = loadPersona();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("parses a valid inline JSON override", () => {
|
||||
process.env[envKey] = JSON.stringify(minimalPersona({ username: "hamish" }));
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe("hamish");
|
||||
expect(p.locales).toEqual(["en"]);
|
||||
});
|
||||
|
||||
it("falls back to the default on invalid JSON", () => {
|
||||
process.env[envKey] = "{not json";
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
|
||||
it("falls back when the username violates the pattern", () => {
|
||||
process.env[envKey] = JSON.stringify(minimalPersona({ username: "Has Spaces" }));
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
|
||||
it("falls back when a declared locale has no content pool", () => {
|
||||
process.env[envKey] = JSON.stringify(
|
||||
minimalPersona({
|
||||
locales: ["en", "de"],
|
||||
// intentionally omit de pools
|
||||
}),
|
||||
);
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
|
||||
it("falls back when a pool has fewer than 3 entries", () => {
|
||||
process.env[envKey] = JSON.stringify(
|
||||
minimalPersona({
|
||||
content: {
|
||||
names: { en: ["only", "two"] },
|
||||
descriptions: { en: ["d1", "d2", "d3"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
|
||||
it("reads a persona from a file: path", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "demo-persona-"));
|
||||
const path = join(dir, "persona.json");
|
||||
writeFileSync(path, JSON.stringify(minimalPersona({ username: "from-file" })));
|
||||
try {
|
||||
process.env[envKey] = `file:${path}`;
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe("from-file");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back when a file: path is unreadable", () => {
|
||||
process.env[envKey] = "file:/nonexistent/does-not-exist.json";
|
||||
const p = loadPersona();
|
||||
expect(p.username).toBe(DEFAULT_PERSONA.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateName / templateDescription with custom persona", () => {
|
||||
it("draws from the supplied persona's pool and never leaks entries from the default", () => {
|
||||
const persona = minimalPersona();
|
||||
const bannedSubstring = "Grunewald"; // present in DEFAULT_PERSONA but not in `persona`
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(2026, 3, 1 + (i % 28), i % 24, i % 60, i);
|
||||
const name = templateName(d, "en", persona);
|
||||
const desc = templateDescription(d, "en", persona);
|
||||
expect(persona.content.names.en).toContain(name);
|
||||
expect(persona.content.descriptions.en).toContain(desc);
|
||||
expect(name).not.toContain(bannedSubstring);
|
||||
expect(desc).not.toContain(bannedSubstring);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickLocale", () => {
|
||||
it("only returns locales declared by the persona", () => {
|
||||
const persona = minimalPersona({ locales: ["en"] });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(pickLocale(persona)).toBe("en");
|
||||
}
|
||||
});
|
||||
|
||||
it("distributes across declared locales", () => {
|
||||
const persona = minimalPersona({
|
||||
locales: ["en", "de"],
|
||||
content: {
|
||||
names: {
|
||||
en: ["en1", "en2", "en3"],
|
||||
de: ["de1", "de2", "de3"],
|
||||
},
|
||||
descriptions: {
|
||||
en: ["d1", "d2", "d3"],
|
||||
de: ["ds1", "ds2", "ds3"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(pickLocale(persona, () => 0)).toBe("en");
|
||||
expect(pickLocale(persona, () => 0.99)).toBe("de");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
|
|||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { listPublicRoutesForOwner } from "~/lib/routes.server";
|
||||
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
|
||||
import { loadPersona } from "~/lib/demo-bot.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
|
@ -31,6 +32,11 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
throw data({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Demo-account badge: true when this profile matches the instance's
|
||||
// configured demo persona username. Computed server-side so we don't
|
||||
// ship the persona config through client HTML.
|
||||
const isDemoUser = user.username === loadPersona().username;
|
||||
|
||||
return data({
|
||||
user: {
|
||||
username: user.username,
|
||||
|
|
@ -57,6 +63,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
createdAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
isOwn,
|
||||
isDemoUser,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -82,11 +89,9 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||
const { user, routes, activities, isOwn } = loaderData;
|
||||
const { user, routes, activities, isOwn, isDemoUser } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
|
||||
const isDemo = user.username === "bruno";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<div className="flex items-start gap-4">
|
||||
|
|
@ -98,7 +103,7 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
|||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{user.displayName ?? user.username}
|
||||
</h1>
|
||||
{isDemo && (
|
||||
{isDemoUser && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
|
||||
{t("demo.badge")}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@
|
|||
"prom-client": "^15.1.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:"
|
||||
"react-router": "catalog:",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "catalog:",
|
||||
|
|
|
|||
|
|
@ -108,21 +108,38 @@ server.listen(port, async () => {
|
|||
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
||||
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
||||
|
||||
// Start background job worker
|
||||
const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
|
||||
const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
|
||||
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
|
||||
await startWorker(boss, [demoBotGenerateJob, demoBotPruneJob]);
|
||||
logger.info("Background job worker started");
|
||||
|
||||
// Bootstrap the demo user when the bot is enabled; cheap idempotent insert.
|
||||
// Pre-flight the demo user so a persona username that clashes with a
|
||||
// real account blocks job scheduling rather than silently attaching
|
||||
// synthetic rows to a human.
|
||||
let enableDemoJobs = false;
|
||||
if (process.env.DEMO_BOT_ENABLED === "true") {
|
||||
const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts");
|
||||
const { ensureDemoUser, DemoPersonaUsernameClashError } = await import(
|
||||
"./app/lib/demo-bot.server.ts"
|
||||
);
|
||||
try {
|
||||
const id = await ensureDemoUser();
|
||||
logger.info({ id }, "demo-bot user ensured");
|
||||
enableDemoJobs = true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "demo-bot ensureDemoUser failed");
|
||||
if (err instanceof DemoPersonaUsernameClashError) {
|
||||
logger.error(
|
||||
{ username: err.username },
|
||||
"demo persona username clash — demo jobs disabled for this process",
|
||||
);
|
||||
} else {
|
||||
logger.error({ err }, "demo-bot ensureDemoUser failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start background job worker
|
||||
const demoJobs: Parameters<typeof startWorker>[1] = [];
|
||||
if (enableDemoJobs) {
|
||||
const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
|
||||
const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
|
||||
demoJobs.push(demoBotGenerateJob, demoBotPruneJob);
|
||||
}
|
||||
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
|
||||
await startWorker(boss, demoJobs);
|
||||
logger.info("Background job worker started");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue