Ctrl K
ComunidadGuías…

server/bot-profile.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { z } from "zod";

import {
  AVATAR_ZOOM_MAX,
  AVATAR_ZOOM_MIN,
  BOT_AVATAR_CROPS,
  botAvatarCropSchema,
  botAvatarUrlSchema,
  mascotFaceColorSchema,
} from "../shared/bot-avatar.ts";
import { BOT_PROFILE_LIMITS, fitsOnOneLine } from "../shared/bot-profile.ts";
import { REPLY_STYLES } from "../shared/reply-style.ts";
import { BOT_BODY_IDS, botBodySchema } from "../shared/bot-body.ts";

import type { BotRecord } from "./store.ts";

export { fitsOnOneLine } from "../shared/bot-profile.ts";

export const BOT_PROFILE_PATCH_FIELDS = [
  "name",
  "title",
  "description",
  "soul",
  "notifications",
  "avatarUrl",
  "avatarCrop",
  "avatarZoom",
  "avatarFocusX",
  "avatarFocusY",
  "mascotBody",
  "mascotFaceColor",
  "voice",
  "speakReplies",
  "replyStyle",
] as const;

export const profilePatchSchema = z.object({
  name: z
    .string({ error: "name must be a string" })
    .max(BOT_PROFILE_LIMITS.name, { error: "name must be at most 100 characters" })
    .refine((value) => Boolean(value.trim()), { error: "name must not be empty" })
    .refine(fitsOnOneLine, { error: "name must fit on one line" })
    .optional(),
  title: z
    .string({ error: "title must be a string" })
    .max(BOT_PROFILE_LIMITS.title, { error: "title must be at most 200 characters" })
    .refine(fitsOnOneLine, { error: "title must fit on one line" })
    .optional(),
  description: z
    .string({ error: "description must be a string" })
    .max(BOT_PROFILE_LIMITS.description, { error: "description must be at most 4000 characters" })
    .optional(),
  soul: z
    .string({ error: "soul must be a string" })
    .refine((value) => Buffer.byteLength(value, "utf8") <= BOT_PROFILE_LIMITS.soul, {
      error: "standing instructions must be at most 24000 bytes",
    })
    .optional(),
  notifications: z.boolean({ error: "notifications must be true or false" }).optional(),
  avatarUrl: z
    .union([botAvatarUrlSchema, z.literal(""), z.null()], {
      error: "avatarUrl must be a stored PNG, JPEG, GIF, or WebP attachment",
    })
    .optional(),
  avatarCrop: botAvatarCropSchema.optional(),
  avatarZoom: z.number({ error: "avatarZoom must be a number from 1 to 3" }).finite().min(AVATAR_ZOOM_MIN).max(AVATAR_ZOOM_MAX).optional(),
  avatarFocusX: z.number({ error: "avatarFocusX must be a number from 0 to 1" }).finite().min(0).max(1).optional(),
  avatarFocusY: z.number({ error: "avatarFocusY must be a number from 0 to 1" }).finite().min(0).max(1).optional(),
  mascotBody: botBodySchema.optional(),
  mascotFaceColor: mascotFaceColorSchema.nullable().optional(),
  voice: z
    .string({ error: "voice must be a string" })
    .max(BOT_PROFILE_LIMITS.voice, { error: "voice must be at most 200 characters" })
    .optional(),
  speakReplies: z.boolean({ error: "speakReplies must be true or false" }).optional(),
  replyStyle: z.enum(REPLY_STYLES, { error: "replyStyle must be default or conversational" }).optional(),
});

export type BotProfilePatchInput = z.input<typeof profilePatchSchema>;

export type BotProfilePatch = Partial<
  Pick<
    BotRecord,
    | "name"
    | "title"
    | "description"
    | "soul"
    | "notifications"
    | "avatarUrl"
    | "avatarCrop"
    | "avatarZoom"
    | "avatarFocusX"
    | "avatarFocusY"
    | "mascotBody"
    | "mascotFaceColor"
    | "voice"
    | "speakReplies"
    | "replyStyle"
  >
>;

export type BotProfilePatchResult =
  | { ok: true; patch: BotProfilePatch }
  | { ok: false; error: string };

/** Paired clients use strict=true, so a future bot field never silently becomes remotely writable. A cleared avatarUrl is
 * `undefined` here (persistence omits it) and goes to clients as null, so Codable and object-spread clients both clear it. */
export function parseBotProfilePatch(input: BotProfilePatchInput, strict = false): BotProfilePatchResult {
  const parsed = (strict ? profilePatchSchema.strict() : profilePatchSchema).safeParse(input);
  if (!parsed.success) {
    const unsupported = parsed.error.issues.find((issue) => issue.code === "unrecognized_keys");
    if (unsupported?.code === "unrecognized_keys") {
      return { ok: false, error: `unsupported profile field: ${unsupported.keys[0] ?? "unknown"}` };
    }
    const issue = parsed.error.issues[0];
    if (issue?.path[0] === "avatarCrop") {
      const options = `${BOT_AVATAR_CROPS.slice(0, -1).join(", ")}, or ${BOT_AVATAR_CROPS.at(-1)}`;
      return { ok: false, error: `avatarCrop must be ${options}` };
    }
    if (issue?.path[0] === "mascotBody") {
      const options = `${BOT_BODY_IDS.slice(0, -1).join(", ")}, or ${BOT_BODY_IDS.at(-1)}`;
      return { ok: false, error: `mascotBody must be ${options}` };
    }
    if (issue?.path[0] === "mascotFaceColor") {
      return { ok: false, error: "mascotFaceColor must be white, black, or null" };
    }
    return { ok: false, error: issue?.message ?? "invalid profile patch" };
  }

  const { avatarUrl, ...fields } = parsed.data;
  const patch: BotProfilePatch = fields;
  if (avatarUrl !== undefined) patch.avatarUrl = avatarUrl || undefined;
  return { ok: true, patch };
}