shitton of the ai changes
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import {DatabaseManager} from "../db/database-manager";
|
||||
import type {AiRequestDbRow} from "../db/db-types";
|
||||
import type {StoredAiRequest} from "../model/stored-ai-request";
|
||||
|
||||
function toDbRow(request: StoredAiRequest): AiRequestDbRow {
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
chatId: request.chatId,
|
||||
messageId: request.messageId,
|
||||
responseMessageId: request.responseMessageId ?? null,
|
||||
fromId: request.fromId,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
status: request.status,
|
||||
startedAt: request.startedAt,
|
||||
finishedAt: request.finishedAt ?? null,
|
||||
error: request.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class AiRequestStore {
|
||||
static async put(request: StoredAiRequest): Promise<void> {
|
||||
await DatabaseManager.upsertAiRequests([toDbRow(request)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {createHash} from "node:crypto";
|
||||
import {DatabaseManager} from "../db/database-manager";
|
||||
import type {ArtifactDbRow} from "../db/db-types";
|
||||
import type {StoredAttachment} from "../model/stored-attachment";
|
||||
import type {PipelineArtifactKind} from "../ai/user-request-pipeline";
|
||||
|
||||
export type StoredArtifactRecord = {
|
||||
id: string;
|
||||
requestId: string;
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
kind: string;
|
||||
stage: string;
|
||||
attachmentId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
attachment?: StoredAttachment;
|
||||
};
|
||||
|
||||
function hashId(parts: Array<string | number | null | undefined>): string {
|
||||
return createHash("sha256").update(parts.map(part => part === null || part === undefined ? "" : String(part)).join("\u0000")).digest("hex");
|
||||
}
|
||||
|
||||
function parsePayload(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isPipelineArtifactKind(value: unknown): value is PipelineArtifactKind {
|
||||
return value === "rag"
|
||||
|| value === "transcript"
|
||||
|| value === "tool_result"
|
||||
|| value === "generated_file"
|
||||
|| value === "tts_audio"
|
||||
|| value === "final_text"
|
||||
|| value === "error";
|
||||
}
|
||||
|
||||
function storedAttachmentFromPayload(payload: Record<string, unknown>): StoredAttachment | undefined {
|
||||
const kind = payload.kind;
|
||||
const fileId = payload.fileId;
|
||||
const fileName = payload.fileName;
|
||||
const cachePath = payload.cachePath;
|
||||
|
||||
if (typeof kind !== "string" || typeof fileId !== "string" || typeof fileName !== "string" || typeof cachePath !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "document",
|
||||
fileId,
|
||||
fileUniqueId: typeof payload.fileUniqueId === "string" ? payload.fileUniqueId : undefined,
|
||||
fileName,
|
||||
mimeType: typeof payload.mimeType === "string" ? payload.mimeType : undefined,
|
||||
cachePath,
|
||||
sizeBytes: typeof payload.sizeBytes === "number" ? payload.sizeBytes : undefined,
|
||||
sha256: typeof payload.sha256 === "string" ? payload.sha256 : undefined,
|
||||
scope: typeof payload.scope === "string" ? payload.scope as StoredAttachment["scope"] : undefined,
|
||||
artifactKind: isPipelineArtifactKind(kind) ? kind : undefined,
|
||||
metadata: typeof payload.metadata === "object" && payload.metadata !== null ? payload.metadata as Record<string, unknown> : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toStoredArtifact(row: ArtifactDbRow): StoredArtifactRecord {
|
||||
const payload = parsePayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
requestId: row.requestId,
|
||||
messageChatId: row.messageChatId,
|
||||
messageId: row.messageId,
|
||||
kind: row.kind,
|
||||
stage: row.stage,
|
||||
attachmentId: row.attachmentId,
|
||||
payload,
|
||||
createdAt: row.createdAt,
|
||||
attachment: storedAttachmentFromPayload(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function toArtifactDbRow(record: StoredArtifactRecord): ArtifactDbRow {
|
||||
return {
|
||||
id: record.id || hashId([record.requestId, record.messageChatId, record.messageId, record.kind, record.attachmentId ?? "", record.createdAt]),
|
||||
requestId: record.requestId,
|
||||
messageChatId: record.messageChatId,
|
||||
messageId: record.messageId,
|
||||
kind: record.kind,
|
||||
stage: record.stage,
|
||||
attachmentId: record.attachmentId,
|
||||
payload: JSON.stringify(record.payload),
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export class ArtifactStore {
|
||||
static async put(record: StoredArtifactRecord | StoredArtifactRecord[]): Promise<void> {
|
||||
const rows = Array.isArray(record) ? record.map(toArtifactDbRow) : [toArtifactDbRow(record)];
|
||||
await DatabaseManager.upsertArtifacts(rows);
|
||||
}
|
||||
|
||||
static async putMessageArtifacts(params: {
|
||||
requestId: string;
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
attachments: StoredAttachment[];
|
||||
stage?: string;
|
||||
createdAt?: string;
|
||||
}): Promise<void> {
|
||||
const createdAt = params.createdAt ?? new Date().toISOString();
|
||||
const rows = params.attachments
|
||||
.filter(attachment => Boolean(attachment.artifactKind))
|
||||
.map((attachment, index) => ({
|
||||
id: hashId([
|
||||
params.requestId,
|
||||
params.messageChatId,
|
||||
params.messageId,
|
||||
attachment.artifactKind ?? "unknown",
|
||||
attachment.fileUniqueId ?? attachment.fileId,
|
||||
createdAt,
|
||||
index,
|
||||
]),
|
||||
requestId: params.requestId,
|
||||
messageChatId: params.messageChatId,
|
||||
messageId: params.messageId,
|
||||
kind: attachment.artifactKind ?? "unknown",
|
||||
stage: params.stage ?? attachment.artifactKind ?? "unknown",
|
||||
attachmentId: attachment.fileUniqueId ?? attachment.fileId,
|
||||
payload: {
|
||||
kind: attachment.artifactKind ?? "unknown",
|
||||
fileId: attachment.fileId,
|
||||
fileUniqueId: attachment.fileUniqueId ?? null,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType ?? null,
|
||||
cachePath: attachment.cachePath,
|
||||
sizeBytes: attachment.sizeBytes ?? null,
|
||||
sha256: attachment.sha256 ?? null,
|
||||
scope: attachment.scope ?? null,
|
||||
metadata: attachment.metadata ?? null,
|
||||
createdAt,
|
||||
},
|
||||
createdAt,
|
||||
}));
|
||||
|
||||
await ArtifactStore.put(rows);
|
||||
}
|
||||
|
||||
static async getByRequestId(requestId: string): Promise<StoredArtifactRecord[]> {
|
||||
const rows = await DatabaseManager.getArtifactsByRequestId(requestId);
|
||||
return rows.map(toStoredArtifact);
|
||||
}
|
||||
|
||||
static async getByMessage(chatId: number, messageId: number): Promise<StoredArtifactRecord[]> {
|
||||
const rows = await DatabaseManager.getArtifactsByMessage(chatId, messageId);
|
||||
return rows.map(toStoredArtifact);
|
||||
}
|
||||
|
||||
static async getLatestRagForReplyChain(chatId: number, messageId: number): Promise<StoredArtifactRecord | null> {
|
||||
let current = await DatabaseManager.getMessageById(chatId, messageId);
|
||||
|
||||
while (current) {
|
||||
const artifacts = await ArtifactStore.getByMessage(current.chatId, current.id);
|
||||
const rag = artifacts.filter(artifact => artifact.kind === "rag").sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1);
|
||||
if (rag) return rag;
|
||||
if (!current.replyToMessageId) break;
|
||||
current = await DatabaseManager.getMessageById(chatId, current.replyToMessageId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getTranscriptForMessage(chatId: number, messageId: number): Promise<StoredArtifactRecord | null> {
|
||||
const artifacts = await ArtifactStore.getByMessage(chatId, messageId);
|
||||
return artifacts.find(artifact => artifact.kind === "transcript") ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {DatabaseManager} from "../db/database-manager";
|
||||
import type {AttachmentDbRow} from "../db/db-types";
|
||||
import type {StoredAttachment} from "../model/stored-attachment";
|
||||
|
||||
function toAttachmentRow(input: {
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
attachment: StoredAttachment;
|
||||
direction: string;
|
||||
createdAt: string;
|
||||
ordinal: number;
|
||||
}): AttachmentDbRow {
|
||||
const attachment = input.attachment;
|
||||
const idSource = [
|
||||
input.messageChatId,
|
||||
input.messageId,
|
||||
input.direction,
|
||||
attachment.scope ?? "user_input",
|
||||
attachment.kind,
|
||||
attachment.fileUniqueId ?? attachment.fileId,
|
||||
attachment.fileName,
|
||||
attachment.cachePath,
|
||||
attachment.artifactKind ?? "",
|
||||
input.ordinal,
|
||||
].join(":");
|
||||
|
||||
return {
|
||||
id: idSource,
|
||||
messageChatId: input.messageChatId,
|
||||
messageId: input.messageId,
|
||||
direction: input.direction,
|
||||
scope: attachment.scope ?? "user_input",
|
||||
kind: attachment.kind,
|
||||
artifactKind: attachment.artifactKind ?? null,
|
||||
fileId: attachment.fileId,
|
||||
fileUniqueId: attachment.fileUniqueId ?? null,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType ?? null,
|
||||
cachePath: attachment.cachePath,
|
||||
sizeBytes: attachment.sizeBytes ?? null,
|
||||
sha256: attachment.sha256 ?? null,
|
||||
metadata: attachment.metadata ? JSON.stringify(attachment.metadata) : null,
|
||||
createdAt: input.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export class AttachmentStore {
|
||||
static async putMessageAttachments(params: {
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
attachments: StoredAttachment[];
|
||||
direction?: string;
|
||||
createdAt?: string;
|
||||
}): Promise<void> {
|
||||
const rows = params.attachments.map((attachment, ordinal) => toAttachmentRow({
|
||||
messageChatId: params.messageChatId,
|
||||
messageId: params.messageId,
|
||||
attachment,
|
||||
direction: params.direction ?? (attachment.scope === "bot_output" ? "output" : "input"),
|
||||
createdAt: params.createdAt ?? new Date().toISOString(),
|
||||
ordinal,
|
||||
}));
|
||||
|
||||
await DatabaseManager.upsertAttachments(rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type BoundaryPrimitive = string | number | boolean | bigint | null | undefined;
|
||||
|
||||
export type BoundaryValue = BoundaryPrimitive | object | readonly BoundaryValue[];
|
||||
|
||||
export interface BoundaryRecord {
|
||||
readonly [key: string]: BoundaryValue;
|
||||
}
|
||||
|
||||
export type ErrorLike = Error | string | BoundaryRecord;
|
||||
+1957
-114
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import {AsyncLocalStorage} from "node:async_hooks";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {appLogger} from "../logging/logger";
|
||||
|
||||
const logger = appLogger.child("localization");
|
||||
|
||||
export const DEFAULT_LOCALE = "en";
|
||||
export const DEFAULT_LANGUAGE_CHOICE = "default";
|
||||
|
||||
export type LanguageChoice = string;
|
||||
export type LocalizationParam = string | number | boolean | null | undefined;
|
||||
export type LocalizationParams = Record<string, LocalizationParam>;
|
||||
interface LocalizationBundle {
|
||||
readonly [key: string]: LocalizationValue;
|
||||
}
|
||||
type LocalizationValue = string | number | boolean | null | undefined | readonly LocalizationValue[] | LocalizationBundle;
|
||||
|
||||
const KNOWN_LANGUAGE_ORDER = ["en", "ru", "ua"];
|
||||
|
||||
function normalizeLanguageCode(value: string | undefined | null): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase().replace("_", "-");
|
||||
if (!normalized) return undefined;
|
||||
|
||||
const code = normalized.split("-")[0];
|
||||
return code === "uk" ? "ua" : code;
|
||||
}
|
||||
|
||||
function readMtimeMs(filePath: string): number | undefined {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function valueByPath(bundle: LocalizationBundle, key: string): LocalizationValue | undefined {
|
||||
if (Object.prototype.hasOwnProperty.call(bundle, key)) {
|
||||
return bundle[key];
|
||||
}
|
||||
|
||||
return key.split(".").reduce<LocalizationValue | undefined>((value, part) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
return (value as LocalizationBundle)[part];
|
||||
}, bundle);
|
||||
}
|
||||
|
||||
function interpolate(value: string, params: LocalizationParams): string {
|
||||
return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
|
||||
const param = params[key];
|
||||
return param === undefined || param === null ? match : String(param);
|
||||
});
|
||||
}
|
||||
|
||||
export class Localization {
|
||||
private static localesDir = path.resolve("locales");
|
||||
private static bundles = new Map<string, LocalizationBundle>();
|
||||
private static fileMtimeMs = new Map<string, number | undefined>();
|
||||
private static fileSignature = "";
|
||||
private static readonly storage = new AsyncLocalStorage<string>();
|
||||
|
||||
static configure(localesDir: string): void {
|
||||
Localization.localesDir = path.resolve(localesDir);
|
||||
Localization.reload(true);
|
||||
}
|
||||
|
||||
static reloadIfChanged(): void {
|
||||
Localization.reload(false);
|
||||
}
|
||||
|
||||
static runWithLocale<T>(locale: string, callback: () => T): T {
|
||||
const resolved = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE;
|
||||
return Localization.storage.run(resolved, callback);
|
||||
}
|
||||
|
||||
static currentLocale(): string {
|
||||
return Localization.storage.getStore() ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
static resolveLocale(choice: LanguageChoice | undefined | null, telegramLanguageCode?: string): string {
|
||||
Localization.reloadIfChanged();
|
||||
|
||||
const normalizedChoice = Localization.normalizeLocale(choice);
|
||||
if (normalizedChoice && normalizedChoice !== DEFAULT_LANGUAGE_CHOICE && Localization.bundles.has(normalizedChoice)) {
|
||||
return normalizedChoice;
|
||||
}
|
||||
|
||||
const telegramLocale = Localization.normalizeLocale(telegramLanguageCode);
|
||||
if (telegramLocale && Localization.bundles.has(telegramLocale)) {
|
||||
return telegramLocale;
|
||||
}
|
||||
|
||||
return Localization.bundles.has(DEFAULT_LOCALE)
|
||||
? DEFAULT_LOCALE
|
||||
: Localization.availableLocaleCodes()[0] ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
static normalizeLocale(value: LanguageChoice | undefined | null): string | undefined {
|
||||
return normalizeLanguageCode(value);
|
||||
}
|
||||
|
||||
static isKnownLanguageChoice(value: string | undefined | null): boolean {
|
||||
if (!value) return false;
|
||||
if (value === DEFAULT_LANGUAGE_CHOICE) return true;
|
||||
|
||||
const normalized = Localization.normalizeLocale(value);
|
||||
if (!normalized) return false;
|
||||
|
||||
Localization.reloadIfChanged();
|
||||
return Localization.bundles.has(normalized);
|
||||
}
|
||||
|
||||
static availableLocaleCodes(): string[] {
|
||||
Localization.reloadIfChanged();
|
||||
|
||||
return [...Localization.bundles.keys()].sort((a, b) => {
|
||||
const aIndex = KNOWN_LANGUAGE_ORDER.indexOf(a);
|
||||
const bIndex = KNOWN_LANGUAGE_ORDER.indexOf(b);
|
||||
|
||||
if (aIndex !== -1 || bIndex !== -1) {
|
||||
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex)
|
||||
- (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
static languageChoices(): string[] {
|
||||
return [DEFAULT_LANGUAGE_CHOICE, ...Localization.availableLocaleCodes()];
|
||||
}
|
||||
|
||||
static languageLabel(choice: LanguageChoice): string {
|
||||
if (choice === DEFAULT_LANGUAGE_CHOICE) {
|
||||
return Localization.text("language.default", {}, "Default");
|
||||
}
|
||||
|
||||
const locale = Localization.normalizeLocale(choice) ?? choice;
|
||||
return Localization.text(`language.${locale}`, {}, locale.toUpperCase());
|
||||
}
|
||||
|
||||
static languageInstructionName(choice: LanguageChoice): string {
|
||||
if (choice === DEFAULT_LANGUAGE_CHOICE) return "";
|
||||
|
||||
const locale = Localization.normalizeLocale(choice) ?? choice;
|
||||
const bundle = Localization.bundles.get(locale);
|
||||
const value = bundle ? valueByPath(bundle, "language.instructionName") : undefined;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : locale;
|
||||
}
|
||||
|
||||
static text(
|
||||
key: string,
|
||||
params: LocalizationParams = {},
|
||||
fallback = key,
|
||||
locale = Localization.currentLocale(),
|
||||
): string {
|
||||
Localization.reloadIfChanged();
|
||||
|
||||
const value = Localization.lookup(locale, key);
|
||||
return interpolate(typeof value === "string" ? value : fallback, params);
|
||||
}
|
||||
|
||||
static textArray(
|
||||
key: string,
|
||||
params: LocalizationParams = {},
|
||||
fallback: string[] = [],
|
||||
locale = Localization.currentLocale(),
|
||||
): string[] {
|
||||
Localization.reloadIfChanged();
|
||||
|
||||
const value = Localization.lookup(locale, key);
|
||||
const values = Array.isArray(value) && value.every(item => typeof item === "string")
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
return values.map(item => interpolate(item, params));
|
||||
}
|
||||
|
||||
private static lookup(locale: string, key: string): LocalizationValue | undefined {
|
||||
const normalized = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE;
|
||||
const bundleValue = Localization.lookupInBundle(normalized, key);
|
||||
if (bundleValue !== undefined) return bundleValue;
|
||||
|
||||
if (normalized !== DEFAULT_LOCALE) {
|
||||
const fallbackValue = Localization.lookupInBundle(DEFAULT_LOCALE, key);
|
||||
if (fallbackValue !== undefined) return fallbackValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static lookupInBundle(locale: string, key: string): LocalizationValue | undefined {
|
||||
const bundle = Localization.bundles.get(locale);
|
||||
return bundle ? valueByPath(bundle, key) : undefined;
|
||||
}
|
||||
|
||||
private static listLocaleFiles(): Map<string, string> {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
if (!fs.existsSync(Localization.localesDir)) {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(Localization.localesDir, {withFileTypes: true})) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
|
||||
const locale = Localization.normalizeLocale(path.basename(entry.name, ".json"));
|
||||
if (locale) {
|
||||
files.set(locale, path.join(Localization.localesDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static reload(force: boolean): void {
|
||||
try {
|
||||
const files = Localization.listLocaleFiles();
|
||||
const signature = [...files.entries()]
|
||||
.map(([locale, filePath]) => `${locale}:${filePath}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
const mtimes = new Map<string, number | undefined>();
|
||||
let changed = force || signature !== Localization.fileSignature;
|
||||
|
||||
for (const [locale, filePath] of files) {
|
||||
const mtimeMs = readMtimeMs(filePath);
|
||||
mtimes.set(locale, mtimeMs);
|
||||
|
||||
if (mtimeMs !== Localization.fileMtimeMs.get(locale)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
const bundles = new Map<string, LocalizationBundle>();
|
||||
for (const [locale, filePath] of files) {
|
||||
try {
|
||||
bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle);
|
||||
} catch (e) {
|
||||
logger.error("file_load.failed", {filePath, locale, error: e instanceof Error ? e : String(e)});
|
||||
const previous = Localization.bundles.get(locale);
|
||||
if (previous) bundles.set(locale, previous);
|
||||
}
|
||||
}
|
||||
|
||||
Localization.bundles = bundles;
|
||||
Localization.fileMtimeMs = mtimes;
|
||||
Localization.fileSignature = signature;
|
||||
logger.debug("reload.done", {force, locales: [...bundles.keys()]});
|
||||
} catch (e) {
|
||||
logger.error("reload.failed", {error: e instanceof Error ? e : String(e)});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
export type MessageImagePart = {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export type MessageAudioPart = {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export type MessagePart = {
|
||||
bot: boolean;
|
||||
name?: string;
|
||||
langCode?: string;
|
||||
userName?: string;
|
||||
content: string;
|
||||
images: string[];
|
||||
}
|
||||
deletedByBotAt?: number | null;
|
||||
images?: string[];
|
||||
imageParts?: MessageImagePart[];
|
||||
audios?: string[];
|
||||
audioParts?: MessageAudioPart[];
|
||||
documents?: string[];
|
||||
videos?: string[];
|
||||
videoNotes?: string[];
|
||||
}
|
||||
|
||||
+45
-11
@@ -2,9 +2,15 @@ import {StoredMessage} from "../model/stored-message";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import {extractTextMessage, getPhotoMaxSize, isStoredMessage} from "../util/utils";
|
||||
import {messageDao} from "../index";
|
||||
import {KeyedAsyncLock} from "../util/async-lock";
|
||||
import {setLruMapValue} from "../util/lru-map";
|
||||
import {createStoredImageAttachment} from "./stored-attachment-utils";
|
||||
|
||||
const MESSAGE_CACHE_MAX_ENTRIES = 10_000;
|
||||
|
||||
export class MessageStore {
|
||||
private static map = new Map<string, StoredMessage>();
|
||||
private static locks = new KeyedAsyncLock();
|
||||
|
||||
private static key(chatId: number, messageId: number) {
|
||||
return `${chatId}:${messageId}`;
|
||||
@@ -15,30 +21,58 @@ export class MessageStore {
|
||||
}
|
||||
|
||||
static async put(m: Message | StoredMessage): Promise<StoredMessage> {
|
||||
const maxSize = isStoredMessage(m) ? null : getPhotoMaxSize(m.photo);
|
||||
|
||||
const msg: StoredMessage = isStoredMessage(m) ? m : {
|
||||
chatId: m.chat.id,
|
||||
id: m.message_id,
|
||||
replyToMessageId: m.reply_to_message?.message_id ?? null,
|
||||
fromId: m.from.id,
|
||||
replyToMessageId: m.reply_to_message?.message_id,
|
||||
fromId: <number>m.from?.id,
|
||||
text: extractTextMessage(m),
|
||||
date: m.date ?? 0,
|
||||
photoMaxSizeFilePath: m.photo ? [getPhotoMaxSize(m.photo).file_unique_id] : null
|
||||
};
|
||||
quoteText: m.quote?.text,
|
||||
date: m.date ?? 0,
|
||||
deletedByBotAt: undefined,
|
||||
attachments: maxSize ? [createStoredImageAttachment({
|
||||
fileId: maxSize.file_id,
|
||||
fileUniqueId: maxSize.file_unique_id,
|
||||
fileName: `${maxSize.file_unique_id || maxSize.file_id}.jpg`,
|
||||
})] : undefined,
|
||||
pipelineAudit: undefined,
|
||||
};
|
||||
|
||||
this.map.set(this.key(msg.chatId, msg.id), msg);
|
||||
await messageDao.insert(messageDao.mapStoredTo([msg]));
|
||||
return msg;
|
||||
const key = this.key(msg.chatId, msg.id);
|
||||
return this.locks.runExclusive(key, async () => {
|
||||
const existing = this.map.get(key) ?? await messageDao.getById({chatId: msg.chatId, id: msg.id});
|
||||
const merged: StoredMessage = {
|
||||
chatId: msg.chatId,
|
||||
id: msg.id,
|
||||
replyToMessageId: msg.replyToMessageId ?? existing?.replyToMessageId,
|
||||
fromId: msg.fromId,
|
||||
text: msg.text !== undefined ? msg.text : existing?.text,
|
||||
quoteText: msg.quoteText ? msg.quoteText : existing?.quoteText,
|
||||
date: msg.date,
|
||||
deletedByBotAt: msg.deletedByBotAt !== undefined ? msg.deletedByBotAt : existing?.deletedByBotAt,
|
||||
attachments: msg.attachments !== undefined ? msg.attachments : existing?.attachments,
|
||||
pipelineAudit: msg.pipelineAudit !== undefined ? msg.pipelineAudit : existing?.pipelineAudit,
|
||||
};
|
||||
|
||||
setLruMapValue(this.map, key, merged, MESSAGE_CACHE_MAX_ENTRIES);
|
||||
await messageDao.insert(messageDao.mapStoredTo([merged]));
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
static async get(chatId: number, messageId: number): Promise<StoredMessage | null> {
|
||||
static async get(chatId: number, messageId: number | undefined): Promise<StoredMessage | null> {
|
||||
if (!messageId) return null;
|
||||
|
||||
const message = await messageDao.getById({chatId: chatId, id: messageId});
|
||||
if (!message) return null;
|
||||
|
||||
this.map.set(this.key(message.chatId, messageId), message);
|
||||
setLruMapValue(this.map, this.key(message.chatId, messageId), message, MESSAGE_CACHE_MAX_ENTRIES);
|
||||
return message;
|
||||
}
|
||||
|
||||
static clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,10 @@ export enum ImageHandleFallbackPolicy {
|
||||
NOTIFY_USER = "NOTIFY_USER",
|
||||
IGNORE_USER = "IGNORE_USER",
|
||||
USE_OLLAMA = "USE_OLLAMA",
|
||||
}
|
||||
}
|
||||
|
||||
export enum ToolRankerFallbackPolicy {
|
||||
MAIN_MODEL = "MAIN_MODEL",
|
||||
ALL_TOOLS = "ALL_TOOLS",
|
||||
NO_TOOLS = "NO_TOOLS",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {createHash} from "node:crypto";
|
||||
import {DatabaseManager} from "../db/database-manager";
|
||||
import type {RequestAuditDbRow} from "../db/db-types";
|
||||
import type {PipelineAuditEvent} from "../ai/user-request-pipeline";
|
||||
|
||||
function hashId(parts: Array<string | number | null | undefined>): string {
|
||||
return createHash("sha256").update(parts.map(part => part === null || part === undefined ? "" : String(part)).join("\u0000")).digest("hex");
|
||||
}
|
||||
|
||||
function toAuditRow(params: {
|
||||
requestId: string;
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
event: PipelineAuditEvent;
|
||||
ordinal: number;
|
||||
}): RequestAuditDbRow {
|
||||
const startedAt = params.event.startedAt ?? null;
|
||||
const finishedAt = params.event.finishedAt ?? null;
|
||||
const durationMs = params.event.durationMs ?? null;
|
||||
const details = params.event.details ? JSON.stringify(params.event.details) : null;
|
||||
|
||||
return {
|
||||
id: hashId([params.requestId, params.messageChatId, params.messageId, params.event.stage, params.event.status, startedAt, finishedAt, params.ordinal]),
|
||||
requestId: params.requestId,
|
||||
messageChatId: params.messageChatId,
|
||||
messageId: params.messageId,
|
||||
stage: params.event.stage,
|
||||
status: params.event.status,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
provider: params.event.provider ?? null,
|
||||
model: params.event.model ?? null,
|
||||
details,
|
||||
error: params.event.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class RequestAuditStore {
|
||||
static async putMessageAudit(params: {
|
||||
requestId: string;
|
||||
messageChatId: number;
|
||||
messageId: number;
|
||||
events: PipelineAuditEvent[];
|
||||
}): Promise<void> {
|
||||
const rows = params.events.map((event, ordinal) => toAuditRow({
|
||||
requestId: params.requestId,
|
||||
messageChatId: params.messageChatId,
|
||||
messageId: params.messageId,
|
||||
event,
|
||||
ordinal,
|
||||
}));
|
||||
|
||||
await DatabaseManager.upsertRequestAudits(rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import path from "node:path";
|
||||
import {Environment} from "./environment";
|
||||
import {StoredAttachment} from "../model/stored-attachment";
|
||||
|
||||
export function photoCachePathForUniqueId(uniqueId: string): string {
|
||||
return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`);
|
||||
}
|
||||
|
||||
export function createStoredImageAttachment(params: {
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
fileName?: string;
|
||||
cachePath?: string;
|
||||
}): StoredAttachment {
|
||||
const fileUniqueId = params.fileUniqueId ?? params.fileId;
|
||||
return {
|
||||
kind: "image",
|
||||
fileId: params.fileId,
|
||||
fileUniqueId: params.fileUniqueId,
|
||||
fileName: params.fileName ?? `${fileUniqueId}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
cachePath: params.cachePath ?? photoCachePathForUniqueId(fileUniqueId),
|
||||
};
|
||||
}
|
||||
|
||||
export function storedAttachmentIdentity(attachment: StoredAttachment): string {
|
||||
return [
|
||||
attachment.kind,
|
||||
attachment.fileUniqueId || attachment.fileId,
|
||||
attachment.cachePath,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function uniqueStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
|
||||
const seen = new Set<string>();
|
||||
const result: StoredAttachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const key = storedAttachmentIdentity(attachment);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(attachment);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
|
||||
return attachments.filter(attachment => attachment.scope !== "internal_artifact");
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import {Environment} from "./environment";
|
||||
import {UserStore} from "./user-store";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {StoredUser} from "../model/stored-user";
|
||||
import {resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
|
||||
import {DEFAULT_LANGUAGE_CHOICE, LanguageChoice, Localization,} from "./localization";
|
||||
|
||||
export const DEFAULT_AI_PROVIDER_CHOICE = "DEFAULT";
|
||||
export const DEFAULT_AI_CONTEXT_SIZE_CHOICE = "DEFAULT";
|
||||
export const AI_CONTEXT_SIZE_MAX_CHOICE = "MAX";
|
||||
export const USER_AI_CONTEXT_SIZE_PRESETS = [1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144] as const;
|
||||
export const MIN_USER_AI_CONTEXT_SIZE = 1024;
|
||||
export const MAX_USER_AI_CONTEXT_SIZE = 1_000_000;
|
||||
export const AI_VOICE_MODE_EXECUTE = "execute";
|
||||
export const AI_VOICE_MODE_TRANSCRIPT = "transcript";
|
||||
export const AI_IMAGE_OUTPUT_MODE_PHOTO = "photo";
|
||||
export const AI_IMAGE_OUTPUT_MODE_DOCUMENT = "document";
|
||||
export type UserAiProviderChoice = AiProvider | typeof DEFAULT_AI_PROVIDER_CHOICE;
|
||||
export type UserAiContextSizeChoice = number | typeof DEFAULT_AI_CONTEXT_SIZE_CHOICE | typeof AI_CONTEXT_SIZE_MAX_CHOICE;
|
||||
export type UserAiVoiceMode = typeof AI_VOICE_MODE_EXECUTE | typeof AI_VOICE_MODE_TRANSCRIPT;
|
||||
export type UserAiImageOutputMode = typeof AI_IMAGE_OUTPUT_MODE_PHOTO | typeof AI_IMAGE_OUTPUT_MODE_DOCUMENT;
|
||||
export type UserInterfaceLanguage = LanguageChoice;
|
||||
export type UserAiResponseLanguage = LanguageChoice;
|
||||
export type UserTier = "creator" | "admin" | "user";
|
||||
|
||||
export type EffectiveUserAiSettings = {
|
||||
userId: number;
|
||||
tier: UserTier;
|
||||
providerChoice: UserAiProviderChoice;
|
||||
effectiveProvider: AiProvider;
|
||||
interfaceLanguage: UserInterfaceLanguage;
|
||||
responseLanguage: UserAiResponseLanguage;
|
||||
contextSizeChoice: UserAiContextSizeChoice;
|
||||
contextSize?: number;
|
||||
voiceMode: UserAiVoiceMode;
|
||||
imageOutputMode: UserAiImageOutputMode;
|
||||
availableProviderChoices: UserAiProviderChoice[];
|
||||
availableContextSizeChoices: UserAiContextSizeChoice[];
|
||||
availableVoiceModes: UserAiVoiceMode[];
|
||||
availableImageOutputModes: UserAiImageOutputMode[];
|
||||
};
|
||||
|
||||
const CREATOR_PROVIDERS: readonly AiProvider[] = [
|
||||
AiProvider.OLLAMA,
|
||||
AiProvider.MISTRAL,
|
||||
AiProvider.OPENAI,
|
||||
];
|
||||
|
||||
const ADMIN_PROVIDERS: readonly AiProvider[] = [
|
||||
AiProvider.MISTRAL,
|
||||
AiProvider.OPENAI,
|
||||
];
|
||||
|
||||
const USER_PROVIDERS: readonly AiProvider[] = [
|
||||
AiProvider.MISTRAL,
|
||||
AiProvider.OLLAMA,
|
||||
];
|
||||
|
||||
export const DEFAULT_INTERFACE_LANGUAGE: UserInterfaceLanguage = DEFAULT_LANGUAGE_CHOICE;
|
||||
export const DEFAULT_AI_RESPONSE_LANGUAGE: UserAiResponseLanguage = DEFAULT_LANGUAGE_CHOICE;
|
||||
export const DEFAULT_AI_VOICE_MODE: UserAiVoiceMode = AI_VOICE_MODE_EXECUTE;
|
||||
export const DEFAULT_AI_IMAGE_OUTPUT_MODE: UserAiImageOutputMode = AI_IMAGE_OUTPUT_MODE_PHOTO;
|
||||
|
||||
export function getUserLanguageChoices(): string[] {
|
||||
return Localization.languageChoices();
|
||||
}
|
||||
|
||||
export function getUserAiContextSizeChoices(): UserAiContextSizeChoice[] {
|
||||
return [DEFAULT_AI_CONTEXT_SIZE_CHOICE, ...USER_AI_CONTEXT_SIZE_PRESETS, AI_CONTEXT_SIZE_MAX_CHOICE];
|
||||
}
|
||||
|
||||
export function getUserAiVoiceModes(): UserAiVoiceMode[] {
|
||||
return [AI_VOICE_MODE_EXECUTE, AI_VOICE_MODE_TRANSCRIPT];
|
||||
}
|
||||
|
||||
export function getUserAiImageOutputModes(): UserAiImageOutputMode[] {
|
||||
return [AI_IMAGE_OUTPUT_MODE_PHOTO, AI_IMAGE_OUTPUT_MODE_DOCUMENT];
|
||||
}
|
||||
|
||||
export function getUserTier(userId: number): UserTier {
|
||||
if (userId === Environment.CREATOR_ID) return "creator";
|
||||
if (Environment.ADMIN_IDS.has(userId)) return "admin";
|
||||
return "user";
|
||||
}
|
||||
|
||||
function allowedProvidersForTier(tier: UserTier): readonly AiProvider[] {
|
||||
switch (tier) {
|
||||
case "creator":
|
||||
return CREATOR_PROVIDERS;
|
||||
case "admin":
|
||||
return ADMIN_PROVIDERS;
|
||||
case "user":
|
||||
return USER_PROVIDERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiProviderConfigured(provider: AiProvider): boolean {
|
||||
const target = resolveAiRuntimeTarget(provider, "chat");
|
||||
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return !!target.baseUrl && !!target.model;
|
||||
case AiProvider.MISTRAL:
|
||||
return !!target.apiKey && !!target.model;
|
||||
case AiProvider.OPENAI:
|
||||
return !!target.apiKey && !!target.model;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailableAiProviderChoices(userId: number): UserAiProviderChoice[] {
|
||||
const tier = getUserTier(userId);
|
||||
const providers = allowedProvidersForTier(tier).filter(isAiProviderConfigured);
|
||||
return [DEFAULT_AI_PROVIDER_CHOICE, ...providers];
|
||||
}
|
||||
|
||||
export function normalizeAiProviderChoice(value: string | undefined | null): UserAiProviderChoice | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === DEFAULT_AI_PROVIDER_CHOICE) return DEFAULT_AI_PROVIDER_CHOICE;
|
||||
|
||||
const providers = Object.values(AiProvider) as string[];
|
||||
return providers.includes(value) ? value as AiProvider : undefined;
|
||||
}
|
||||
|
||||
export function normalizeAiContextSizeChoice(value: string | number | undefined | null): UserAiContextSizeChoice | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
let numericValue: number;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
if (normalized === DEFAULT_AI_CONTEXT_SIZE_CHOICE || lower === "default" || lower === "auto") {
|
||||
return DEFAULT_AI_CONTEXT_SIZE_CHOICE;
|
||||
}
|
||||
|
||||
if (normalized === AI_CONTEXT_SIZE_MAX_CHOICE || lower === "max") {
|
||||
return AI_CONTEXT_SIZE_MAX_CHOICE;
|
||||
}
|
||||
|
||||
numericValue = Number(normalized);
|
||||
} else {
|
||||
numericValue = value;
|
||||
}
|
||||
|
||||
if (numericValue === -1) {
|
||||
return AI_CONTEXT_SIZE_MAX_CHOICE;
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(numericValue) || numericValue < MIN_USER_AI_CONTEXT_SIZE || numericValue > MAX_USER_AI_CONTEXT_SIZE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
export function normalizeAiVoiceMode(value: string | undefined | null): UserAiVoiceMode | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case AI_VOICE_MODE_EXECUTE:
|
||||
case "command":
|
||||
case "commands":
|
||||
case "ai":
|
||||
return AI_VOICE_MODE_EXECUTE;
|
||||
case AI_VOICE_MODE_TRANSCRIPT:
|
||||
case "transcribe":
|
||||
case "transcription":
|
||||
case "text":
|
||||
return AI_VOICE_MODE_TRANSCRIPT;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAiImageOutputMode(value: string | undefined | null): UserAiImageOutputMode | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case AI_IMAGE_OUTPUT_MODE_PHOTO:
|
||||
case "photo":
|
||||
case "photos":
|
||||
case "image":
|
||||
case "images":
|
||||
return AI_IMAGE_OUTPUT_MODE_PHOTO;
|
||||
case AI_IMAGE_OUTPUT_MODE_DOCUMENT:
|
||||
case "doc":
|
||||
case "docs":
|
||||
case "file":
|
||||
case "files":
|
||||
return AI_IMAGE_OUTPUT_MODE_DOCUMENT;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUserLanguageChoice(value: string | undefined | null): UserInterfaceLanguage | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === DEFAULT_LANGUAGE_CHOICE) return DEFAULT_LANGUAGE_CHOICE;
|
||||
|
||||
const normalized = Localization.normalizeLocale(value);
|
||||
return normalized && Localization.isKnownLanguageChoice(normalized)
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeInterfaceLanguage(value: string | undefined | null): UserInterfaceLanguage | undefined {
|
||||
return normalizeUserLanguageChoice(value);
|
||||
}
|
||||
|
||||
export function normalizeAiResponseLanguage(value: string | undefined | null): UserAiResponseLanguage | undefined {
|
||||
return normalizeUserLanguageChoice(value);
|
||||
}
|
||||
|
||||
export function getProviderChoiceLabel(choice: UserAiProviderChoice): string {
|
||||
if (choice === DEFAULT_AI_PROVIDER_CHOICE) {
|
||||
return Localization.text("providerChoice.default", {}, "Default");
|
||||
}
|
||||
|
||||
return choice.charAt(0) + choice.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function getResponseLanguageLabel(language: UserAiResponseLanguage): string {
|
||||
return Localization.languageLabel(language);
|
||||
}
|
||||
|
||||
export function getContextSizeLabel(choice: UserAiContextSizeChoice): string {
|
||||
if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE) {
|
||||
return Environment.userSettingsContextSizeDefaultText;
|
||||
}
|
||||
|
||||
if (choice === AI_CONTEXT_SIZE_MAX_CHOICE) {
|
||||
return Environment.userSettingsContextSizeMaxText;
|
||||
}
|
||||
|
||||
return Environment.getUserSettingsContextSizeText(choice);
|
||||
}
|
||||
|
||||
export function getVoiceModeLabel(mode: UserAiVoiceMode): string {
|
||||
switch (mode) {
|
||||
case AI_VOICE_MODE_EXECUTE:
|
||||
return Environment.userSettingsVoiceModeExecuteText;
|
||||
case AI_VOICE_MODE_TRANSCRIPT:
|
||||
return Environment.userSettingsVoiceModeTranscriptText;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageOutputModeLabel(mode: UserAiImageOutputMode): string {
|
||||
switch (mode) {
|
||||
case AI_IMAGE_OUTPUT_MODE_PHOTO:
|
||||
return Environment.userSettingsImageOutputPhotoText;
|
||||
case AI_IMAGE_OUTPUT_MODE_DOCUMENT:
|
||||
return Environment.userSettingsImageOutputDocumentText;
|
||||
}
|
||||
}
|
||||
|
||||
export function getInterfaceLanguageLabel(language: UserInterfaceLanguage): string {
|
||||
return Localization.languageLabel(language);
|
||||
}
|
||||
|
||||
export function getResponseLanguageInstruction(language: UserAiResponseLanguage): string {
|
||||
const instructions = [
|
||||
"Language:"
|
||||
];
|
||||
|
||||
if (language === DEFAULT_LANGUAGE_CHOICE) {
|
||||
instructions.push("Always answer in the language of the user’s latest message unless explicitly asked otherwise.");
|
||||
} else {
|
||||
instructions.push(`Always answer to the user in ${Localization.languageInstructionName(language)}. If the user specifically requests another language, comply with that request.`);
|
||||
}
|
||||
|
||||
return instructions.join("\n");
|
||||
}
|
||||
|
||||
function shouldUpdateInterfaceLanguage(user: StoredUser | null, language: UserInterfaceLanguage): boolean {
|
||||
return !!user?.interfaceLanguage && user.interfaceLanguage !== language;
|
||||
}
|
||||
|
||||
function shouldUpdateProvider(user: StoredUser | null, choice: UserAiProviderChoice): boolean {
|
||||
return !!user?.aiProvider && user.aiProvider !== choice;
|
||||
}
|
||||
|
||||
function shouldUpdateLanguage(user: StoredUser | null, language: UserAiResponseLanguage): boolean {
|
||||
return !!user?.aiResponseLanguage && user.aiResponseLanguage !== language;
|
||||
}
|
||||
|
||||
function shouldUpdateContextSize(user: StoredUser | null, choice: UserAiContextSizeChoice): boolean {
|
||||
if (!user) return false;
|
||||
if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE && user.aiContextSize === undefined) return false;
|
||||
return normalizeAiContextSizeChoice(user.aiContextSize) !== choice;
|
||||
}
|
||||
|
||||
function shouldUpdateVoiceMode(user: StoredUser | null, mode: UserAiVoiceMode): boolean {
|
||||
return !!user?.aiVoiceMode && user.aiVoiceMode !== mode;
|
||||
}
|
||||
|
||||
function shouldUpdateImageOutputMode(user: StoredUser | null, mode: UserAiImageOutputMode): boolean {
|
||||
return !!user?.aiImageOutputMode && user.aiImageOutputMode !== mode;
|
||||
}
|
||||
|
||||
function contextSizeChoiceToStored(choice: UserAiContextSizeChoice): number | undefined {
|
||||
return choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE ? undefined : choice === AI_CONTEXT_SIZE_MAX_CHOICE ? -1 : choice;
|
||||
}
|
||||
|
||||
export async function ensureValidUserAiSettings(userId: number): Promise<EffectiveUserAiSettings> {
|
||||
const user = await UserStore.get(userId);
|
||||
const availableProviderChoices = getAvailableAiProviderChoices(userId);
|
||||
let availableContextSizeChoices = getUserAiContextSizeChoices();
|
||||
const availableVoiceModes = getUserAiVoiceModes();
|
||||
let providerChoice = normalizeAiProviderChoice(user?.aiProvider) ?? DEFAULT_AI_PROVIDER_CHOICE;
|
||||
let interfaceLanguage = normalizeInterfaceLanguage(user?.interfaceLanguage) ?? DEFAULT_INTERFACE_LANGUAGE;
|
||||
let responseLanguage = normalizeAiResponseLanguage(user?.aiResponseLanguage) ?? DEFAULT_AI_RESPONSE_LANGUAGE;
|
||||
let contextSizeChoice = normalizeAiContextSizeChoice(user?.aiContextSize) ?? DEFAULT_AI_CONTEXT_SIZE_CHOICE;
|
||||
let voiceMode = normalizeAiVoiceMode(user?.aiVoiceMode) ?? DEFAULT_AI_VOICE_MODE;
|
||||
let imageOutputMode = normalizeAiImageOutputMode(user?.aiImageOutputMode) ?? DEFAULT_AI_IMAGE_OUTPUT_MODE;
|
||||
|
||||
if (!availableProviderChoices.includes(providerChoice)) {
|
||||
providerChoice = availableProviderChoices[0] ?? DEFAULT_AI_PROVIDER_CHOICE;
|
||||
}
|
||||
|
||||
if (!Localization.isKnownLanguageChoice(interfaceLanguage)) {
|
||||
interfaceLanguage = DEFAULT_INTERFACE_LANGUAGE;
|
||||
}
|
||||
|
||||
if (!Localization.isKnownLanguageChoice(responseLanguage)) {
|
||||
responseLanguage = DEFAULT_AI_RESPONSE_LANGUAGE;
|
||||
}
|
||||
|
||||
if (contextSizeChoice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE && !normalizeAiContextSizeChoice(contextSizeChoice)) {
|
||||
contextSizeChoice = DEFAULT_AI_CONTEXT_SIZE_CHOICE;
|
||||
}
|
||||
|
||||
if (!availableContextSizeChoices.includes(contextSizeChoice)) {
|
||||
availableContextSizeChoices = [
|
||||
DEFAULT_AI_CONTEXT_SIZE_CHOICE,
|
||||
...[
|
||||
...availableContextSizeChoices.filter(choice => choice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE),
|
||||
contextSizeChoice,
|
||||
].sort((a, b) => Number(a) - Number(b)),
|
||||
];
|
||||
}
|
||||
|
||||
if (!availableVoiceModes.includes(voiceMode)) {
|
||||
voiceMode = DEFAULT_AI_VOICE_MODE;
|
||||
}
|
||||
|
||||
if (!getUserAiImageOutputModes().includes(imageOutputMode)) {
|
||||
imageOutputMode = DEFAULT_AI_IMAGE_OUTPUT_MODE;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldUpdateProvider(user, providerChoice)
|
||||
|| shouldUpdateInterfaceLanguage(user, interfaceLanguage)
|
||||
|| shouldUpdateLanguage(user, responseLanguage)
|
||||
|| shouldUpdateContextSize(user, contextSizeChoice)
|
||||
|| shouldUpdateVoiceMode(user, voiceMode)
|
||||
|| shouldUpdateImageOutputMode(user, imageOutputMode)
|
||||
) {
|
||||
await UserStore.updateSettings(userId, {
|
||||
interfaceLanguage,
|
||||
aiProvider: providerChoice,
|
||||
aiResponseLanguage: responseLanguage,
|
||||
aiContextSize: contextSizeChoiceToStored(contextSizeChoice),
|
||||
aiVoiceMode: voiceMode,
|
||||
aiImageOutputMode: imageOutputMode,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tier: getUserTier(userId),
|
||||
providerChoice,
|
||||
effectiveProvider: providerChoice === DEFAULT_AI_PROVIDER_CHOICE ? Environment.DEFAULT_AI_PROVIDER : providerChoice,
|
||||
interfaceLanguage,
|
||||
responseLanguage,
|
||||
contextSizeChoice,
|
||||
contextSize: contextSizeChoiceToStored(contextSizeChoice),
|
||||
voiceMode,
|
||||
imageOutputMode,
|
||||
availableProviderChoices,
|
||||
availableContextSizeChoices,
|
||||
availableVoiceModes,
|
||||
availableImageOutputModes: getUserAiImageOutputModes(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveEffectiveAiProviderForUser(userId: number | undefined): Promise<AiProvider> {
|
||||
if (!userId) return Environment.DEFAULT_AI_PROVIDER;
|
||||
return (await ensureValidUserAiSettings(userId)).effectiveProvider;
|
||||
}
|
||||
|
||||
export async function resolveAiResponseLanguageForUser(userId: number | undefined): Promise<UserAiResponseLanguage> {
|
||||
if (!userId) return DEFAULT_AI_RESPONSE_LANGUAGE;
|
||||
return (await ensureValidUserAiSettings(userId)).responseLanguage;
|
||||
}
|
||||
|
||||
export async function resolveAiContextSizeForUser(userId: number | undefined): Promise<number | undefined> {
|
||||
if (!userId) return undefined;
|
||||
return (await ensureValidUserAiSettings(userId)).contextSize;
|
||||
}
|
||||
|
||||
export async function resolveAiVoiceModeForUser(userId: number | undefined): Promise<UserAiVoiceMode> {
|
||||
if (!userId) return DEFAULT_AI_VOICE_MODE;
|
||||
return (await ensureValidUserAiSettings(userId)).voiceMode;
|
||||
}
|
||||
|
||||
export async function resolveAiImageOutputModeForUser(userId: number | undefined): Promise<UserAiImageOutputMode> {
|
||||
if (!userId) return DEFAULT_AI_IMAGE_OUTPUT_MODE;
|
||||
return (await ensureValidUserAiSettings(userId)).imageOutputMode;
|
||||
}
|
||||
|
||||
export async function resolveInterfaceLocaleForUser(
|
||||
userId: number | undefined,
|
||||
telegramLanguageCode?: string,
|
||||
): Promise<string> {
|
||||
if (!userId) {
|
||||
return Localization.resolveLocale(DEFAULT_INTERFACE_LANGUAGE, telegramLanguageCode);
|
||||
}
|
||||
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
const user = await UserStore.get(userId);
|
||||
return Localization.resolveLocale(settings.interfaceLanguage, telegramLanguageCode ?? user?.langCode);
|
||||
}
|
||||
|
||||
export async function setUserAiProviderChoice(
|
||||
userId: number,
|
||||
choice: UserAiProviderChoice,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
|
||||
if (!settings.availableProviderChoices.includes(choice)) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
interfaceLanguage: settings.interfaceLanguage,
|
||||
aiProvider: choice,
|
||||
aiResponseLanguage: settings.responseLanguage,
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
|
||||
export async function setUserAiContextSizeChoice(
|
||||
userId: number,
|
||||
choice: UserAiContextSizeChoice,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
const normalized = normalizeAiContextSizeChoice(choice);
|
||||
|
||||
if (!normalized && normalized !== -1) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
aiContextSize: contextSizeChoiceToStored(normalized),
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
|
||||
export async function setUserAiVoiceMode(
|
||||
userId: number,
|
||||
mode: UserAiVoiceMode,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
|
||||
if (!getUserAiVoiceModes().includes(mode)) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
aiVoiceMode: mode,
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
|
||||
export async function setUserAiImageOutputMode(
|
||||
userId: number,
|
||||
mode: UserAiImageOutputMode,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
|
||||
if (!getUserAiImageOutputModes().includes(mode)) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
aiImageOutputMode: mode,
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
|
||||
export async function setUserAiResponseLanguage(
|
||||
userId: number,
|
||||
language: UserAiResponseLanguage,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
|
||||
if (!Localization.isKnownLanguageChoice(language)) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
interfaceLanguage: settings.interfaceLanguage,
|
||||
aiProvider: settings.providerChoice,
|
||||
aiResponseLanguage: language,
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
|
||||
export async function setUserInterfaceLanguage(
|
||||
userId: number,
|
||||
language: UserInterfaceLanguage,
|
||||
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
|
||||
const settings = await ensureValidUserAiSettings(userId);
|
||||
|
||||
if (!Localization.isKnownLanguageChoice(language)) {
|
||||
return {ok: false, settings};
|
||||
}
|
||||
|
||||
await UserStore.updateSettings(userId, {
|
||||
interfaceLanguage: language,
|
||||
aiProvider: settings.providerChoice,
|
||||
aiResponseLanguage: settings.responseLanguage,
|
||||
});
|
||||
|
||||
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import {InlineKeyboardMarkup} from "typescript-telegram-bot-api";
|
||||
import {Environment} from "./environment";
|
||||
import {
|
||||
DEFAULT_AI_PROVIDER_CHOICE,
|
||||
EffectiveUserAiSettings,
|
||||
getContextSizeLabel,
|
||||
getInterfaceLanguageLabel,
|
||||
getProviderChoiceLabel,
|
||||
getResponseLanguageLabel,
|
||||
getImageOutputModeLabel,
|
||||
getVoiceModeLabel,
|
||||
getUserLanguageChoices,
|
||||
UserAiContextSizeChoice,
|
||||
UserAiImageOutputMode,
|
||||
UserAiProviderChoice,
|
||||
UserAiResponseLanguage,
|
||||
UserAiVoiceMode,
|
||||
UserInterfaceLanguage,
|
||||
} from "./user-ai-settings";
|
||||
|
||||
export const USER_SETTINGS_CALLBACK_PREFIX = "/settings";
|
||||
|
||||
export type UserSettingsScreen = "main" | "provider" | "interfaceLanguage" | "responseLanguage" | "contextSize" | "voiceMode" | "imageOutput";
|
||||
|
||||
function tierLabel(tier: EffectiveUserAiSettings["tier"]): string {
|
||||
const labels: Record<EffectiveUserAiSettings["tier"], string> = {
|
||||
creator: Environment.userSettingsCreatorTierText,
|
||||
admin: Environment.userSettingsAdminTierText,
|
||||
user: Environment.userSettingsUserTierText,
|
||||
};
|
||||
|
||||
return labels[tier];
|
||||
}
|
||||
|
||||
function callbackData(settings: EffectiveUserAiSettings, screen: UserSettingsScreen, value?: string): string {
|
||||
return [USER_SETTINGS_CALLBACK_PREFIX, String(settings.userId), screen, value].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function selectedText(selected: boolean, text: string): string {
|
||||
return selected ? Environment.getUserSettingsSelectedText(text) : text;
|
||||
}
|
||||
|
||||
function currentProviderText(settings: EffectiveUserAiSettings): string {
|
||||
if (settings.providerChoice !== DEFAULT_AI_PROVIDER_CHOICE) {
|
||||
return getProviderChoiceLabel(settings.providerChoice);
|
||||
}
|
||||
|
||||
return `${getProviderChoiceLabel(DEFAULT_AI_PROVIDER_CHOICE)} (${getProviderChoiceLabel(Environment.DEFAULT_AI_PROVIDER)})`;
|
||||
}
|
||||
|
||||
export function formatUserSettingsText(settings: EffectiveUserAiSettings, screen: UserSettingsScreen = "main"): string {
|
||||
const title = Environment.getUserSettingsTitle(screen);
|
||||
|
||||
return [
|
||||
title,
|
||||
"",
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsTierLabel, tierLabel(settings.tier)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsAiProviderLabel, currentProviderText(settings)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsInterfaceLanguageLabel, getInterfaceLanguageLabel(settings.interfaceLanguage)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsResponseLanguageLabel, getResponseLanguageLabel(settings.responseLanguage)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsContextSizeLabel, getContextSizeLabel(settings.contextSizeChoice)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsVoiceModeLabel, getVoiceModeLabel(settings.voiceMode)),
|
||||
Environment.getUserSettingsFieldText(Environment.userSettingsImageOutputLabel, getImageOutputModeLabel(settings.imageOutputMode)),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildUserSettingsKeyboard(settings: EffectiveUserAiSettings, screen: UserSettingsScreen = "main"): InlineKeyboardMarkup {
|
||||
if (screen === "provider") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...settings.availableProviderChoices.map(choice => {
|
||||
const text = choice === DEFAULT_AI_PROVIDER_CHOICE
|
||||
? currentProviderText({...settings, providerChoice: DEFAULT_AI_PROVIDER_CHOICE})
|
||||
: getProviderChoiceLabel(choice);
|
||||
|
||||
return [{
|
||||
text: selectedText(settings.providerChoice === choice, text),
|
||||
callback_data: callbackData(settings, "provider", choice),
|
||||
}];
|
||||
}),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (screen === "interfaceLanguage") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...getUserLanguageChoices().map(language => [{
|
||||
text: selectedText(settings.interfaceLanguage === language, getInterfaceLanguageLabel(language)),
|
||||
callback_data: callbackData(settings, "interfaceLanguage", language),
|
||||
}]),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (screen === "responseLanguage") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...getUserLanguageChoices().map(language => [{
|
||||
text: selectedText(settings.responseLanguage === language, getResponseLanguageLabel(language)),
|
||||
callback_data: callbackData(settings, "responseLanguage", language),
|
||||
}]),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (screen === "contextSize") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...settings.availableContextSizeChoices.map(choice => [{
|
||||
text: selectedText(settings.contextSizeChoice === choice, getContextSizeLabel(choice)),
|
||||
callback_data: callbackData(settings, "contextSize", String(choice)),
|
||||
}]),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (screen === "voiceMode") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...settings.availableVoiceModes.map(mode => [{
|
||||
text: selectedText(settings.voiceMode === mode, getVoiceModeLabel(mode)),
|
||||
callback_data: callbackData(settings, "voiceMode", mode),
|
||||
}]),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (screen === "imageOutput") {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
...settings.availableImageOutputModes.map(mode => [{
|
||||
text: selectedText(settings.imageOutputMode === mode, getImageOutputModeLabel(mode)),
|
||||
callback_data: callbackData(settings, "imageOutput", mode),
|
||||
}]),
|
||||
[{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsAiProviderButtonPrefix, currentProviderText(settings)),
|
||||
callback_data: callbackData(settings, "provider")
|
||||
}],
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsInterfaceLanguageButtonPrefix, getInterfaceLanguageLabel(settings.interfaceLanguage)),
|
||||
callback_data: callbackData(settings, "interfaceLanguage")
|
||||
}],
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsResponseLanguageButtonPrefix, getResponseLanguageLabel(settings.responseLanguage)),
|
||||
callback_data: callbackData(settings, "responseLanguage")
|
||||
}],
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsContextSizeButtonPrefix, getContextSizeLabel(settings.contextSizeChoice)),
|
||||
callback_data: callbackData(settings, "contextSize")
|
||||
}],
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsVoiceModeButtonPrefix, getVoiceModeLabel(settings.voiceMode)),
|
||||
callback_data: callbackData(settings, "voiceMode")
|
||||
}],
|
||||
[{
|
||||
text: Environment.getUserSettingsFieldText(Environment.userSettingsImageOutputButtonPrefix, getImageOutputModeLabel(settings.imageOutputMode)),
|
||||
callback_data: callbackData(settings, "imageOutput")
|
||||
}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserSettingsCallbackData(data: string | undefined): {
|
||||
userId: number;
|
||||
screen: UserSettingsScreen;
|
||||
providerChoice?: UserAiProviderChoice;
|
||||
interfaceLanguage?: UserInterfaceLanguage;
|
||||
responseLanguage?: UserAiResponseLanguage;
|
||||
contextSizeChoice?: UserAiContextSizeChoice | string;
|
||||
voiceMode?: UserAiVoiceMode;
|
||||
imageOutputMode?: UserAiImageOutputMode;
|
||||
} | null {
|
||||
if (!data?.startsWith(USER_SETTINGS_CALLBACK_PREFIX)) return null;
|
||||
|
||||
const [, userIdValue, screenValue, value] = data.split(" ");
|
||||
const userId = Number(userIdValue);
|
||||
const screen = (screenValue === "language" ? "responseLanguage" : screenValue || "main") as UserSettingsScreen;
|
||||
|
||||
if (!Number.isSafeInteger(userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
screen !== "main"
|
||||
&& screen !== "provider"
|
||||
&& screen !== "interfaceLanguage"
|
||||
&& screen !== "responseLanguage"
|
||||
&& screen !== "contextSize"
|
||||
&& screen !== "voiceMode"
|
||||
&& screen !== "imageOutput"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
screen,
|
||||
providerChoice: screen === "provider" ? value as UserAiProviderChoice | undefined : undefined,
|
||||
interfaceLanguage: screen === "interfaceLanguage" ? value as UserInterfaceLanguage | undefined : undefined,
|
||||
responseLanguage: screen === "responseLanguage" ? value as UserAiResponseLanguage | undefined : undefined,
|
||||
contextSizeChoice: screen === "contextSize" ? value as UserAiContextSizeChoice | string | undefined : undefined,
|
||||
voiceMode: screen === "voiceMode" ? value as UserAiVoiceMode | undefined : undefined,
|
||||
imageOutputMode: screen === "imageOutput" ? value as UserAiImageOutputMode | undefined : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import {User} from "typescript-telegram-bot-api";
|
||||
import {userDao} from "../index";
|
||||
import {StoredUser} from "../model/stored-user";
|
||||
import {getLruMapValue, setLruMapValue} from "../util/lru-map";
|
||||
|
||||
const USER_CACHE_MAX_ENTRIES = 5_000;
|
||||
|
||||
export class UserStore {
|
||||
private static map = new Map<number, StoredUser>();
|
||||
@@ -10,6 +13,7 @@ export class UserStore {
|
||||
}
|
||||
|
||||
static async put(u: User): Promise<StoredUser> {
|
||||
const current = getLruMapValue(this.map, u.id);
|
||||
const user: StoredUser = {
|
||||
id: u.id,
|
||||
isBot: u.is_bot,
|
||||
@@ -17,23 +21,40 @@ export class UserStore {
|
||||
lastName: u.last_name,
|
||||
userName: u.username,
|
||||
isPremium: u.is_premium,
|
||||
langCode: u.language_code,
|
||||
interfaceLanguage: current?.interfaceLanguage,
|
||||
aiProvider: current?.aiProvider,
|
||||
aiResponseLanguage: current?.aiResponseLanguage,
|
||||
aiContextSize: current?.aiContextSize,
|
||||
aiVoiceMode: current?.aiVoiceMode,
|
||||
aiImageOutputMode: current?.aiImageOutputMode,
|
||||
};
|
||||
|
||||
this.map.set(u.id, user);
|
||||
setLruMapValue(this.map, u.id, user, USER_CACHE_MAX_ENTRIES);
|
||||
|
||||
await userDao.insert(userDao.mapTo([u]));
|
||||
return user;
|
||||
}
|
||||
|
||||
static async updateSettings(
|
||||
id: number,
|
||||
settings: Partial<Pick<StoredUser, "interfaceLanguage" | "aiProvider" | "aiResponseLanguage" | "aiContextSize" | "aiVoiceMode" | "aiImageOutputMode">>
|
||||
): Promise<StoredUser | null> {
|
||||
await userDao.updateSettings(id, settings);
|
||||
const user = await userDao.getById({id});
|
||||
if (user) setLruMapValue(this.map, id, user, USER_CACHE_MAX_ENTRIES);
|
||||
return user;
|
||||
}
|
||||
|
||||
static async get(id: number): Promise<StoredUser | null> {
|
||||
const user = await userDao.getById({id: id});
|
||||
if (!user) return null;
|
||||
|
||||
this.map.set(id, user);
|
||||
setLruMapValue(this.map, id, user, USER_CACHE_MAX_ENTRIES);
|
||||
return user;
|
||||
}
|
||||
|
||||
static clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user