diff --git a/src/callback_commands/user-settings.ts b/src/callback_commands/user-settings.ts new file mode 100644 index 0000000..dc19fe0 --- /dev/null +++ b/src/callback_commands/user-settings.ts @@ -0,0 +1,99 @@ +import {CallbackQuery} from "typescript-telegram-bot-api"; +import {CallbackCommand} from "../base/callback-command"; +import {UserStore} from "../common/user-store"; +import { + ensureValidUserAiSettings, + normalizeAiContextSizeChoice, + normalizeAiProviderChoice, + normalizeAiResponseLanguage, + normalizeAiVoiceMode, + normalizeInterfaceLanguage, + resolveInterfaceLocaleForUser, + setUserAiContextSizeChoice, + setUserAiProviderChoice, + setUserAiResponseLanguage, + setUserAiVoiceMode, + setUserInterfaceLanguage, +} from "../common/user-ai-settings"; +import { + buildUserSettingsKeyboard, + formatUserSettingsText, + parseUserSettingsCallbackData, + USER_SETTINGS_CALLBACK_PREFIX, + UserSettingsScreen, +} from "../common/user-settings-view"; +import {editMessageText, ignoreIfNotChanged, logError} from "../util/utils"; +import {Environment} from "../common/environment"; +import {Localization} from "../common/localization"; + +export class UserSettingsCallback extends CallbackCommand { + data = USER_SETTINGS_CALLBACK_PREFIX; + text = Environment.userSettingsCallbackText; + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const message = query.message; + const parsed = parseUserSettingsCallbackData(query.data); + if (!parsed || parsed.userId !== query.from.id) return; + + await UserStore.put(query.from); + + let screen: UserSettingsScreen = parsed.screen; + let settings = await ensureValidUserAiSettings(query.from.id); + + if (parsed.screen === "provider" && parsed.providerChoice) { + const choice = normalizeAiProviderChoice(parsed.providerChoice); + if (choice) { + const result = await setUserAiProviderChoice(query.from.id, choice); + settings = result.settings; + } + screen = "provider"; + } + + if (parsed.screen === "interfaceLanguage" && parsed.interfaceLanguage) { + const language = normalizeInterfaceLanguage(parsed.interfaceLanguage); + if (language) { + const result = await setUserInterfaceLanguage(query.from.id, language); + settings = result.settings; + } + screen = "interfaceLanguage"; + } + + if (parsed.screen === "responseLanguage" && parsed.responseLanguage) { + const language = normalizeAiResponseLanguage(parsed.responseLanguage); + if (language) { + const result = await setUserAiResponseLanguage(query.from.id, language); + settings = result.settings; + } + screen = "responseLanguage"; + } + + if (parsed.screen === "contextSize" && parsed.contextSizeChoice) { + const choice = normalizeAiContextSizeChoice(parsed.contextSizeChoice); + if (choice) { + const result = await setUserAiContextSizeChoice(query.from.id, choice); + settings = result.settings; + } + screen = "contextSize"; + } + + if (parsed.screen === "voiceMode" && parsed.voiceMode) { + const mode = normalizeAiVoiceMode(parsed.voiceMode); + if (mode) { + const result = await setUserAiVoiceMode(query.from.id, mode); + settings = result.settings; + } + screen = "voiceMode"; + } + + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); + + await Localization.runWithLocale(locale, () => editMessageText({ + chat_id: message.chat.id, + message_id: message.message_id, + text: formatUserSettingsText(settings, screen), + reply_markup: buildUserSettingsKeyboard(settings, screen), + })).catch(ignoreIfNotChanged).catch(logError); + } +} diff --git a/src/commands/settings.ts b/src/commands/settings.ts new file mode 100644 index 0000000..a1e2723 --- /dev/null +++ b/src/commands/settings.ts @@ -0,0 +1,57 @@ +import {Message} from "typescript-telegram-bot-api"; +import {Command} from "../base/command"; +import {UserStore} from "../common/user-store"; +import { + ensureValidUserAiSettings, + normalizeAiContextSizeChoice, + normalizeAiVoiceMode, + setUserAiContextSizeChoice, + setUserAiVoiceMode, +} from "../common/user-ai-settings"; +import {buildUserSettingsKeyboard, formatUserSettingsText} from "../common/user-settings-view"; +import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; + +export class Settings extends Command { + command = ["settings", "config"]; + argsMode = "optional" as const; + + title = Environment.commandTitles.settings; + description = Environment.commandDescriptions.settings; + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + if (!msg.from) return; + + await UserStore.put(msg.from); + const args = match?.[3]?.trim(); + let settings = await ensureValidUserAiSettings(msg.from.id); + let screen: Parameters[1] = "main"; + + if (args) { + const [name, ...rest] = args.split(/\s+/); + const value = rest.join(" "); + + if (name?.toLowerCase() === "context" || name?.toLowerCase() === "ctx") { + const choice = normalizeAiContextSizeChoice(value); + if (choice) { + settings = (await setUserAiContextSizeChoice(msg.from.id, choice)).settings; + screen = "contextSize"; + } + } + + if (name?.toLowerCase() === "voice" || name?.toLowerCase() === "audio") { + const mode = normalizeAiVoiceMode(value); + if (mode) { + settings = (await setUserAiVoiceMode(msg.from.id, mode)).settings; + screen = "voiceMode"; + } + } + } + + await replyToMessage({ + message: msg, + text: formatUserSettingsText(settings, screen), + reply_markup: buildUserSettingsKeyboard(settings, screen), + }).catch(logError); + } +} diff --git a/src/common/message-store.ts b/src/common/message-store.ts index c15d409..3340cf4 100644 --- a/src/common/message-store.ts +++ b/src/common/message-store.ts @@ -2,9 +2,14 @@ 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"; + +const MESSAGE_CACHE_MAX_ENTRIES = 10_000; export class MessageStore { private static map = new Map(); + private static locks = new KeyedAsyncLock(); private static key(chatId: number, messageId: number) { return `${chatId}:${messageId}`; @@ -23,13 +28,32 @@ export class MessageStore { replyToMessageId: m.reply_to_message?.message_id, fromId: m.from?.id, text: extractTextMessage(m), + quoteText: m.quote?.text, date: m.date ?? 0, photoMaxSizeFilePath: maxSizePath ? [maxSizePath] : 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, + photoMaxSizeFilePath: msg.photoMaxSizeFilePath !== undefined + ? msg.photoMaxSizeFilePath + : existing?.photoMaxSizeFilePath, + attachments: msg.attachments !== undefined ? msg.attachments : existing?.attachments, + }; + + setLruMapValue(this.map, key, merged, MESSAGE_CACHE_MAX_ENTRIES); + await messageDao.insert(messageDao.mapStoredTo([merged])); + return merged; + }); } static async get(chatId: number, messageId: number | undefined): Promise { @@ -38,11 +62,11 @@ export class MessageStore { 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(); } -} \ No newline at end of file +} diff --git a/src/common/user-ai-settings.ts b/src/common/user-ai-settings.ts new file mode 100644 index 0000000..bf9af9a --- /dev/null +++ b/src/common/user-ai-settings.ts @@ -0,0 +1,450 @@ +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 USER_AI_CONTEXT_SIZE_PRESETS = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 256000] as const; +export const MIN_USER_AI_CONTEXT_SIZE = 64; +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 type UserAiProviderChoice = AiProvider | typeof DEFAULT_AI_PROVIDER_CHOICE; +export type UserAiContextSizeChoice = number | typeof DEFAULT_AI_CONTEXT_SIZE_CHOICE; +export type UserAiVoiceMode = typeof AI_VOICE_MODE_EXECUTE | typeof AI_VOICE_MODE_TRANSCRIPT; +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; + availableProviderChoices: UserAiProviderChoice[]; + availableContextSizeChoices: UserAiContextSizeChoice[]; + availableVoiceModes: UserAiVoiceMode[]; +}; + +const CREATOR_PROVIDERS: readonly AiProvider[] = [ + AiProvider.OLLAMA, + AiProvider.GEMINI, + AiProvider.MISTRAL, + AiProvider.OPENAI, +]; + +const ADMIN_PROVIDERS: readonly AiProvider[] = [ + AiProvider.MISTRAL, + AiProvider.OPENAI, +]; + +const USER_PROVIDERS: readonly AiProvider[] = [ + AiProvider.MISTRAL, + AiProvider.GEMINI, + 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 function getUserLanguageChoices(): string[] { + return Localization.languageChoices(); +} + +export function getUserAiContextSizeChoices(): UserAiContextSizeChoice[] { + return [DEFAULT_AI_CONTEXT_SIZE_CHOICE, ...USER_AI_CONTEXT_SIZE_PRESETS]; +} + +export function getUserAiVoiceModes(): UserAiVoiceMode[] { + return [AI_VOICE_MODE_EXECUTE, AI_VOICE_MODE_TRANSCRIPT]; +} + +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.GEMINI: + return !!target.apiKey && !!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; + } + + numericValue = Number(normalized); + } else { + numericValue = value; + } + + 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 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; + } + + 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 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 contextSizeChoiceToStored(choice: UserAiContextSizeChoice): number | undefined { + return choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE ? undefined : choice; +} + +export async function ensureValidUserAiSettings(userId: number): Promise { + 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; + + 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 ( + shouldUpdateProvider(user, providerChoice) + || shouldUpdateInterfaceLanguage(user, interfaceLanguage) + || shouldUpdateLanguage(user, responseLanguage) + || shouldUpdateContextSize(user, contextSizeChoice) + || shouldUpdateVoiceMode(user, voiceMode) + ) { + await UserStore.updateSettings(userId, { + interfaceLanguage, + aiProvider: providerChoice, + aiResponseLanguage: responseLanguage, + aiContextSize: contextSizeChoiceToStored(contextSizeChoice), + aiVoiceMode: voiceMode, + }); + } + + return { + userId, + tier: getUserTier(userId), + providerChoice, + effectiveProvider: providerChoice === DEFAULT_AI_PROVIDER_CHOICE ? Environment.DEFAULT_AI_PROVIDER : providerChoice, + interfaceLanguage, + responseLanguage, + contextSizeChoice, + contextSize: contextSizeChoiceToStored(contextSizeChoice), + voiceMode, + availableProviderChoices, + availableContextSizeChoices, + availableVoiceModes, + }; +} + +export async function resolveEffectiveAiProviderForUser(userId: number | undefined): Promise { + if (!userId) return Environment.DEFAULT_AI_PROVIDER; + return (await ensureValidUserAiSettings(userId)).effectiveProvider; +} + +export async function resolveAiResponseLanguageForUser(userId: number | undefined): Promise { + if (!userId) return DEFAULT_AI_RESPONSE_LANGUAGE; + return (await ensureValidUserAiSettings(userId)).responseLanguage; +} + +export async function resolveAiContextSizeForUser(userId: number | undefined): Promise { + if (!userId) return undefined; + return (await ensureValidUserAiSettings(userId)).contextSize; +} + +export async function resolveAiVoiceModeForUser(userId: number | undefined): Promise { + if (!userId) return DEFAULT_AI_VOICE_MODE; + return (await ensureValidUserAiSettings(userId)).voiceMode; +} + +export async function resolveInterfaceLocaleForUser( + userId: number | undefined, + telegramLanguageCode?: string, +): Promise { + 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) { + 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 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)}; +} diff --git a/src/common/user-settings-view.ts b/src/common/user-settings-view.ts new file mode 100644 index 0000000..4486f24 --- /dev/null +++ b/src/common/user-settings-view.ts @@ -0,0 +1,196 @@ +import {InlineKeyboardMarkup} from "typescript-telegram-bot-api"; +import {Environment} from "./environment"; +import { + DEFAULT_AI_PROVIDER_CHOICE, + EffectiveUserAiSettings, + getContextSizeLabel, + getInterfaceLanguageLabel, + getProviderChoiceLabel, + getResponseLanguageLabel, + getVoiceModeLabel, + getUserLanguageChoices, + UserAiContextSizeChoice, + UserAiProviderChoice, + UserAiResponseLanguage, + UserAiVoiceMode, + UserInterfaceLanguage, +} from "./user-ai-settings"; + +export const USER_SETTINGS_CALLBACK_PREFIX = "/settings"; + +export type UserSettingsScreen = "main" | "provider" | "interfaceLanguage" | "responseLanguage" | "contextSize" | "voiceMode"; + +function tierLabel(tier: EffectiveUserAiSettings["tier"]): string { + const labels: Record = { + 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)), + ].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")}], + ], + }; + } + + 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") + }], + ], + }; +} + +export function parseUserSettingsCallbackData(data: string | undefined): { + userId: number; + screen: UserSettingsScreen; + providerChoice?: UserAiProviderChoice; + interfaceLanguage?: UserInterfaceLanguage; + responseLanguage?: UserAiResponseLanguage; + contextSizeChoice?: UserAiContextSizeChoice | string; + voiceMode?: UserAiVoiceMode; +} | 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" + ) { + 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, + }; +} diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1689252..6e2c15f 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -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(); @@ -10,6 +13,7 @@ export class UserStore { } static async put(u: User): Promise { + const current = getLruMapValue(this.map, u.id); const user: StoredUser = { id: u.id, isBot: u.is_bot, @@ -17,23 +21,39 @@ 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, }; - 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> + ): Promise { + 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 { 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(); } -} \ No newline at end of file +} diff --git a/src/db/database-manager.ts b/src/db/database-manager.ts index ed6209a..5525734 100644 --- a/src/db/database-manager.ts +++ b/src/db/database-manager.ts @@ -2,16 +2,141 @@ import "dotenv/config"; import {drizzle, LibSQLDatabase} from "drizzle-orm/libsql"; import {Environment} from "../common/environment"; import {logError} from "../util/utils"; +import {sql} from "drizzle-orm"; + +type TableInfoRow = { + name: string; + pk: number; +}; export class DatabaseManager { static db: LibSQLDatabase; + static ready: Promise = Promise.resolve(); static init() { - try { - DatabaseManager.db = drizzle(Environment.DB_PATH); - } catch (e) { + DatabaseManager.db = drizzle(Environment.DB_PATH); + DatabaseManager.ready = DatabaseManager.ensureSchema().catch(e => { logError(e); + throw e; + }); + } + + private static async getTableInfo(tableName: string): Promise { + return DatabaseManager.db.all(sql.raw(`PRAGMA table_info(${tableName})`)).catch((e: Error) => { + const message = String(e?.message ?? e); + if (!message.includes("no such table")) logError(e); + return []; + }); + } + + private static async ensureSchema(): Promise { + await DatabaseManager.ensureUsersTable(); + await DatabaseManager.ensureMessagesTable(); + } + + private static async ensureUsersTable(): Promise { + await DatabaseManager.db.run(sql` + CREATE TABLE IF NOT EXISTS users + ( + id INTEGER PRIMARY KEY NOT NULL, + isBot INTEGER NOT NULL, + firstName TEXT NOT NULL, + lastName TEXT, + userName TEXT, + isPremium INTEGER, + langCode TEXT, + interfaceLanguage TEXT DEFAULT 'default', + aiProvider TEXT, + aiResponseLanguage TEXT DEFAULT 'ru', + aiContextSize INTEGER, + aiVoiceMode TEXT DEFAULT 'execute' + ) + `); + + const columns = await DatabaseManager.getTableInfo("users"); + const columnNames = new Set(columns.map(column => column.name)); + + if (!columnNames.has("langCode")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN langCode TEXT`); + } + + if (!columnNames.has("aiProvider")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN aiProvider TEXT`); + } + + if (!columnNames.has("interfaceLanguage")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN interfaceLanguage TEXT DEFAULT 'default'`); + } + + if (!columnNames.has("aiResponseLanguage")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN aiResponseLanguage TEXT DEFAULT 'ru'`); + } + + if (!columnNames.has("aiContextSize")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN aiContextSize INTEGER`); + } + + if (!columnNames.has("aiVoiceMode")) { + await DatabaseManager.db.run(sql`ALTER TABLE users ADD COLUMN aiVoiceMode TEXT DEFAULT 'execute'`); } } -} \ No newline at end of file + + private static async createMessagesTable(): Promise { + await DatabaseManager.db.run(sql` + CREATE TABLE IF NOT EXISTS messages + ( + id INTEGER NOT NULL, + chatId INTEGER NOT NULL, + replyToMessageId INTEGER, + fromId INTEGER NOT NULL, + text TEXT, + date INTEGER NOT NULL, + photoMaxSizeFilePath TEXT, + attachments TEXT, + PRIMARY KEY (chatId, id) + ) + `); + } + + private static async ensureMessagesTable(): Promise { + let columns = await DatabaseManager.getTableInfo("messages"); + + if (!columns.length) { + await DatabaseManager.createMessagesTable(); + return; + } + + const hasAttachments = columns.some(column => column.name === "attachments"); + const idPk = columns.find(column => column.name === "id")?.pk ?? 0; + const chatIdPk = columns.find(column => column.name === "chatId")?.pk ?? 0; + const hasCompositeMessageKey = idPk > 0 && chatIdPk > 0; + + if (hasAttachments && hasCompositeMessageKey) { + return; + } + + await DatabaseManager.recreateMessagesTable(columns); + + columns = await DatabaseManager.getTableInfo("messages"); + if (!columns.some(column => column.name === "attachments")) { + throw new Error("Failed to ensure messages.attachments column."); + } + } + + private static async recreateMessagesTable(columns: TableInfoRow[]): Promise { + const legacyTable = `messages_legacy_${Date.now()}`; + const hasAttachments = columns.some(column => column.name === "attachments"); + const attachmentsSelect = hasAttachments ? "attachments" : "NULL AS attachments"; + + await DatabaseManager.db.run(sql.raw(`ALTER TABLE messages RENAME TO ${legacyTable}`)); + await DatabaseManager.createMessagesTable(); + await DatabaseManager.db.run(sql.raw(` + INSERT OR REPLACE INTO messages + (id, chatId, replyToMessageId, fromId, text, date, photoMaxSizeFilePath, attachments) + SELECT id, chatId, replyToMessageId, fromId, text, date, photoMaxSizeFilePath, ${attachmentsSelect} + FROM ${legacyTable} + `)); + await DatabaseManager.db.run(sql.raw(`DROP TABLE ${legacyTable}`)); + } +} diff --git a/src/db/database.ts b/src/db/database.ts index f913b18..40a7b5d 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -2,6 +2,8 @@ import * as fs from "fs"; import {Environment} from "../common/environment"; import {logError} from "../util/utils"; import {Answers} from "../model/answers"; +import path from "node:path"; +import {KeyedAsyncLock} from "../util/async-lock"; type DataJsonFile = { admins: number[] @@ -10,9 +12,42 @@ type DataJsonFile = { export let jsonFile: DataJsonFile; +const DEFAULT_DATA: DataJsonFile = { + admins: [], + muted: [], +}; + +const DEFAULT_ANSWERS: Answers = { + test: ["a"], + prefix: ["?"], + better: ["Better"], + who: [], + kick: [], + invite: [], + day: [], +}; + +const dataFileLock = new KeyedAsyncLock(); + +function ensureDataPath(): void { + fs.mkdirSync(Environment.DATA_PATH, {recursive: true}); +} + +function readJsonFile(fileName: string, defaultValue: T): T { + ensureDataPath(); + + const filePath = `${Environment.DATA_PATH}/${fileName}`; + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify(defaultValue, null, 2)); + return structuredClone(defaultValue); + } + + return JSON.parse(fs.readFileSync(filePath).toString()) as T; +} + export async function readData(): Promise { try { - jsonFile = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/data.json`).toString()); + jsonFile = readJsonFile("data.json", DEFAULT_DATA); const admins = jsonFile.admins || []; admins.unshift(Environment.CREATOR_ID); @@ -28,29 +63,37 @@ export async function readData(): Promise { } export async function saveData(): Promise { - const adminIds: number[] = []; - Environment.ADMIN_IDS.forEach(id => adminIds.push(id)); - jsonFile.admins = adminIds; + return dataFileLock.runExclusive("data.json", async () => { + ensureDataPath(); + jsonFile ??= structuredClone(DEFAULT_DATA); - const mutedList: number[] = []; - Environment.MUTED_IDS.forEach(id => mutedList.push(id)); - jsonFile.muted = mutedList; + const adminIds: number[] = []; + Environment.ADMIN_IDS.forEach(id => adminIds.push(id)); + jsonFile.admins = adminIds; - try { - fs.writeFileSync(`${Environment.DATA_PATH}/data.json`, JSON.stringify(jsonFile)); - return readData(); - } catch (e) { - return Promise.reject(e); - } + const mutedList: number[] = []; + Environment.MUTED_IDS.forEach(id => mutedList.push(id)); + jsonFile.muted = mutedList; + + try { + const filePath = path.join(Environment.DATA_PATH, "data.json"); + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(jsonFile)); + fs.renameSync(tmpPath, filePath); + return readData(); + } catch (e) { + return Promise.reject(e); + } + }); } export async function retrieveAnswers(): Promise { try { - const json: Answers = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/answers.json`).toString()); + const json = readJsonFile("answers.json", DEFAULT_ANSWERS); Environment.setAnswers(json); return Promise.resolve(); } catch (e) { logError(e); return Promise.reject(e); } -} \ No newline at end of file +} diff --git a/src/db/message-dao.ts b/src/db/message-dao.ts index 2eace4a..cf5e263 100644 --- a/src/db/message-dao.ts +++ b/src/db/message-dao.ts @@ -5,12 +5,14 @@ import {and, eq} from "drizzle-orm"; import {inArray} from "drizzle-orm/sql/expressions/conditions"; import {Dao} from "../base/dao"; import {buildExcludedSet} from "../util/utils"; +import {StoredAttachment} from "../model/stored-attachment"; export class MessageDao extends Dao { private tag: string = "MessageDao"; override async getAll(): Promise { + await DatabaseManager.ready; const then = Date.now(); const messages = await DatabaseManager.db.select().from(messagesTable); @@ -23,6 +25,7 @@ export class MessageDao extends Dao { } override async getById(params: { chatId: number, id: number }): Promise { + await DatabaseManager.ready; const then = Date.now(); const messages = @@ -45,6 +48,7 @@ export class MessageDao extends Dao { } override async getByIds(params: { chatId: number, ids: number[] }): Promise { + await DatabaseManager.ready; const then = Date.now(); const messages = @@ -65,13 +69,16 @@ export class MessageDao extends Dao { } async insert(values: MessageInsert[]): Promise { + if (!values.length) return true; + + await DatabaseManager.ready; const then = Date.now(); const r = await DatabaseManager.db .insert(messagesTable) .values(values) .onConflictDoUpdate({ - target: messagesTable.id, - set: buildExcludedSet(messagesTable, ["id"]) + target: [messagesTable.chatId, messagesTable.id], + set: buildExcludedSet(messagesTable, ["chatId", "id"]) }); const now = Date.now(); @@ -88,8 +95,10 @@ export class MessageDao extends Dao { replyToMessageId: msg.replyToMessageId, fromId: msg.fromId, text: msg.text, + quoteText: msg.quoteText, date: msg.date, photoMaxSizeFilePath: msg.photoMaxSizeFilePath?.join(";"), + attachments: msg.attachments?.length ? JSON.stringify(msg.attachments) : undefined, }; }); } @@ -102,9 +111,22 @@ export class MessageDao extends Dao { replyToMessageId: m.replyToMessageId || undefined, fromId: m.fromId, text: m.text, + quoteText: m.quoteText, date: m.date, - photoMaxSizeFilePath: m.photoMaxSizeFilePath?.split(";") + photoMaxSizeFilePath: m.photoMaxSizeFilePath?.split(";"), + attachments: parseAttachments(m.attachments), }; }); } -} \ No newline at end of file +} + +function parseAttachments(value?: string | null): StoredAttachment[] | undefined { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 9a4ef17..f8b0ba6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,24 +1,34 @@ -import {int, sqliteTable, text} from "drizzle-orm/sqlite-core"; +import {int, primaryKey, sqliteTable, text} from "drizzle-orm/sqlite-core"; export const messagesTable = sqliteTable("messages", { - id: int().primaryKey().unique().notNull(), + id: int().notNull(), chatId: int().notNull(), replyToMessageId: int(), fromId: int().notNull(), text: text(), + quoteText: text(), date: int().notNull(), photoMaxSizeFilePath: text(), -}); + attachments: text(), +}, table => [ + primaryKey({columns: [table.chatId, table.id]}), +]); export type MessageInsert = typeof messagesTable.$inferInsert; export const usersTable = sqliteTable("users", { - id: int().primaryKey().unique().notNull(), + id: int().primaryKey().notNull(), isBot: int().notNull(), firstName: text().notNull(), lastName: text(), userName: text(), isPremium: int(), + langCode: text(), + interfaceLanguage: text().default("default"), + aiProvider: text(), + aiResponseLanguage: text().default("ru"), + aiContextSize: int(), + aiVoiceMode: text().default("execute"), }); export type UserInsert = typeof usersTable.$inferInsert; diff --git a/src/db/user-dao.ts b/src/db/user-dao.ts index 914308e..e969968 100644 --- a/src/db/user-dao.ts +++ b/src/db/user-dao.ts @@ -12,6 +12,7 @@ export class UserDao extends Dao { private tag: string = "UserDao"; override async getAll(): Promise { + await DatabaseManager.ready; const then = Date.now(); const users = await DatabaseManager.db.select().from(usersTable); @@ -24,6 +25,7 @@ export class UserDao extends Dao { } override async getById(params: { id: number }): Promise { + await DatabaseManager.ready; const then = Date.now(); const users = @@ -43,6 +45,7 @@ export class UserDao extends Dao { } override async getByIds(params: { ids: number[] }): Promise { + await DatabaseManager.ready; const then = Date.now(); const users = @@ -60,7 +63,9 @@ export class UserDao extends Dao { } override async insert(values: UserInsert[] | UserInsert): Promise { + await DatabaseManager.ready; const rows = Array.isArray(values) ? values : [values]; + if (!rows.length) return true; const then = Date.now(); const r = await DatabaseManager.db @@ -68,7 +73,7 @@ export class UserDao extends Dao { .values(rows) .onConflictDoUpdate({ target: usersTable.id, - set: buildExcludedSet(usersTable, ["id"]) + set: buildExcludedSet(usersTable, ["id", "interfaceLanguage", "aiProvider", "aiResponseLanguage", "aiContextSize", "aiVoiceMode"]) }); const now = Date.now(); @@ -77,6 +82,28 @@ export class UserDao extends Dao { return true; } + async updateSettings( + id: number, + settings: Partial> + ): Promise { + await DatabaseManager.ready; + + const update: Partial = {}; + if ("interfaceLanguage" in settings) update.interfaceLanguage = settings.interfaceLanguage ?? null; + if ("aiProvider" in settings) update.aiProvider = settings.aiProvider ?? null; + if ("aiResponseLanguage" in settings) update.aiResponseLanguage = settings.aiResponseLanguage ?? null; + if ("aiContextSize" in settings) update.aiContextSize = settings.aiContextSize ?? null; + if ("aiVoiceMode" in settings) update.aiVoiceMode = settings.aiVoiceMode ?? null; + if (!Object.keys(update).length) return true; + + await DatabaseManager.db + .update(usersTable) + .set(update) + .where(eq(usersTable.id, id)); + + return true; + } + mapTo(users: User[]): UserInsert[] { return users.map(u => { return { @@ -85,21 +112,29 @@ export class UserDao extends Dao { firstName: u.first_name, lastName: u.last_name, userName: u.username, - isPremium: boolToInt(u.is_premium) + isPremium: boolToInt(u.is_premium), + langCode: u.language_code }; }); } mapFrom(users: UserInsert[]): StoredUser[] { + // @ts-ignore return users.map(u => { return { id: u.id, isBot: u.isBot === 1, firstName: u.firstName, - lastName: u.lastName, - userName: u.userName, - isPremium: u.isPremium === 1 + lastName: u.lastName === null ? undefined : u.lastName, + userName: u.userName === null ? undefined : u.userName, + isPremium: u.isPremium === 1, + langCode: u.langCode === null ? undefined : u.langCode, + interfaceLanguage: u.interfaceLanguage === null ? undefined : u.interfaceLanguage, + aiProvider: u.aiProvider === null ? undefined : u.aiProvider, + aiResponseLanguage: u.aiResponseLanguage === null ? undefined : u.aiResponseLanguage, + aiContextSize: u.aiContextSize === null ? undefined : u.aiContextSize, + aiVoiceMode: u.aiVoiceMode === null ? undefined : u.aiVoiceMode, }; }); } -} \ No newline at end of file +} diff --git a/src/model/stored-attachment.ts b/src/model/stored-attachment.ts new file mode 100644 index 0000000..5792188 --- /dev/null +++ b/src/model/stored-attachment.ts @@ -0,0 +1,11 @@ +export type StoredAttachmentKind = "image" | "document" | "audio" | "video" | "video-note"; + +export type StoredAttachment = { + kind: StoredAttachmentKind; + fileId: string; + fileUniqueId?: string; + fileName: string; + mimeType?: string; + cachePath: string; +}; + diff --git a/src/model/stored-message.ts b/src/model/stored-message.ts index 4b5088a..36452d6 100644 --- a/src/model/stored-message.ts +++ b/src/model/stored-message.ts @@ -1,9 +1,13 @@ +import {StoredAttachment} from "./stored-attachment"; + export type StoredMessage = { chatId: number; id: number; replyToMessageId?: number; fromId: number; text?: string | null; + quoteText?: string | null; date: number; photoMaxSizeFilePath?: string[] | null; -}; \ No newline at end of file + attachments?: StoredAttachment[] | null; +}; diff --git a/src/model/stored-user.ts b/src/model/stored-user.ts index bc58ae5..f63e0c0 100644 --- a/src/model/stored-user.ts +++ b/src/model/stored-user.ts @@ -2,7 +2,13 @@ export type StoredUser = { id: number; isBot: boolean; firstName: string; - lastName?: string | null; - userName?: string | null; + lastName?: string; + userName?: string; isPremium?: boolean; -} \ No newline at end of file + langCode?: string; + interfaceLanguage?: string; + aiProvider?: string; + aiResponseLanguage?: string; + aiContextSize?: number; + aiVoiceMode?: string; +}